From 5c5517e2e3211cff2e427bd3c4d7c9e6f2896538 Mon Sep 17 00:00:00 2001 From: Vincent Rubinetti Date: Fri, 12 Apr 2024 21:36:42 -0400 Subject: [PATCH 01/27] cloud func and misc cleanup --- .gitignore | 2 + frontend/src/api/{input.ts => api.ts} | 38 +++- frontend/src/api/submit.ts | 16 -- frontend/src/api/types.ts | 24 +-- frontend/src/components/Button.tsx | 17 +- frontend/src/components/Tabs.tsx | 4 +- frontend/src/pages/LoadAnalysis.tsx | 28 ++- frontend/src/pages/NewAnalysis.tsx | 25 ++- .../convert_ids/convert_ids_deploy/main.py | 90 ++++----- functions/ml/README.md | 131 +++++++++++++ functions/ml/ml_deploy.sh | 2 +- functions/ml/ml_deploy/main.py | 173 ++++++------------ local_runner/backend/entrypoint.sh | 2 +- 13 files changed, 327 insertions(+), 225 deletions(-) rename frontend/src/api/{input.ts => api.ts} (51%) delete mode 100644 frontend/src/api/submit.ts create mode 100644 functions/ml/README.md diff --git a/.gitignore b/.gitignore index db9c291..cfaa85f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ __pycache__/ # ignore sensitive items .env /.keys/* + +data diff --git a/frontend/src/api/input.ts b/frontend/src/api/api.ts similarity index 51% rename from frontend/src/api/input.ts rename to frontend/src/api/api.ts index 6aa86df..fa8eb04 100644 --- a/frontend/src/api/input.ts +++ b/frontend/src/api/api.ts @@ -1,13 +1,21 @@ import { api, request } from "@/api"; -import type { ConvertIds, Species } from "@/api/types"; +import type { AnalysisResults, ConvertIds, Input, Species } from "@/api/types"; /** convert input list of genes into entrez */ export const convertGeneIds = async ( - ids: string[], + genes: string[], species: Species = "Human", ) => { - const params = { geneids: ids, species }; - const response = await request(`${api}/gpz-convert-ids`, params); + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + + const params = { genes, species }; + + const response = await request( + `${api}/gpz-convert-ids`, + undefined, + { method: "POST", headers, body: JSON.stringify(params) }, + ); /** map "couldn't convert" status to easier-to-work-with value */ for (const row of response.df_convert_out) @@ -31,3 +39,25 @@ export const convertGeneIds = async ( return transformed; }; + +/** submit analysis */ +export const submitAnalysis = async (input: Input) => { + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + + const params = { + genes: input.genes, + net_type: input.network, + gsc: input.genesetContext, + sp_trn: input.species, + sp_tst: input.species, + }; + + const response = await request(`${api}/gpz-ml`, undefined, { + method: "POST", + headers, + body: JSON.stringify(params), + }); + + return response; +}; diff --git a/frontend/src/api/submit.ts b/frontend/src/api/submit.ts deleted file mode 100644 index 7ec21f4..0000000 --- a/frontend/src/api/submit.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { api, request } from "@/api"; -import type { AnalysisResults, Input } from "@/api/types"; - -/** submit analysis */ -export const submitAnalysis = async (input: Input) => { - const params = { - geneids: input.genes, - sp_trn: input.species, - sp_tst: input.species, - net_type: input.network, - gsc: input.genesetContext, - }; - const response = await request(`${api}/gpz-ml`, params); - - return response; -}; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 04b1d11..ffc092b 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1,5 +1,11 @@ export type ConvertIds = { + input_count: number; convert_ids: string[]; + table_summary: { + Network: string; + NetworkGenes: number; + PositiveGenes: number; + }[]; df_convert_out: { "Entrez ID": string; "In BioGRID?": string; @@ -7,16 +13,9 @@ export type ConvertIds = { "In STRING?": string; "Original ID": string; }[]; - input_count: number; - table_summary: { - Network: string; - NetworkGenes: number; - PositiveGenes: number; - }[]; }; export type AnalysisResults = { - avgps: number[]; df_convert_out_subset: { "Entrez ID": string; "In BioGRID?"?: string; @@ -24,12 +23,16 @@ export type AnalysisResults = { "In STRING?"?: string; "Original ID": string; }[]; + avgps: number[]; + positive_genes: number; + isolated_genes: string[]; + isolated_genes_sym: string[]; df_edge: { Node1: string; Node2: string }[]; df_edge_sym: { Node1: string; Node2: string }[]; df_probs: { - "Class-Label": string; + "Class-Label": "P" | "N" | "U"; Entrez: string; - "Known/Novel": string; + "Known/Novel": "Known" | "Novel"; Name: string; Probability: number; Rank: number; @@ -41,9 +44,6 @@ export type AnalysisResults = { Rank: number; Similarity: number; }[]; - isolated_genes: string[]; - isolated_genes_sym: string[]; - positive_genes: number; }; export type Species = diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index 5c71547..eaf0514 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -4,7 +4,7 @@ import type { ReactElement, ReactNode, } from "react"; -import { cloneElement, forwardRef } from "react"; +import { forwardRef } from "react"; import classNames from "classnames"; import { useForm } from "@/components/Form"; import Link from "@/components/Link"; @@ -59,10 +59,17 @@ const Button = forwardRef( ref, ) => { /** contents of main element */ - const children = [text, icon && cloneElement(icon, { className: "icon" })]; - - /** flip icon/text */ - if (flip) children.reverse(); + const children = flip ? ( + <> + {icon} + {text} + + ) : ( + <> + {icon} + {text} + + ); /** class name string */ const _class = classNames(className, classes.button, classes[design], { diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx index 6e1fff3..94a17af 100644 --- a/frontend/src/components/Tabs.tsx +++ b/frontend/src/components/Tabs.tsx @@ -1,5 +1,5 @@ import type { ReactElement, ReactNode } from "react"; -import { cloneElement, Fragment, useId } from "react"; +import { Fragment, useId } from "react"; import classNames from "classnames"; import { kebabCase } from "lodash"; import { StringParam, useQueryParam } from "use-query-params"; @@ -61,7 +61,7 @@ const Tabs = ({ syncWithUrl = "", children }: Props) => { type="button" > {tab.text} - {tab.icon && cloneElement(tab.icon, { className: "icon" })} + {tab.icon} ))} diff --git a/frontend/src/pages/LoadAnalysis.tsx b/frontend/src/pages/LoadAnalysis.tsx index 3acb2a8..a7006d4 100644 --- a/frontend/src/pages/LoadAnalysis.tsx +++ b/frontend/src/pages/LoadAnalysis.tsx @@ -1,11 +1,14 @@ import { Fragment, useEffect, useState } from "react"; import { FaArrowDown, + FaArrowRightToBracket, FaArrowUp, + FaChartBar, FaMagnifyingGlassChart, } from "react-icons/fa6"; import { useLocation } from "react-router"; -import { submitAnalysis } from "@/api/submit"; +import { startCase } from "lodash"; +import { submitAnalysis } from "@/api/api"; import type { AnalysisResults, Input } from "@/api/types"; import Alert from "@/components/Alert"; import Button from "@/components/Button"; @@ -51,6 +54,29 @@ const LoadAnalysis = () => { }> Load Analysis + + + {input && ( +
+ }> + Inputs + + +
+ {Object.entries(input).map(([key, value]) => ( + + {startCase(key)} + {Array.isArray(value) ? value.join(", ") : value} + + ))} +
+
+ )} + +
+ }> + Results +
{results && ( diff --git a/frontend/src/pages/NewAnalysis.tsx b/frontend/src/pages/NewAnalysis.tsx index d3c4e9b..076839f 100644 --- a/frontend/src/pages/NewAnalysis.tsx +++ b/frontend/src/pages/NewAnalysis.tsx @@ -1,7 +1,7 @@ import { Fragment, useEffect, useState } from "react"; +import { FaBeer } from "react-icons/fa"; import { FaArrowUp, - FaBacteria, FaCheck, FaDna, FaEye, @@ -17,7 +17,7 @@ import { import { GiFly, GiRat } from "react-icons/gi"; import { useNavigate } from "react-router"; import { useDebounce } from "use-debounce"; -import { convertGeneIds } from "@/api/input"; +import { convertGeneIds } from "@/api/api"; import type { GenesetContext, Input, Network, Species } from "@/api/types"; import Alert from "@/components/Alert"; import Button from "@/components/Button"; @@ -38,8 +38,14 @@ import { formatNumber } from "@/util/string"; import meta from "./meta.json"; import classes from "./NewAnalysis.module.css"; -const example = - "CASP3,CYP1A2,CYP1A1,NFE2L2,CYP2C19,CYP2D6,CYP7A1,NR1H4,TP53,CYP19A1"; +const example: Record = { + Human: "CASP3,CYP1A2,CYP1A1,NFE2L2,CYP2C19,CYP2D6,CYP7A1,NR1H4,TP53,CYP19A1", + Mouse: "Mpo,Inmt,Gnmt,Fos,Calr,Selenbp2,Rgn,Stat6,Etfa,Atp5f1b", + Fly: "SC35,Rbp1-like,x16,Rsf1,B52,norpA,SF2,Srp54k,Srp54,Rbp1", + Zebrafish: "upf1,dhx34,lsm1,xrn1,xrn2,lsm7,mrto4,pnrc2,lsm4,nbas", + Worm: "egl-26,cas-1,exc-5,gex-3,gex-2,sax-2,cas-2,mig-6,cap-2,rhgf-2", + Yeast: "KL1,GND1,GND2,ZWF1,TKL2,RKI1,RPE1,SOL4,TAL1,SOL3", +}; const speciesOptions: SelectOption[] = [ { id: "Human", text: "Human", icon: }, @@ -47,7 +53,7 @@ const speciesOptions: SelectOption[] = [ { id: "Fly", text: "Fly", icon: }, { id: "Zebrafish", text: "Zebrafish", icon: }, { id: "Worm", text: "Worm", icon: }, - { id: "Yeast", text: "Yeast", icon: }, + { id: "Yeast", text: "Yeast", icon: }, ] as const; const networkOptions: RadioOption[] = [ @@ -216,7 +222,8 @@ const NewAnalysis = () => {
@@ -357,7 +364,7 @@ const NewAnalysis = () => { onChange={setGenesetContext} label="Geneset Context" options={genesetContextOptions} - tooltip="Source used to select negative genes and which sets to compare the trained model to." + tooltip="Source used to select negative genes and which sets to compare the trained model to" /> @@ -367,7 +374,7 @@ const NewAnalysis = () => { options={filteredSpeciesOptions} value={species} onChange={setSpecies} - tooltip="The species for which model predictions will be made." + tooltip="The species for which model predictions will be made" /> diff --git a/functions/convert_ids/convert_ids_deploy/main.py b/functions/convert_ids/convert_ids_deploy/main.py index d334926..decc9b0 100644 --- a/functions/convert_ids/convert_ids_deploy/main.py +++ b/functions/convert_ids/convert_ids_deploy/main.py @@ -1,75 +1,51 @@ import functions_framework import geneplexus + @functions_framework.http def convert_ids(request): - """HTTP Cloud Function. - set function up for CORS headers so function can be called from js - """ + normalize gene symbols/names/ids to entrez ids + """ + + # handle preflight request if request.method == "OPTIONS": headers = { "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Methods": "POST", "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Max-Age": "3600", } return ("", 204, headers) - - # Set CORS headers for the main request + + # set CORS headers for main request headers = {"Access-Control-Allow-Origin": "*"} - - """"Function Inputs - geneids: if request_json list of strings - if request_args genes are in comma separated string - species: str; options are ["Human","Mouse","Fly","Zebrafish","Worm","Yeast"] - - """ - + try: - # get info from the requests - request_json = request.get_json(silent=True) - request_args = request.args - # gene ids must be comma separated in request - if request_json: - geneids = request_json["geneids"] - input_genes = geneids - species = request_json["species"] - elif request_args: - geneids = request_args["geneids"] - input_genes = geneids.strip().split(",") - species = request_args["species"] + # parse request body params + body = request.get_json() + genes = body["genes"] + species = body["species"] except: - return "problem with input" - - """"Function Outputs - convert_ids: List[str] Entrez IDs in string format - input_count: int The number of user input genes - table_summary: List of Dicts. Each element of the list is a - different network - Keys: Network: str options ["BioGRID","STRING","IMP"] - Note if species == Zebrafish, BioGRID is not included - NetworkGenes: int Number of genes in network - Positive Genes: int Number of user genes in network - df_convert_out: Dict repreenstation of a dataframe - Keys: columns: List[str] column headers of the data frame - note: if species == Zebrafish the "In BioGRID" - will be missing - data: List of Lists[str] the data the files the dataframe - index: List[str] row indicies (not used at any point) - - """ - + message = "Problem with input headers, body, or params." + return ({"message": message}, 400, headers) + try: - # set the gp object - # here net type, features and gsc are never used for gene conversion + # set up geneplexus + # other params (e.g. network, features, gsc) not needed for gene conversion gp = geneplexus.GenePlexus(file_loc="data", sp_trn=species) - gp.load_genes(input_genes) - json_out = {} - json_out["convert_ids"] = gp.convert_ids - json_out["table_summary"] = gp.table_summary - json_out["input_count"] = gp.input_count - json_out["df_convert_out"] = gp.df_convert_out.to_dict(orient="records") - return (json_out, 200, headers) - except: - return "problem with geneplexus" + + # load and convert genes + gp.load_genes(genes) + + # format response + response = {} + response["convert_ids"] = gp.convert_ids + response["table_summary"] = gp.table_summary + response["input_count"] = gp.input_count + response["df_convert_out"] = gp.df_convert_out.to_dict(orient="records") + + return (response, 200, headers) + except Exception as error: + message = f"Error running GenePlexus:\n\n{error}" + return ({"message": message}, 400, headers) diff --git a/functions/ml/README.md b/functions/ml/README.md new file mode 100644 index 0000000..9e26279 --- /dev/null +++ b/functions/ml/README.md @@ -0,0 +1,131 @@ +# General + +Unless noted otherwise, each API method expects: + +- `Content-Type: application/json` header. +- `POST` method +- Request body with JSON-encoded string of appropriate params + +## `/gpz-convert-ids` + +#### Inputs + +```ts +type request = { + // list of gene symbols/names/ids + genes: string[]; + // species to lookup genes against + species: "Human" | "Mouse" | "Fly" | "Zebrafish" | "Worm" | "Yeast"; +}; +``` + +#### Outputs + +```ts +type response = { + // number of genes inputted + input_count: int; + // list of successfully converted Entrez IDs + convert_ids: string[]; + // high level summary of conversion results, per network + table_summary: { + // network + Network: "BioGRID" | "STRING" | "IMP"; + // total number of genes in network + NetworkGenes: int; + // number of input genes in network + PositiveGenes: int; + }[]; + // dataframe of results + df_convert_out: { + // converted id of gene + "Entrez ID": string; + // input id of gene + "Original ID": string; + // whether gene was found in each network + "In BioGRID?": "Y" | "N"; + "In IMP?": "Y" | "N"; + "In STRING?": "Y" | "N"; + }[]; +}; +``` + +Note: if input species is Zebrafish, BioGRID is not included in results + +## `/gpz-convert-ids` + +#### Inputs + +```ts +type request = { + // list of gene symbols/names/ids + genes: string[]; + // species to lookup genes against + sp_trn: "Human" | "Mouse" | "Fly" | "Zebrafish" | "Worm" | "Yeast"; + // species for which model predictions will be made + sp_test: "Human" | "Mouse" | "Fly" | "Zebrafish" | "Worm" | "Yeast"; + // source used to select negative genes and which sets to compare trained model to + gsc: "GO" | "Monarch" | "DisGeNet" | "Combined"; + // network that ML features are from and which edge list is used to make final graph + net_type: "BioGRID" | "STRING" | "IMP"; +}; +``` + +#### Outputs + +```ts +type Response = { + // see `convert-ids` `df_convert_out` schema + df_convert_out_subset: { + "Original ID": string; + "Entrez ID": string; + // only one of these present, based on selected network + "In BioGRID?"?: string; + "In IMP?"?: string; + "In STRING?"?: string; + }[]; + + // cross validation results, performance measured using log2(auprc/prior) + avgps: int[]; + + // number of genes considered positives in network + positive_genes: number; + // top predicted genes that are isolated from other top predicted genes in network (as Entrez IDs) + isolated_genes: string[]; + // top predicted genes that are isolated from other top predicted genes in network (as gene symbols) + isolated_genes_sym: string[]; + + // edge list corresponding to subgraph induced by top predicted genes (as Entrez IDs) + df_edge: { Node1: string; Node2: string }[]; + // edge list corresponding to subgraph induced by top predicted genes (as gene symbols) + df_edge_sym: { Node1: string; Node2: string }[]; + + df_probs: { + // Entrez ID + "Entrez": string; + // full gene name + "Name": string; + // gene symbol + "Symbol": string; + // whether gene is in input gene list + "Known/Novel": "Known" | "Novel"; + // gene class, positive | negative | neutraul + "Class-Label": "P" | "N" | "U"; + // probability of gene being part of input gene list + "Probability": number; + // rank of relevance of gene to input gene list + "Rank": int; + }[]; + + df_sim: { + // term ID + ID: string; + // term name + Name: string; + // similarity between input model and a model trained on term gene set + Similarity: number; + // rank of similarity between input model and a model trained on term gene set + Rank: int; + }[]; +}; +``` diff --git a/functions/ml/ml_deploy.sh b/functions/ml/ml_deploy.sh index 8ad1487..5c9a5c9 100755 --- a/functions/ml/ml_deploy.sh +++ b/functions/ml/ml_deploy.sh @@ -7,7 +7,7 @@ gcloud functions deploy gpz-ml \ --runtime=python311 \ --region=us-central1 \ --source=. \ - --entry-point=run_pipeline \ + --entry-point=ml \ --trigger-http \ --allow-unauthenticated \ --memory=8192MB \ diff --git a/functions/ml/ml_deploy/main.py b/functions/ml/ml_deploy/main.py index 89cbdf7..06f9c28 100755 --- a/functions/ml/ml_deploy/main.py +++ b/functions/ml/ml_deploy/main.py @@ -3,133 +3,72 @@ @functions_framework.http -def run_pipeline(request): - """HTTP Cloud Function. - set function up for CORS headers so function can be called from js - +def ml(request): """ + run full analysis with geneplexus + """ + + # handle preflight request if request.method == "OPTIONS": headers = { "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Methods": "POST", "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Max-Age": "3600", } return ("", 204, headers) - - # Set CORS headers for the main request + + # set CORS headers for main request headers = {"Access-Control-Allow-Origin": "*"} - - """"Function Inputs - geneids: if request_json list of strings - if request_args genes are in comma separated string - Note: should just be original input genes as they will be converted - again - gsc: str; option are ["GO","Monarch","DisGeNet","Combined"] - this will be used to select negative genes and which sets - to compare trained model to - Note: DisGeNet is only available for Human - Note: Combined is including GO, Monarch together (plus - DisGeNet for human) - net_type: str; options are ["BioGRID","STRING","IMP"] - network for which the ML features are from and which - edgelist is used to make the final graph - sp_trn: str; options are ["Human","Mouse","Fly","Zebrafish","Worm","Yeast"] - this is same species in which user supplies the gene ids for - Note: If net_type == BioGRID, Zebrafish is not allowed - sp_tst: str; options are ["Human","Mouse","Fly","Zebrafish","Worm","Yeast"] - this is the species for which model predictions will be made - Note: If net_type == BioGRID, Zebrafish is not allowed - - """ - + try: - # get info from the requests - request_json = request.get_json(silent=True) - request_args = request.args - # gene ids must be comma separated in request - if request_json: - geneids = request_json["geneids"] - input_genes = geneids - gsc = request_json["gsc"] - net_type = request_json["net_type"] - sp_trn = request_json["sp_trn"] - sp_tst = request_json["sp_tst"] - elif request_args: - geneids = request_args["geneids"] - input_genes = geneids.strip().split(",") - gsc = request_args["gsc"] - net_type = request_args["net_type"] - sp_trn = request_args["sp_trn"] - sp_tst = request_args["sp_tst"] + # parse request body params + request_json = request.get_json() + genes = request_json["genes"] + gsc = request_json["gsc"] + net_type = request_json["net_type"] + sp_trn = request_json["sp_trn"] + sp_tst = request_json["sp_tst"] except: - return "problem with input" - - """"Function Outputs - :attr:`GenePlexus.avgps` (array of float) - Cross validation results. Performance is measured using - log2(auprc/prior). - :attr:`GenePlexus.df_probs` (DataFrame) - A table with 7 columns: **Entrez** (the gene Entrez ID), **Symbol** - (the gene Symbol), **Name** (the gene Name), **Probability** (the - probability of a gene being part of the input gene list), - **Known/Novel** (whether the gene is in the input gene list), - **Class-Label** (positive, negative, or neutral), **Rank** (rank of - relevance of the gene to the input gene list). - Note: If sp_trn != sp_tst then Known/Novel and Class-Label columns - are not included - :attr:`GenePlexus.df_sim` (DataFrame) - A table with 4 columns: **ID** (the term ID), **Name** (name of - the term), **Similarity** (similarity between the input model - and a model trained on the term gene set), **Rank** (rank of - similarity between the input model and a model trained on the - term gene set). - :attr:`GenePlexus.df_edge` (DataFrame) - Table of edge list corresponding to the subgraph induced by the top - predicted genes (in Entrez gene ID). - :attr:`GenePlexus.isolated_genes` (List[str]) - List of top predicted genes (in Entrez gene ID) that are isolated - from other top predicted genes in the network. - :attr:`GenePlexus.df_edge_sym` (DataFrame) - Table of edge list corresponding to the subgraph induced by the top - predicted genes (in gene symbol). - :attr:`GenePlexus.isolated_genes_sym` (List[str]) - List of top predicted genes (in gene symbol) that are isolated from - other top predicted genes in the network. - :attr:`GenePlexus.df_convert_out_subset` (Dataframe) - Three columns: **Original ID** (user supplied gene ID), **Entrez ID** - (ID converted to Entrez if possible), ** In net_type ** (the column for - which the network was used to run the model) - :attr:`GenePlexus.isolated_genes_sym` (List[str]) - List of genes considered positives in the network. - - """ + message = "Problem with input headers, body, or params." + return ({"message": message}, 400, headers) try: - # set the gp object - gp = geneplexus.GenePlexus(file_loc = "data", - gsc_trn = gsc, - gsc_tst = gsc, - features = "SixSpeciesN2V", - net_type = net_type, - sp_trn = sp_trn, - sp_tst = sp_tst) - gp.load_genes(input_genes) - mdl_weights, df_probs, avgps = gp.fit_and_predict() - df_sim, weights_dict = gp.make_sim_dfs() - df_edge, isolated_genes, df_edge_sym, isolated_genes_sym = gp.make_small_edgelist() - df_convert_out_subset, positive_genes = gp.alter_validation_df() - # save outputs in json format - json_out = {} - json_out["avgps"] = gp.avgps - json_out["df_probs"] = gp.df_probs.to_dict(orient="records") - json_out["df_sim"] = gp.df_sim.to_dict(orient="records") - json_out["df_edge"] = gp.df_edge.to_dict(orient="records") - json_out["isolated_genes"] = gp.isolated_genes - json_out["df_edge_sym"] = gp.df_edge_sym.to_dict(orient="records") - json_out["isolated_genes_sym"] = gp.isolated_genes_sym - json_out["df_convert_out_subset"] = gp.df_convert_out_subset.to_dict(orient="records") - json_out["positive_genes"] = gp.positive_genes - return (json_out, 200, headers) - except: - return ("problem with geneplexus", 500, headers) + # set up geneplexus + gp = geneplexus.GenePlexus( + file_loc="data", + gsc_trn=gsc, + gsc_tst=gsc, + features="SixSpeciesN2V", + net_type=net_type, + sp_trn=sp_trn, + sp_tst=sp_tst, + ) + + # load and convert genes + gp.load_genes(genes) + + # run full analysis + gp.fit_and_predict() + gp.make_sim_dfs() + gp.make_small_edgelist() + gp.alter_validation_df() + + # format response + response = {} + response["avgps"] = gp.avgps + response["df_probs"] = gp.df_probs.to_dict(orient="records") + response["df_sim"] = gp.df_sim.to_dict(orient="records") + response["df_edge"] = gp.df_edge.to_dict(orient="records") + response["isolated_genes"] = gp.isolated_genes + response["df_edge_sym"] = gp.df_edge_sym.to_dict(orient="records") + response["isolated_genes_sym"] = gp.isolated_genes_sym + response["df_convert_out_subset"] = gp.df_convert_out_subset.to_dict( + orient="records" + ) + response["positive_genes"] = gp.positive_genes + + return (response, 200, headers) + except Exception as error: + message = f"Error running GenePlexus:\n\n{error}" + return ({"message": message}, 500, headers) diff --git a/local_runner/backend/entrypoint.sh b/local_runner/backend/entrypoint.sh index 287422f..a00ff48 100755 --- a/local_runner/backend/entrypoint.sh +++ b/local_runner/backend/entrypoint.sh @@ -15,7 +15,7 @@ function launch_and_watch() { # launch each function in a separate process and background it ( launch_and_watch /app/functions/convert_ids/convert_ids_deploy convert_ids 8080) & -( launch_and_watch /app/functions/ml/ml_deploy run_pipeline 8081 ) & +( launch_and_watch /app/functions/ml/ml_deploy ml 8081 ) & # run caddy to serve the functions we're watching from a single origin /usr/bin/caddy run --environ --config /etc/caddy/Caddyfile From 1ad74168097e05040c753ccf6342ae134bdfd1ac Mon Sep 17 00:00:00 2001 From: Vincent Rubinetti Date: Sat, 13 Apr 2024 14:24:48 -0400 Subject: [PATCH 02/27] fix netlify mock url flag --- frontend/src/main.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index afae114..3e50c1b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,7 +6,11 @@ console.debug(import.meta); (async () => { /** mock api */ - if (new URL(window.location.href).searchParams.get("mock") === "true") { + if ( + new URL( + window.sessionStorage.redirect || window.location.href, + ).searchParams.get("mock") === "true" + ) { const { setupWorker } = await import("msw/browser"); const { handlers } = await import("../fixtures"); await setupWorker(...handlers).start({ From 13d076c761bb3b333a3d48e17fc1a2e2da4ea9bc Mon Sep 17 00:00:00 2001 From: Vincent Rubinetti Date: Mon, 15 Apr 2024 10:40:33 -0400 Subject: [PATCH 03/27] move readme up folder --- functions/{ml => }/README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename functions/{ml => }/README.md (100%) diff --git a/functions/ml/README.md b/functions/README.md similarity index 100% rename from functions/ml/README.md rename to functions/README.md From f040ab01c066f25b4b59194f716e9ee776fb79fc Mon Sep 17 00:00:00 2001 From: Christopher Andrew Mancuso Date: Mon, 15 Apr 2024 10:56:59 -0400 Subject: [PATCH 04/27] updated readme for gcp functions.md --- functions/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/functions/README.md b/functions/README.md index 9e26279..e135528 100644 --- a/functions/README.md +++ b/functions/README.md @@ -52,7 +52,7 @@ type response = { Note: if input species is Zebrafish, BioGRID is not included in results -## `/gpz-convert-ids` +## `/gpz-ml` #### Inputs @@ -100,6 +100,7 @@ type Response = { // edge list corresponding to subgraph induced by top predicted genes (as gene symbols) df_edge_sym: { Node1: string; Node2: string }[]; + // table showing how associated each gene in prediction species network is to the users gene list df_probs: { // Entrez ID "Entrez": string; @@ -117,6 +118,7 @@ type Response = { "Rank": int; }[]; + // table showing how similar user's trained model is to models trained on known gene sets df_sim: { // term ID ID: string; From 1d193e84773f77a5196a57474c732bfbad326ba7 Mon Sep 17 00:00:00 2001 From: Vincent Rubinetti Date: Mon, 15 Apr 2024 12:49:31 -0400 Subject: [PATCH 05/27] pass inputs back --- functions/README.md | 9 ++++++--- functions/ml/ml_deploy/main.py | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/functions/README.md b/functions/README.md index e135528..3db0916 100644 --- a/functions/README.md +++ b/functions/README.md @@ -11,7 +11,7 @@ Unless noted otherwise, each API method expects: #### Inputs ```ts -type request = { +type Request = { // list of gene symbols/names/ids genes: string[]; // species to lookup genes against @@ -22,7 +22,7 @@ type request = { #### Outputs ```ts -type response = { +type Response = { // number of genes inputted input_count: int; // list of successfully converted Entrez IDs @@ -57,7 +57,7 @@ Note: if input species is Zebrafish, BioGRID is not included in results #### Inputs ```ts -type request = { +type Request = { // list of gene symbols/names/ids genes: string[]; // species to lookup genes against @@ -75,6 +75,9 @@ type request = { ```ts type Response = { + // copy of inputs for re-uploading convenience + input: Request; + // see `convert-ids` `df_convert_out` schema df_convert_out_subset: { "Original ID": string; diff --git a/functions/ml/ml_deploy/main.py b/functions/ml/ml_deploy/main.py index 06f9c28..bf912dd 100755 --- a/functions/ml/ml_deploy/main.py +++ b/functions/ml/ml_deploy/main.py @@ -56,6 +56,7 @@ def ml(request): # format response response = {} + response["input"] = request_json response["avgps"] = gp.avgps response["df_probs"] = gp.df_probs.to_dict(orient="records") response["df_sim"] = gp.df_sim.to_dict(orient="records") From ca6de0124b08c54d012d739df28f38d4b0be1730 Mon Sep 17 00:00:00 2001 From: Vincent Rubinetti Date: Mon, 15 Apr 2024 12:53:33 -0400 Subject: [PATCH 06/27] add input to fixture data --- frontend/fixtures/ml.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/frontend/fixtures/ml.json b/frontend/fixtures/ml.json index 7bd0eac..d6662bc 100644 --- a/frontend/fixtures/ml.json +++ b/frontend/fixtures/ml.json @@ -1,4 +1,22 @@ { + "input": { + "genes": [ + "836", + "1544", + "1543", + "4780", + "1557", + "1565", + "1581", + "9971", + "7157", + "1588" + ], + "sp_trn": "Human", + "sp_test": "Human", + "net_type": "BioGRID", + "gsc": "GO" + }, "avgps": [-10, -10, -10], "df_convert_out_subset": [ { From aa07d3a65fd4cf84e09ea16aa2f89597d7aa5568 Mon Sep 17 00:00:00 2001 From: Vincent Rubinetti Date: Mon, 15 Apr 2024 15:01:30 -0400 Subject: [PATCH 07/27] changes --- frontend/bun.lockb | Bin 228669 -> 229076 bytes frontend/fixtures/index.ts | 22 ++- frontend/fixtures/ml.json | 3 +- frontend/package.json | 2 +- frontend/src/App.tsx | 6 +- frontend/src/api/api.ts | 29 ++-- frontend/src/api/types.ts | 47 +++++- frontend/src/components/Header.tsx | 2 +- frontend/src/components/Heading.module.css | 4 +- frontend/src/components/Section.module.css | 8 + frontend/src/components/Section.tsx | 13 +- frontend/src/global/layout.css | 11 +- frontend/src/global/styles.css | 6 +- frontend/src/pages/Analysis.module.css | 27 ++++ frontend/src/pages/Analysis.tsx | 161 +++++++++++++++++++++ frontend/src/pages/Home.tsx | 2 +- frontend/src/pages/LoadAnalysis.tsx | 141 ------------------ frontend/src/pages/NewAnalysis.tsx | 86 +++++++---- functions/README.md | 6 +- 19 files changed, 355 insertions(+), 221 deletions(-) create mode 100644 frontend/src/pages/Analysis.module.css create mode 100644 frontend/src/pages/Analysis.tsx delete mode 100644 frontend/src/pages/LoadAnalysis.tsx diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 91aa818318a51d8faa6803b0559b26485c647b2a..1ffc76c8bf56fcf92aa8eea9e7a43b7d5f4891fa 100755 GIT binary patch delta 43425 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 diff --git a/frontend/fixtures/index.ts b/frontend/fixtures/index.ts index 0679d59..d7dd976 100644 --- a/frontend/fixtures/index.ts +++ b/frontend/fixtures/index.ts @@ -1,15 +1,23 @@ -import { http, HttpResponse, passthrough } from "msw"; +import { + http, + HttpResponse, + passthrough, + type HttpResponseResolver, +} from "msw"; import convertIds from "../fixtures/convert-ids.json"; import ml from "../fixtures/ml.json"; +const nonMocked: HttpResponseResolver = ({ request }) => { + console.debug("Non-mocked request", new URL(request.url).pathname); + return passthrough(); +}; + /** api calls to be mocked (faked) with fixture data */ export const handlers = [ - http.get("*/gpz-convert-ids", () => HttpResponse.json(convertIds)), - http.get("*/gpz-ml", () => HttpResponse.json(ml)), + http.post("*/gpz-convert-ids", () => HttpResponse.json(convertIds)), + http.post("*/gpz-ml", () => HttpResponse.json(ml)), /** any other request */ - http.get(/.*/, ({ request }) => { - console.debug("Non-mocked request", new URL(request.url).pathname); - return passthrough(); - }), + http.get(/.*/, nonMocked), + http.post(/.*/, nonMocked), ]; diff --git a/frontend/fixtures/ml.json b/frontend/fixtures/ml.json index d6662bc..3dbc255 100644 --- a/frontend/fixtures/ml.json +++ b/frontend/fixtures/ml.json @@ -1,5 +1,6 @@ { "input": { + "name": "Test Analysis", "genes": [ "836", "1544", @@ -13,7 +14,7 @@ "1588" ], "sp_trn": "Human", - "sp_test": "Human", + "sp_tst": "Human", "net_type": "BioGRID", "gsc": "GO" }, diff --git a/frontend/package.json b/frontend/package.json index 77c5834..b5e2e87 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "test:lint": "./lint.sh", "test:types": "tsc", "test:spelling": "bunx cspell src/**/*.{tsx,ts}", - "clean": "rm -rf node_modules bun.lockb && bun pm cache rm" + "clean": "rm -rf node_modules dist bun.lockb && bun pm cache rm" }, "dependencies": { "@tanstack/react-query": "^5.29.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6ab4ac8..68d91d3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,7 +24,7 @@ import TableOfContents from "@/components/TableOfContents"; import Toasts from "@/components/Toasts"; import About from "@/pages/About"; import Home from "@/pages/Home"; -import LoadAnalysis from "@/pages/LoadAnalysis"; +import Analysis from "@/pages/Analysis"; import NewAnalysis from "@/pages/NewAnalysis"; import NotFound from "@/pages/NotFound"; import Testbed from "@/pages/Testbed"; @@ -107,8 +107,8 @@ const routes = [ loader: () => ({ toc: true }) satisfies Meta, }, { - path: "load-analysis", - element: , + path: "analysis", + element: , loader: () => ({ toc: true }) satisfies Meta, }, { diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index fa8eb04..2af84a6 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -1,5 +1,11 @@ import { api, request } from "@/api"; -import type { AnalysisResults, ConvertIds, Input, Species } from "@/api/types"; +import { + revertInput, + type _AnalysisResults, + type _ConvertIds, + type AnalysisInput, + type Species, +} from "@/api/types"; /** convert input list of genes into entrez */ export const convertGeneIds = async ( @@ -11,7 +17,7 @@ export const convertGeneIds = async ( const params = { genes, species }; - const response = await request( + const response = await request<_ConvertIds>( `${api}/gpz-convert-ids`, undefined, { method: "POST", headers, body: JSON.stringify(params) }, @@ -27,7 +33,11 @@ export const convertGeneIds = async ( count: response.input_count, success: response.df_convert_out.filter((row) => row["Entrez ID"]).length, error: response.df_convert_out.filter((row) => !row["Entrez ID"]).length, - summary: response.table_summary, + summary: response.table_summary.map((row) => ({ + network: row.Network, + positiveGenes: row.PositiveGenes, + totalGenes: row.NetworkGenes, + })), table: response.df_convert_out.map((row) => ({ input: row["Original ID"], entrez: row["Entrez ID"], @@ -41,19 +51,12 @@ export const convertGeneIds = async ( }; /** submit analysis */ -export const submitAnalysis = async (input: Input) => { +export const submitAnalysis = async (input: AnalysisInput) => { const headers = new Headers(); headers.append("Content-Type", "application/json"); - const params = { - genes: input.genes, - net_type: input.network, - gsc: input.genesetContext, - sp_trn: input.species, - sp_tst: input.species, - }; - - const response = await request(`${api}/gpz-ml`, undefined, { + const params = revertInput(input); + const response = await request<_AnalysisResults>(`${api}/gpz-ml`, undefined, { method: "POST", headers, body: JSON.stringify(params), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index ffc092b..19d01ec 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1,4 +1,5 @@ -export type ConvertIds = { +/** response format from convert ids api endpoint */ +export type _ConvertIds = { input_count: number; convert_ids: string[]; table_summary: { @@ -15,7 +16,9 @@ export type ConvertIds = { }[]; }; -export type AnalysisResults = { +/** response format from ml analysis api endpoint */ +export type _AnalysisResults = { + input: _AnalysisInput; df_convert_out_subset: { "Entrez ID": string; "In BioGRID?"?: string; @@ -46,6 +49,7 @@ export type AnalysisResults = { }[]; }; +/** species options */ export type Species = | "Human" | "Mouse" @@ -54,13 +58,48 @@ export type Species = | "Worm" | "Yeast"; +/** network options */ export type Network = "BioGRID" | "STRING" | "IMP"; +/** geneset context options */ export type GenesetContext = "GO" | "Monarch" | "DisGeNet" | "Combined"; -export type Input = { +/** input format for ml analysis api endpoint */ +export type _AnalysisInput = { + name: string; genes: string[]; - species: Species; + sp_trn: Species; + sp_tst: Species; + net_type: Network; + gsc: GenesetContext; +}; + +/** analysis input format for frontend */ +export type AnalysisInput = { + name: string; + genes: string[]; + speciesTrain: Species; + speciesTest: Species; network: Network; genesetContext: GenesetContext; }; + +/** transform analysis input from backend format to frontend format */ +export const convertInput = (input: _AnalysisInput): AnalysisInput => ({ + name: input.name, + genes: input.genes, + speciesTrain: input.sp_trn, + speciesTest: input.sp_tst, + network: input.net_type, + genesetContext: input.gsc, +}); + +/** transform analysis input from frontend format to backend format */ +export const revertInput = (input: AnalysisInput): _AnalysisInput => ({ + name: input.name, + genes: input.genes, + sp_trn: input.speciesTrain, + sp_tst: input.speciesTest, + net_type: input.network, + gsc: input.genesetContext, +}); diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index c127d3d..4d80621 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -51,7 +51,7 @@ const Header = () => { New Analysis - + Load Analysis diff --git a/frontend/src/components/Heading.module.css b/frontend/src/components/Heading.module.css index 0f5452a..79dc73c 100644 --- a/frontend/src/components/Heading.module.css +++ b/frontend/src/components/Heading.module.css @@ -6,11 +6,11 @@ } h1.heading, -h2.heading { +h2.heading, +h3.heading { justify-content: center; } -h3.heading, h4.heading { justify-content: flex-start; } diff --git a/frontend/src/components/Section.module.css b/frontend/src/components/Section.module.css index 952bff3..1b7685f 100644 --- a/frontend/src/components/Section.module.css +++ b/frontend/src/components/Section.module.css @@ -1,8 +1,16 @@ .section { + display: flex; + flex-direction: column; + align-items: center; padding: 60px max(40px, (100% - var(--content)) / 2); + gap: 40px; background: var(--white); } +.section:last-child { + flex-grow: 1; +} + .section:nth-child(even) { background: #fafafa; } diff --git a/frontend/src/components/Section.tsx b/frontend/src/components/Section.tsx index e1f9e15..51678bd 100644 --- a/frontend/src/components/Section.tsx +++ b/frontend/src/components/Section.tsx @@ -21,15 +21,10 @@ type Props = { const Section = ({ fill, full, className, ...props }: Props) => { return (
    ); diff --git a/frontend/src/global/layout.css b/frontend/src/global/layout.css index aabff01..1656481 100644 --- a/frontend/src/global/layout.css +++ b/frontend/src/global/layout.css @@ -70,23 +70,18 @@ gap: 40px 60px; } -/** let flex/grid gap handle spacing of children */ - -:is(.flex-row, .flex-col, .grid) > * { - margin: 0; -} - /** simple two-col auto-sized table of key/value pairs */ .mini-table { display: grid; - grid-template-columns: auto auto; - align-items: center; + grid-template-columns: max-content auto; + grid-auto-rows: minmax(min-content, max-content); gap: 5px 20px; text-align: left; } .mini-table > :nth-child(odd) { + max-width: 200px; color: var(--gray); } diff --git a/frontend/src/global/styles.css b/frontend/src/global/styles.css index e936ce5..4576d0a 100644 --- a/frontend/src/global/styles.css +++ b/frontend/src/global/styles.css @@ -25,7 +25,9 @@ body { } main { + display: flex; flex-grow: 1; + flex-direction: column; } /** word breaking */ @@ -45,6 +47,7 @@ h2, h3, h4 { width: 100%; + margin: 0; color: var(--deep); font-weight: var(--bold); } @@ -61,7 +64,7 @@ h2 { h3 { font-size: 1.1rem; - text-align: left; + text-align: center; } h4 { @@ -193,6 +196,7 @@ button { padding: 0; border: none; background: none; + color: var(--accent); font: inherit; cursor: pointer; } diff --git a/frontend/src/pages/Analysis.module.css b/frontend/src/pages/Analysis.module.css new file mode 100644 index 0000000..37ab3d8 --- /dev/null +++ b/frontend/src/pages/Analysis.module.css @@ -0,0 +1,27 @@ +.inputs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 40px; +} + +.inputs > * { + max-width: max-content; +} + +.genes { + display: flex; + flex-direction: column; + gap: 20px; +} + +.genes-list { + display: flex; + flex-wrap: wrap; + gap: 5px 15px; +} + +@media (max-width: 600px) { + .inputs { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx new file mode 100644 index 0000000..ba80ee7 --- /dev/null +++ b/frontend/src/pages/Analysis.tsx @@ -0,0 +1,161 @@ +import { useEffect, useState } from "react"; +import { + FaArrowDown, + FaArrowRightToBracket, + FaArrowUp, + FaChartBar, + FaMagnifyingGlassChart, +} from "react-icons/fa6"; +import { useLocation } from "react-router"; +import { submitAnalysis } from "@/api/api"; +import { + convertInput, + type _AnalysisResults, + type AnalysisInput, +} from "@/api/types"; +import Alert from "@/components/Alert"; +import Button from "@/components/Button"; +import Heading from "@/components/Heading"; +import Meta from "@/components/Meta"; +import Section from "@/components/Section"; +import { toast } from "@/components/Toasts"; +import UploadButton from "@/components/UploadButton"; +import { downloadJson } from "@/util/download"; +import { useQuery } from "@/util/hooks"; +import { formatNumber } from "@/util/string"; +import classes from "./Analysis.module.css"; + +const Analysis = () => { + /** get info and state from route */ + const location = useLocation(); + const state = location.state ?? {}; + const stateInput = state.input as AnalysisInput | undefined; + + /** query results */ + const { + data: queryData, + status: queryStatus, + query: runSubmitAnalysis, + } = useQuery(async () => await submitAnalysis(stateInput!), stateInput); + + useEffect(() => { + /** submit query once on mounted, if appropriate */ + if (stateInput && queryStatus === "idle") runSubmitAnalysis(); + }, [stateInput, queryStatus, runSubmitAnalysis]); + + /** upload results */ + const [upload, setUpload] = useState<_AnalysisResults>(); + const uploadInput = upload ? convertInput(upload.input) : undefined; + + const input = uploadInput ?? stateInput; + const results = upload ?? queryData; + + /** show all genes */ + const [showAllGenes, setShowAllGenes] = useState(false); + + console.log(input); + + return ( + <> + + +
    + }> + {input?.name || "Analysis"} + + +
    + {results && ( +
    +
    + + {input && ( +
    + }> + Inputs + + +
    +
    + Species Train + {input.speciesTrain} + Species Test + {input.speciesTest} + Network + {input.network} + Geneset Context + {input.genesetContext} +
    + +
    + Genes ({formatNumber(input.genes.length)}) + + {input.genes + .slice(0, showAllGenes ? Infinity : 10) + .map((gene, index) => ( + {gene} + ))} + {input.genes.length > 10 && ( + + )} + +
    +
    +
    + )} + + {results && ( +
    + }> + Results + + + {queryStatus === "loading" && ( + + Processing analysis. This may take a minute or so. + + )} + {queryStatus === "error" && ( + Error processing analysis. + )} +
    + )} + + ); +}; + +export default Analysis; diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 1589b18..a6ed877 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -24,7 +24,7 @@ const Home = () => { design="accent" />
    - - ); -}; - -export default LoadAnalysis; diff --git a/frontend/src/pages/NewAnalysis.tsx b/frontend/src/pages/NewAnalysis.tsx index 076839f..7fe5797 100644 --- a/frontend/src/pages/NewAnalysis.tsx +++ b/frontend/src/pages/NewAnalysis.tsx @@ -18,7 +18,12 @@ import { GiFly, GiRat } from "react-icons/gi"; import { useNavigate } from "react-router"; import { useDebounce } from "use-debounce"; import { convertGeneIds } from "@/api/api"; -import type { GenesetContext, Input, Network, Species } from "@/api/types"; +import type { + AnalysisInput, + GenesetContext, + Network, + Species, +} from "@/api/types"; import Alert from "@/components/Alert"; import Button from "@/components/Button"; import Heading from "@/components/Heading"; @@ -112,9 +117,12 @@ const NewAnalysis = () => { const [filename, setFilename] = useState(""); /** selected species */ - const [species, setSpecies] = useState<(typeof speciesOptions)[number]["id"]>( - speciesOptions[0]!.id, - ); + const [speciesTrain, setSpeciesTrain] = useState< + (typeof speciesOptions)[number]["id"] + >(speciesOptions[0]!.id); + const [speciesTest, setSpeciesTest] = useState< + (typeof speciesOptions)[number]["id"] + >(speciesOptions[0]!.id); /** selected network type */ const [network, setNetwork] = useState<(typeof networkOptions)[number]["id"]>( @@ -128,7 +136,7 @@ const NewAnalysis = () => { /** update meta counts */ networkOptions.forEach((option) => { - const { nodes, edges } = meta[species][option.id]; + const { nodes, edges } = meta[speciesTest][option.id]; option.tertiary = `${formatNumber(nodes, true)} nodes – ${formatNumber(edges, true)} edges`; }); @@ -140,9 +148,9 @@ const NewAnalysis = () => { } = useQuery( async () => splitGeneIds.length - ? await convertGeneIds(splitGeneIds, species) + ? await convertGeneIds(splitGeneIds, speciesTrain) : undefined, - [splitGeneIds, species], + [splitGeneIds, speciesTrain], ); /** converted list of gene ids */ @@ -154,6 +162,9 @@ const NewAnalysis = () => { if (genesStatus !== "idle") scrollTo("#review-genes"); }, [genesStatus]); + /** analysis name */ + const [name, setName] = useState(""); + /** submit analysis */ const navigate = useNavigate(); const submitAnalysis = () => { @@ -165,9 +176,16 @@ const NewAnalysis = () => { } /** send inputs to load analysis page */ - navigate("/load-analysis", { + navigate("/analysis", { state: { - input: { genes, species, network, genesetContext } satisfies Input, + input: { + name, + genes, + speciesTrain, + speciesTest, + network, + genesetContext, + } satisfies AnalysisInput, }, }); }; @@ -180,9 +198,15 @@ const NewAnalysis = () => { }); /** warn about param restrictions */ - if (network === "BioGRID" && species === "Zebrafish") + if ( + network === "BioGRID" && + (speciesTrain === "Zebrafish" || speciesTest === "Zebrafish") + ) toast("BioGRID does not support Zebrafish.", "warning", "warn1"); - if (genesetContext === "DisGeNet" && species !== "Human") + if ( + genesetContext === "DisGeNet" && + (speciesTrain !== "Human" || speciesTest !== "Human") + ) toast("DisGeNet only supports Human genes.", "warning", "warn2"); return ( @@ -213,16 +237,17 @@ const NewAnalysis = () => {
    +
    { tooltip="Source used to select negative genes and which sets to compare the trained model to" />
    - - - - {/* table-wide search */} - } - value={search} - onChange={setSearch} - tooltip="Search entire table for plain text or regex" - /> - - {/* download */} -
    -
    {/* table */} ({ cols, rows }: Props) => {
    - {/* bottom controls */} + {/* controls */}
    {/* pagination */}
    @@ -453,12 +405,59 @@ const Table = ({ cols, rows }: Props) => { {/* per page */} + + {/* table-wide search */} + } + value={search} + onChange={setSearch} + tooltip="Search entire table for plain text or regex" + /> + + {/* download */} +
    ); diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx index 94a17af..100eae9 100644 --- a/frontend/src/components/Tabs.tsx +++ b/frontend/src/components/Tabs.tsx @@ -15,7 +15,7 @@ type Props = { */ syncWithUrl?: string; /** series of Tab components */ - children: ReactElement[]; + children: (ReactElement | undefined)[]; }; const Tabs = ({ syncWithUrl = "", children }: Props) => { @@ -23,11 +23,13 @@ const Tabs = ({ syncWithUrl = "", children }: Props) => { const [value, setValue] = useQueryParam(syncWithUrl, StringParam); /** tab props */ - const tabProps = children.map((child) => ({ - ...child.props, - /** make unique tab id from text */ - id: kebabCase(child.props.text), - })); + const tabProps = children + .filter((child): child is ReactElement => !!child) + .map((child) => ({ + ...child.props, + /** make unique tab id from text */ + id: kebabCase(child.props.text), + })); /** set up zag */ const [state, send] = useMachine( diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index ba80ee7..4c91a04 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -1,7 +1,6 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { FaArrowDown, - FaArrowRightToBracket, FaArrowUp, FaChartBar, FaMagnifyingGlassChart, @@ -9,9 +8,10 @@ import { import { useLocation } from "react-router"; import { submitAnalysis } from "@/api/api"; import { - convertInput, + convertAnalysisInputs, + convertAnalysisResults, type _AnalysisResults, - type AnalysisInput, + type AnalysisInputs, } from "@/api/types"; import Alert from "@/components/Alert"; import Button from "@/components/Button"; @@ -20,50 +20,54 @@ import Meta from "@/components/Meta"; import Section from "@/components/Section"; import { toast } from "@/components/Toasts"; import UploadButton from "@/components/UploadButton"; +import Inputs from "@/pages/analysis/Inputs"; import { downloadJson } from "@/util/download"; import { useQuery } from "@/util/hooks"; -import { formatNumber } from "@/util/string"; -import classes from "./Analysis.module.css"; const Analysis = () => { /** get info and state from route */ const location = useLocation(); const state = location.state ?? {}; - const stateInput = state.input as AnalysisInput | undefined; + const stateInput = state.inputs as AnalysisInputs | undefined; /** query results */ const { data: queryData, status: queryStatus, - query: runSubmitAnalysis, + query: runQuery, + reset: resetQuery, } = useQuery(async () => await submitAnalysis(stateInput!), stateInput); - useEffect(() => { - /** submit query once on mounted, if appropriate */ - if (stateInput && queryStatus === "idle") runSubmitAnalysis(); - }, [stateInput, queryStatus, runSubmitAnalysis]); - /** upload results */ const [upload, setUpload] = useState<_AnalysisResults>(); - const uploadInput = upload ? convertInput(upload.input) : undefined; - - const input = uploadInput ?? stateInput; - const results = upload ?? queryData; + const uploadInput = upload ? convertAnalysisInputs(upload.inputs) : undefined; + const uploadResults = upload ? convertAnalysisResults(upload) : undefined; - /** show all genes */ - const [showAllGenes, setShowAllGenes] = useState(false); + /** submit query once on mounted, if appropriate */ + if (!uploadInput && stateInput && queryStatus === "idle") runQuery(); - console.log(input); + /** "final" input and results */ + const inputs = uploadInput ?? stateInput; + const results = uploadResults ?? queryData; return ( <> - +
    }> - {input?.name || "Analysis"} + Analysis + {queryStatus === "loading" && ( + + Processing analysis. This may take a minute or so. + + )} + {queryStatus === "error" && ( + Error processing analysis. + )} +
    {results && (
    - {input && ( -
    - }> - Inputs - - -
    -
    - Species Train - {input.speciesTrain} - Species Test - {input.speciesTest} - Network - {input.network} - Geneset Context - {input.genesetContext} -
    - -
    - Genes ({formatNumber(input.genes.length)}) - - {input.genes - .slice(0, showAllGenes ? Infinity : 10) - .map((gene, index) => ( - {gene} - ))} - {input.genes.length > 10 && ( - - )} - -
    -
    -
    - )} + {results && (
    }> Results - - {queryStatus === "loading" && ( - - Processing analysis. This may take a minute or so. - - )} - {queryStatus === "error" && ( - Error processing analysis. - )}
    )} diff --git a/frontend/src/pages/NewAnalysis.module.css b/frontend/src/pages/NewAnalysis.module.css index 552cd0c..1c1f2d1 100644 --- a/frontend/src/pages/NewAnalysis.module.css +++ b/frontend/src/pages/NewAnalysis.module.css @@ -1,7 +1,6 @@ .summary { - display: grid; - grid-template-columns: 20px 1fr; - align-items: center; + display: flex; + flex-direction: column; width: unset; gap: 10px; } @@ -10,20 +9,6 @@ grid-column: 1 / -1; } -.mark { - display: inline-flex; - align-items: center; - gap: 5px; -} - -.success { - color: var(--success); -} - -.error { - color: var(--error); -} - .parameters { display: flex; flex-wrap: wrap; diff --git a/frontend/src/pages/NewAnalysis.tsx b/frontend/src/pages/NewAnalysis.tsx index 7fe5797..b64fc83 100644 --- a/frontend/src/pages/NewAnalysis.tsx +++ b/frontend/src/pages/NewAnalysis.tsx @@ -1,8 +1,7 @@ -import { Fragment, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { FaBeer } from "react-icons/fa"; import { FaArrowUp, - FaCheck, FaDna, FaEye, FaFish, @@ -12,14 +11,13 @@ import { FaPlus, FaTable, FaWorm, - FaXmark, } from "react-icons/fa6"; import { GiFly, GiRat } from "react-icons/gi"; import { useNavigate } from "react-router"; import { useDebounce } from "use-debounce"; import { convertGeneIds } from "@/api/api"; import type { - AnalysisInput, + AnalysisInputs, GenesetContext, Network, Species, @@ -27,6 +25,7 @@ import type { import Alert from "@/components/Alert"; import Button from "@/components/Button"; import Heading from "@/components/Heading"; +import Mark from "@/components/Mark"; import Meta from "@/components/Meta"; import Radios, { type Option as RadioOption } from "@/components/Radios"; import Section from "@/components/Section"; @@ -103,12 +102,12 @@ const genesetContextOptions: RadioOption[] = [ ]; const NewAnalysis = () => { - /** raw text list of gene ids */ - const [geneIds, setGeneIds] = useState(""); - const [debouncedGeneIds] = useDebounce(geneIds, 500); + /** raw text list of input gene ids */ + const [inputGenes, setInputGenes] = useState(""); + const [debouncedInputGenes] = useDebounce(inputGenes, 500); - /** array of gene ids */ - const splitGeneIds = debouncedGeneIds + /** array of input gene ids */ + const splitInputGenes = debouncedInputGenes .split(/,|\t|\n/) .map((id) => id.trim()) .filter(Boolean); @@ -142,25 +141,21 @@ const NewAnalysis = () => { /** gene id conversion data */ const { - data: geneData, - status: genesStatus, + data: geneConversionData, + status: geneConversionStatus, query: runConvertGeneIds, } = useQuery( async () => - splitGeneIds.length - ? await convertGeneIds(splitGeneIds, speciesTrain) + splitInputGenes.length + ? await convertGeneIds(splitInputGenes, speciesTrain) : undefined, - [splitGeneIds, speciesTrain], + [splitInputGenes, speciesTrain], ); - /** converted list of gene ids */ - const genes = - geneData?.table.map((entry) => entry.entrez).filter(Boolean) || []; - /** scroll down to review section after entering genes */ useEffect(() => { - if (genesStatus !== "idle") scrollTo("#review-genes"); - }, [genesStatus]); + if (geneConversionStatus !== "idle") scrollTo("#review-genes"); + }, [geneConversionStatus]); /** analysis name */ const [name, setName] = useState(""); @@ -169,7 +164,7 @@ const NewAnalysis = () => { const navigate = useNavigate(); const submitAnalysis = () => { /** check for sufficient inputs */ - if (!genes.length) { + if (!splitInputGenes.length) { window.alert("Please enter some genes first!"); scrollTo("#enter-genes"); return; @@ -178,14 +173,14 @@ const NewAnalysis = () => { /** send inputs to load analysis page */ navigate("/analysis", { state: { - input: { + inputs: { name, - genes, + genes: splitInputGenes, speciesTrain, speciesTest, network, genesetContext, - } satisfies AnalysisInput, + } satisfies AnalysisInputs, }, }); }; @@ -225,9 +220,9 @@ const NewAnalysis = () => { { - setGeneIds(value); + setInputGenes(value); setFilename(""); }} multi={true} @@ -247,7 +242,7 @@ const NewAnalysis = () => { + )} + + + + )} + + {results && ( + }> + cell || Failed, + }, + { + key: "inNetwork", + name: "In Network", + render: YesNo, + filterable: true, + filterType: "boolean", + }, + ]} + rows={results.inputGenes} + /> + + )} + + + ); +}; + +export default Inputs; diff --git a/frontend/src/util/hooks.ts b/frontend/src/util/hooks.ts index 7614592..8b7671a 100644 --- a/frontend/src/util/hooks.ts +++ b/frontend/src/util/hooks.ts @@ -92,5 +92,11 @@ export const useQuery = ( if (auto) query(); }, [auto, query, key]); - return { query, data, status }; + /** reset data and status */ + const reset = () => { + setData(undefined); + setStatus("idle"); + }; + + return { query, data, status, reset }; }; From 26a753f05c842b6c46fa60262eba7098173b76c4 Mon Sep 17 00:00:00 2001 From: Vincent Rubinetti Date: Tue, 16 Apr 2024 13:12:25 -0400 Subject: [PATCH 09/27] fixes, additions --- frontend/src/api/types.ts | 19 ++- frontend/src/components/Slider.tsx | 2 +- frontend/src/components/Table.module.css | 11 ++ frontend/src/components/Table.tsx | 127 +++++++++++------- frontend/src/components/Tabs.tsx | 5 +- frontend/src/pages/Analysis.tsx | 39 ++++-- frontend/src/pages/NewAnalysis.tsx | 7 - frontend/src/pages/Testbed.tsx | 6 +- frontend/src/pages/analysis/ConvertedIds.tsx | 35 +++++ frontend/src/pages/analysis/Inputs.module.css | 4 +- frontend/src/pages/analysis/Inputs.tsx | 107 +++++---------- frontend/src/pages/analysis/Predictions.tsx | 56 ++++++++ frontend/src/util/string.ts | 17 ++- functions/README.md | 16 +-- 14 files changed, 277 insertions(+), 174 deletions(-) create mode 100644 frontend/src/pages/analysis/ConvertedIds.tsx create mode 100644 frontend/src/pages/analysis/Predictions.tsx diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 8619f8f..fb4708a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -114,18 +114,18 @@ export type _AnalysisResults = { df_edge: { Node1: string; Node2: string }[]; df_edge_sym: { Node1: string; Node2: string }[]; df_probs: { - "Class-Label": "P" | "N" | "U"; + Rank: number; Entrez: string; - "Known/Novel": "Known" | "Novel"; + Symbol: string; Name: string; Probability: number; - Rank: number; - Symbol: string; + "Known/Novel": "Known" | "Novel"; + "Class-Label": "P" | "N" | "U"; }[]; df_sim: { + Rank: number; ID: string; Name: string; - Rank: number; Similarity: number; }[]; }; @@ -140,6 +140,15 @@ export const convertAnalysisResults = (backend: _AnalysisResults) => ({ inNetwork: (row["In BioGRID?"] ?? row["In IMP?"] ?? row["In STRING?"]) === "Y", })), + predictions: backend.df_probs.map((row) => ({ + rank: row.Rank, + entrez: row.Entrez, + symbol: row.Symbol, + name: row.Name, + probability: row.Probability, + knownNovel: row["Known/Novel"], + classLabel: row["Class-Label"], + })), }); /** ml endpoint params frontend format */ diff --git a/frontend/src/components/Slider.tsx b/frontend/src/components/Slider.tsx index 52486ff..aab73e2 100644 --- a/frontend/src/components/Slider.tsx +++ b/frontend/src/components/Slider.tsx @@ -59,7 +59,7 @@ const Slider = ({ /** defaults */ const _min = min ?? 0; const _max = max ?? 100; - const _step = step ?? 1; + const _step = Math.min(step ?? (_max - _min) / 10, _max - _min); /** set up zag */ const [state, send] = useMachine( diff --git a/frontend/src/components/Table.module.css b/frontend/src/components/Table.module.css index 185078f..f06e078 100644 --- a/frontend/src/components/Table.module.css +++ b/frontend/src/components/Table.module.css @@ -3,6 +3,17 @@ overflow-x: auto; } +.expanded { + width: calc(100vw - 80px); +} + +@media (max-width: 800px) { + .expanded th, + .expanded td { + white-space: nowrap; + } +} + .table { width: 100%; min-width: min(max-content, var(--content)); diff --git a/frontend/src/components/Table.tsx b/frontend/src/components/Table.tsx index fed2b70..dd4fbaf 100644 --- a/frontend/src/components/Table.tsx +++ b/frontend/src/components/Table.tsx @@ -1,5 +1,6 @@ -import type { ReactNode } from "react"; +import type { CSSProperties, ReactNode } from "react"; import { useMemo, useState } from "react"; +import { FaCompressArrowsAlt, FaExpandArrowsAlt } from "react-icons/fa"; import { FaAngleLeft, FaAngleRight, @@ -12,6 +13,7 @@ import { FaSortDown, FaSortUp, } from "react-icons/fa6"; +import classNames from "classnames"; import { clamp, isEqual, pick, sortBy, sum } from "lodash"; import type { Column, FilterFn, NoInfer, RowData } from "@tanstack/react-table"; import { @@ -47,15 +49,17 @@ type Col< name: string; /** is sortable (default true) */ sortable?: boolean; - /** whether col is individually filterable */ + /** whether col is individually filterable (default true) */ filterable?: boolean; /** * how to treat cell value when filtering individually or searching globally * (default string) */ filterType?: "string" | "number" | "enum" | "boolean"; - /** horizontal alignment */ + /** horizontal alignment (default left) */ align?: "left" | "center" | "right"; + /** cell style */ + style?: CSSProperties; /** visibility (default true) */ show?: boolean; /** custom render function for cell */ @@ -82,6 +86,7 @@ declare module "@tanstack/table-core" { filterable: NonNullable; filterType: NonNullable; align: NonNullable; + style: Col["style"]; } } @@ -101,6 +106,8 @@ const colToOption = ( * https://codesandbox.io/p/devbox/tanstack-table-example-kitchen-sink-vv4871 */ const Table = ({ cols, rows }: Props) => { + const [expanded, setExpanded] = useState(true); + /** column visibility options for multi-select */ const visibleOptions = cols.map(colToOption); /** visible columns */ @@ -189,14 +196,15 @@ const Table = ({ cols, rows }: Props) => { /** sortable */ enableSorting: col.sortable ?? true, /** individually filterable */ - enableColumnFilter: col.filterable ?? false, + enableColumnFilter: col.filterable ?? true, /** only include in table-wide search if column is visible */ // enableGlobalFilter: visible.includes(String(index)), /** type of column */ meta: { - filterable: col.filterable ?? false, + filterable: col.filterable ?? true, filterType: col.filterType ?? "string", align: col.align ?? "left", + style: col.style, }, /** func to use for filtering individual column */ filterFn: filterFunc, @@ -245,7 +253,7 @@ const Table = ({ cols, rows }: Props) => { return (
    -
    +
    {/* table */}
    ({ cols, rows }: Props) => { key={header.id} align={header.column.columnDef.meta?.align} aria-colindex={Number(header.id) + 1} + style={header.column.columnDef.meta?.style} > {header.isPlaceholder ? null : (
    @@ -332,7 +341,11 @@ const Table = ({ cols, rows }: Props) => { } > {row.getVisibleCells().map((cell) => ( -
    + {flexRender( cell.column.columnDef.cell, cell.getContext(), @@ -403,61 +416,70 @@ const Table = ({ cols, rows }: Props) => { - {/* per page */} - +
    + {/* per page */} + +
    {/* table-wide search */} } value={search} onChange={setSearch} tooltip="Search entire table for plain text or regex" /> - {/* download */} -