From 460db0ed2d662af1f1e20dea8ec0ed408998cae1 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 28 Oct 2024 12:49:53 +0100 Subject: [PATCH 01/21] Make session share button + persist to local storage Moves ShareButton from experiment to whole app TODO - [ ] Handle inline todos --- .../class-solid/src/components/Experiment.tsx | 2 - apps/class-solid/src/components/Nav.tsx | 5 + .../src/components/ShareButton.tsx | 23 ++-- apps/class-solid/src/lib/encode.ts | 105 +++++++++++------- apps/class-solid/src/lib/onPageTransition.ts | 52 +++++++++ apps/class-solid/src/lib/store.ts | 8 ++ apps/class-solid/src/routes/index.tsx | 36 +----- 7 files changed, 149 insertions(+), 82 deletions(-) create mode 100644 apps/class-solid/src/lib/onPageTransition.ts diff --git a/apps/class-solid/src/components/Experiment.tsx b/apps/class-solid/src/components/Experiment.tsx index 020505e6..579b579c 100644 --- a/apps/class-solid/src/components/Experiment.tsx +++ b/apps/class-solid/src/components/Experiment.tsx @@ -17,7 +17,6 @@ import { } from "~/lib/store"; import { ExperimentConfigForm } from "./ExperimentConfigForm"; import { PermutationsList } from "./PermutationsList"; -import { ShareButton } from "./ShareButton"; import { MdiCog, MdiContentCopy, MdiDelete, MdiDownload } from "./icons"; import { Card, @@ -271,7 +270,6 @@ export function ExperimentCard(props: { > - diff --git a/apps/class-solid/src/components/Nav.tsx b/apps/class-solid/src/components/Nav.tsx index ca8cc53c..9ddfdf56 100644 --- a/apps/class-solid/src/components/Nav.tsx +++ b/apps/class-solid/src/components/Nav.tsx @@ -1,4 +1,5 @@ import { useLocation } from "@solidjs/router"; +import { ShareButton } from "./ShareButton"; export default function Nav() { const location = useLocation(); @@ -15,6 +16,10 @@ export default function Nav() {
  • About
  • +
  • + {/* TODO move right */} + +
  • ); diff --git a/apps/class-solid/src/components/ShareButton.tsx b/apps/class-solid/src/components/ShareButton.tsx index 45e01098..12f883c1 100644 --- a/apps/class-solid/src/components/ShareButton.tsx +++ b/apps/class-solid/src/components/ShareButton.tsx @@ -1,7 +1,7 @@ -import { type Accessor, Show, createMemo, createSignal } from "solid-js"; +import { Show, createMemo, createSignal } from "solid-js"; import { Button } from "~/components/ui/button"; -import { encodeExperiment } from "~/lib/encode"; -import type { Experiment } from "~/lib/store"; +import { encodeAppState } from "~/lib/encode"; +import { analyses, experiments } from "~/lib/store"; import { MdiClipboard, MdiClipboardCheck, @@ -18,7 +18,7 @@ import { import { TextField, TextFieldInput } from "./ui/text-field"; import { showToast } from "./ui/toast"; -export function ShareButton(props: { experiment: Accessor }) { +export function ShareButton() { const [open, setOpen] = createSignal(false); const [isCopied, setIsCopied] = createSignal(false); let inputRef: HTMLInputElement | undefined; @@ -26,8 +26,9 @@ export function ShareButton(props: { experiment: Accessor }) { if (!open()) { return ""; } - const encodedExperiment = encodeExperiment(props.experiment()); - const url = `${window.location.origin}#${encodedExperiment}`; + + const appState = encodeAppState(experiments, analyses); + const url = `${window.location.origin}#${appState}`; return url; }); @@ -57,8 +58,9 @@ export function ShareButton(props: { experiment: Accessor }) { return ( - }> - + {/* TODO disable when there are zero experiments or analyses */} + + Share @@ -73,7 +75,8 @@ export function ShareButton(props: { experiment: Accessor }) { > this link {" "} - will be able to view the current experiment in their web browser. + will be able to view the current application state in their web + browser. @@ -84,7 +87,7 @@ export function ShareButton(props: { experiment: Accessor }) { type="text" readonly class="w-full" - aria-label="Shareable link for current experiment" + aria-label="Shareable link for current application state" /> + ); diff --git a/apps/class-solid/src/components/icons.tsx b/apps/class-solid/src/components/icons.tsx index 16c5a710..24b37460 100644 --- a/apps/class-solid/src/components/icons.tsx +++ b/apps/class-solid/src/components/icons.tsx @@ -232,3 +232,21 @@ export function MdiClipboardCheck(props: JSX.IntrinsicElements["svg"]) { ); } + +export function MdiContentSave(props: JSX.IntrinsicElements["svg"]) { + return ( + + + Save + + ); +} diff --git a/apps/class-solid/src/lib/encode.ts b/apps/class-solid/src/lib/encode.ts index 31bee03e..2ab8a39b 100644 --- a/apps/class-solid/src/lib/encode.ts +++ b/apps/class-solid/src/lib/encode.ts @@ -1,4 +1,4 @@ -import { type PartialConfig, pruneDefaults } from "@classmodel/class/validate"; +import { parse, pruneDefaults } from "@classmodel/class/validate"; import { unwrap } from "solid-js/store"; import type { Analysis } from "~/components/Analysis"; import type { Experiment } from "./store"; @@ -11,15 +11,17 @@ export function decodeAppState(encoded: string): [Experiment[], Analysis[]] { (exp: { name: string; description: string; - reference: PartialConfig; - permutations: Record; + reference: unknown; + permutations: Record; }) => ({ name: exp.name, description: exp.description, - reference: exp.reference, + reference: { + config: parse(exp.reference), + }, permutations: Object.entries(exp.permutations).map(([name, config]) => ({ name, - config, + config: parse(config), })), }), ); diff --git a/apps/class-solid/src/lib/onPageTransition.ts b/apps/class-solid/src/lib/onPageTransition.ts index d1d7b95c..5e7707ec 100644 --- a/apps/class-solid/src/lib/onPageTransition.ts +++ b/apps/class-solid/src/lib/onPageTransition.ts @@ -3,23 +3,42 @@ import { showToast } from "~/components/ui/toast"; import { encodeAppState } from "./encode"; import { analyses, experiments, loadStateFromString } from "./store"; +const localStorageName = "class-state"; + +export function hasLocalStorage() { + const state = localStorage.getItem(localStorageName); + return ( + state !== null && + state !== "%7B%22experiments%22%3A%5B%5D%2C%22analyses%22%3A%5B%5D%7D" + ); +} + +export function loadFromLocalStorage() { + const rawState = localStorage.getItem(localStorageName); + if (!rawState) { + return; + } + try { + loadStateFromString(rawState); + showToast({ + title: "State loaded from local storage", + variant: "success", + duration: 1000, + }); + } catch (error) { + console.error(error); + showToast({ + title: "Failed to load state from local storage", + description: `${error}`, + variant: "error", + }); + } +} + export async function onPageLoad() { const location = useLocation(); const navigate = useNavigate(); - let rawState = location.hash.substring(1); - if (!rawState) { - // If no state in URL, check if there is a state in local storage - const rawStateFromLocalStorage = localStorage.getItem(localStorageName); - if ( - rawStateFromLocalStorage && - rawStateFromLocalStorage !== - "%7B%22experiments%22%3A%5B%5D%2C%22analyses%22%3A%5B%5D%7D" && - // TODO prompt is annoying when developing, disable during development? - window.confirm("Would you like to resume from the previous session?") - ) { - rawState = rawStateFromLocalStorage; - } - } + const rawState = location.hash.substring(1); if (!rawState) { return; } @@ -44,9 +63,15 @@ export async function onPageLoad() { navigate("/"); } -const localStorageName = "class-state"; - -export function onPageLeave() { +export function saveAppState() { const appState = encodeAppState(experiments, analyses); + if ( + appState === "%7B%22experiments%22%3A%5B%5D%2C%22analyses%22%3A%5B%5D%7D" + ) { + localStorage.removeItem(localStorageName); + } + // TODO instead of storing to local storage store to url + // pro: multiple tabs will not share state + // con: ugly urls localStorage.setItem(localStorageName, appState); } diff --git a/apps/class-solid/src/routes/index.tsx b/apps/class-solid/src/routes/index.tsx index c77d1417..31231664 100644 --- a/apps/class-solid/src/routes/index.tsx +++ b/apps/class-solid/src/routes/index.tsx @@ -1,9 +1,10 @@ -import { For, Show, createSignal, onCleanup, onMount } from "solid-js"; +import { For, Show, createSignal, onMount } from "solid-js"; import { AnalysisCard, addAnalysis } from "~/components/Analysis"; import { AddExperimentDialog, ExperimentCard } from "~/components/Experiment"; import { UploadExperiment } from "~/components/UploadExperiment"; import { MdiPlusBox } from "~/components/icons"; +import { Button } from "~/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -14,7 +15,11 @@ import { } from "~/components/ui/dropdown-menu"; import { Flex } from "~/components/ui/flex"; import { Toaster } from "~/components/ui/toast"; -import { onPageLeave, onPageLoad } from "~/lib/onPageTransition"; +import { + hasLocalStorage, + loadFromLocalStorage, + onPageLoad, +} from "~/lib/onPageTransition"; import { experiments } from "~/lib/store"; import { analyses } from "~/lib/store"; @@ -23,7 +28,6 @@ export default function Home() { const [openAddDialog, setOpenAddDialog] = createSignal(false); onMount(onPageLoad); - onCleanup(onPageLeave); return (
    @@ -58,6 +62,11 @@ export default function Home() { /> + + + {(experiment, index) => ( From b0ad687daf48f57f6bdec601495f2a47c966bf7d Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 8 Nov 2024 10:26:04 +0100 Subject: [PATCH 03/21] Style save button + move to right --- apps/class-solid/src/components/Nav.tsx | 23 +++++++++++++---------- apps/class-solid/src/routes/about.tsx | 5 +++-- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/class-solid/src/components/Nav.tsx b/apps/class-solid/src/components/Nav.tsx index 065480f8..33a232cd 100644 --- a/apps/class-solid/src/components/Nav.tsx +++ b/apps/class-solid/src/components/Nav.tsx @@ -2,7 +2,6 @@ import { useLocation } from "@solidjs/router"; import { saveAppState } from "~/lib/onPageTransition"; import { ShareButton } from "./ShareButton"; import { MdiContentSave } from "./icons"; -import { Button } from "./ui/button"; export default function Nav() { const location = useLocation(); @@ -12,22 +11,26 @@ export default function Nav() { : "border-transparent hover:border-sky-600"; return ( diff --git a/apps/class-solid/src/routes/about.tsx b/apps/class-solid/src/routes/about.tsx index 1a29ce84..282eecdc 100644 --- a/apps/class-solid/src/routes/about.tsx +++ b/apps/class-solid/src/routes/about.tsx @@ -3,8 +3,9 @@ import { A } from "@solidjs/router"; export default function About() { return (
    -

    - Welcome to CLASS +

    + Welcome to Chemistry Land-surface Atmosphere{" "} + Soil Slab model (CLASS)

    Here, we're developing a new version of CLASS that can run in the From 36ca2f27dcb54cfbc35d49543366455b12392b03 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 8 Nov 2024 10:31:38 +0100 Subject: [PATCH 04/21] Fix app tests --- apps/class-solid/tests/index.spec.ts | 4 ++-- apps/class-solid/tests/share.spec.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/class-solid/tests/index.spec.ts b/apps/class-solid/tests/index.spec.ts index d22f7367..363523bf 100644 --- a/apps/class-solid/tests/index.spec.ts +++ b/apps/class-solid/tests/index.spec.ts @@ -1,10 +1,10 @@ import { expect, test } from "@playwright/test"; -test("has welcome", async ({ page }) => { +test("has link to home page", async ({ page }) => { await page.goto("/about"); await expect( - page.getByRole("heading", { name: "Welcome to CLASS" }), + page.getByRole("link", { name: "classmodel.github.io" }), ).toBeVisible(); }); diff --git a/apps/class-solid/tests/share.spec.ts b/apps/class-solid/tests/share.spec.ts index 9042ed46..609b81df 100644 --- a/apps/class-solid/tests/share.spec.ts +++ b/apps/class-solid/tests/share.spec.ts @@ -12,10 +12,7 @@ test("Create share link from an experiment", async ({ page }) => { await page.getByRole("button", { name: "Run" }).click(); // Open share dialog - const origExperiment = page.getByLabel("My experiment 1", { exact: true }); - await origExperiment - .getByRole("button", { name: "Share experiment" }) - .click(); + await page.getByRole("button", { name: "Share" }).click(); // Open link, in a new popup window const sharedPagePromise = page.waitForEvent("popup"); await page.getByRole("link", { name: "this link" }).click(); From 85be68d2a7ec4972d7d6cfee9459da4c9698a402 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 8 Nov 2024 10:35:04 +0100 Subject: [PATCH 05/21] Remove place holder --- apps/class-solid/src/routes/about.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/apps/class-solid/src/routes/about.tsx b/apps/class-solid/src/routes/about.tsx index 282eecdc..97aef42f 100644 --- a/apps/class-solid/src/routes/about.tsx +++ b/apps/class-solid/src/routes/about.tsx @@ -1,5 +1,3 @@ -import { A } from "@solidjs/router"; - export default function About() { return (

    @@ -22,13 +20,6 @@ export default function About() { classmodel.github.io {" "}

    -

    - - Home - - {" - "} - About Page -

    ); } From e8e9296423446ae2bc48c06f8dbdf9eef7261ed8 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 8 Nov 2024 10:56:51 +0100 Subject: [PATCH 06/21] Show all analysis on completion of first experiment --- apps/class-solid/src/components/Analysis.tsx | 32 +--------------- apps/class-solid/src/lib/encode.ts | 3 +- apps/class-solid/src/lib/store.ts | 39 +++++++++++++++++++- apps/class-solid/src/routes/index.tsx | 6 +-- apps/class-solid/tests/experiment.spec.ts | 9 ++--- apps/class-solid/tests/share.spec.ts | 4 +- 6 files changed, 49 insertions(+), 44 deletions(-) diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index cf43f184..9990db71 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -1,7 +1,6 @@ import { For, Match, Switch, createMemo, createUniqueId } from "solid-js"; import { getVerticalProfiles } from "~/lib/profiles"; -import { analyses, experiments, setAnalyses } from "~/lib/store"; -import type { Experiment } from "~/lib/store"; +import { type Analysis, deleteAnalysis, experiments } from "~/lib/store"; import LinePlot from "./LinePlot"; import { MdiCog, MdiContentCopy, MdiDelete, MdiDownload } from "./icons"; import { Button } from "./ui/button"; @@ -24,33 +23,6 @@ const colors = [ const linestyles = ["none", "5,5", "10,10", "15,5,5,5", "20,10,5,5,5,10"]; -export interface Analysis { - name: string; - description: string; - id: string; - experiments: Experiment[] | undefined; - type: string; -} - -export function addAnalysis(type = "default") { - const name = { - default: "Final height", - timeseries: "Timeseries", - profiles: "Vertical profiles", - }[type]; - - setAnalyses(analyses.length, { - name: name, - id: createUniqueId(), - experiments: experiments, - type: type, - }); -} - -function deleteAnalysis(analysis: Analysis) { - setAnalyses(analyses.filter((ana) => ana.id !== analysis.id)); -} - /** Very rudimentary plot showing time series of each experiment globally available * It only works if the time axes are equal */ @@ -196,7 +168,7 @@ export function AnalysisCard(analysis: Analysis) { Unknown analysis type

    }> - + diff --git a/apps/class-solid/src/lib/encode.ts b/apps/class-solid/src/lib/encode.ts index 2ab8a39b..45959477 100644 --- a/apps/class-solid/src/lib/encode.ts +++ b/apps/class-solid/src/lib/encode.ts @@ -1,7 +1,6 @@ import { parse, pruneDefaults } from "@classmodel/class/validate"; import { unwrap } from "solid-js/store"; -import type { Analysis } from "~/components/Analysis"; -import type { Experiment } from "./store"; +import type { Analysis, Experiment } from "./store"; export function decodeAppState(encoded: string): [Experiment[], Analysis[]] { const decoded = decodeURIComponent(encoded); diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index d9e8fccc..970d3fd5 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -6,7 +6,7 @@ import { parseExperimentConfig, pruneDefaults, } from "@classmodel/class/validate"; -import type { Analysis } from "~/components/Analysis"; +import { createUniqueId } from "solid-js"; import { decodeAppState } from "./encode"; import { runClass } from "./runner"; @@ -97,6 +97,13 @@ export async function runExperiment(id: number) { e.running = false; }), ); + + // If no analyis are set then add all of them + if (analyses.length === 0) { + for (const key of Object.keys(analysisNames) as AnalysisType[]) { + addAnalysis(key); + } + } } function findExperiment(index: number) { @@ -280,3 +287,33 @@ export async function loadStateFromString(rawState: string): Promise { await Promise.all(loadedExperiments.map((_, i) => runExperiment(i))); setAnalyses(loadedAnalyses); } + +const analysisNames = { + profiles: "Vertical profiles", + timeseries: "Timeseries", + finalheight: "Final height", +} as const; +type AnalysisType = keyof typeof analysisNames; + +export interface Analysis { + name: string; + description: string; + id: string; + experiments: Experiment[] | undefined; + type: AnalysisType; +} + +export function addAnalysis(type: AnalysisType) { + const name = analysisNames[type]; + + setAnalyses(analyses.length, { + name, + id: createUniqueId(), + experiments: experiments, + type, + }); +} + +export function deleteAnalysis(analysis: Analysis) { + setAnalyses(analyses.filter((ana) => ana.id !== analysis.id)); +} diff --git a/apps/class-solid/src/routes/index.tsx b/apps/class-solid/src/routes/index.tsx index 31231664..6a637308 100644 --- a/apps/class-solid/src/routes/index.tsx +++ b/apps/class-solid/src/routes/index.tsx @@ -1,6 +1,6 @@ import { For, Show, createSignal, onMount } from "solid-js"; -import { AnalysisCard, addAnalysis } from "~/components/Analysis"; +import { AnalysisCard } from "~/components/Analysis"; import { AddExperimentDialog, ExperimentCard } from "~/components/Experiment"; import { UploadExperiment } from "~/components/UploadExperiment"; import { MdiPlusBox } from "~/components/icons"; @@ -21,7 +21,7 @@ import { onPageLoad, } from "~/lib/onPageTransition"; -import { experiments } from "~/lib/store"; +import { addAnalysis, experiments } from "~/lib/store"; import { analyses } from "~/lib/store"; export default function Home() { @@ -84,7 +84,7 @@ export default function Home() { Add analysis - addAnalysis()}> + addAnalysis("finalheight")}> Final height addAnalysis("timeseries")}> diff --git a/apps/class-solid/tests/experiment.spec.ts b/apps/class-solid/tests/experiment.spec.ts index dd0745ac..f01728bb 100644 --- a/apps/class-solid/tests/experiment.spec.ts +++ b/apps/class-solid/tests/experiment.spec.ts @@ -20,10 +20,6 @@ test("Duplicate experiment with a permutation", async ({ page }, testInfo) => { await page.getByLabel("ABL height").fill("800"); await page.getByRole("button", { name: "Run" }).click(); - // Add timeseries analysis - await page.getByTitle("Add analysis").click(); - await page.getByRole("menuitem", { name: "Timeseries" }).click(); - // Duplicate experiment await page.getByTitle("Duplicate experiment").click(); @@ -58,7 +54,10 @@ test("Duplicate experiment with a permutation", async ({ page }, testInfo) => { // visually check that timeseries plot has 4 non-overlapping lines await testInfo.attach("timeseries plot with 4 non-overlapping lines", { - body: await page.locator("figure").screenshot(), + body: await page + .getByRole("article", { name: "Timeseries" }) + .locator("figure") + .screenshot(), contentType: "image/png", }); }); diff --git a/apps/class-solid/tests/share.spec.ts b/apps/class-solid/tests/share.spec.ts index 609b81df..0ed2b09e 100644 --- a/apps/class-solid/tests/share.spec.ts +++ b/apps/class-solid/tests/share.spec.ts @@ -33,9 +33,7 @@ test("Create share link from an experiment", async ({ page }) => { expect(config1.reference.initialState?.h_0).toEqual(800); // Check that shared experiment has been run by - // adding Final Height analysis and checking height is non-zero - await sharedPage.getByRole("button", { name: "Add analysis" }).click(); - await sharedPage.getByRole("menuitem", { name: "Final height" }).click(); + // checking height in final height analysis const finalHeightAnalysis = sharedPage.getByRole("article", { name: "Final height", }); From c2ee6584bfc4262fc6f0e8c43050bd7f18650bc8 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 8 Nov 2024 16:02:11 +0100 Subject: [PATCH 07/21] When model run fails log the config --- apps/class-solid/src/lib/runner.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/class-solid/src/lib/runner.ts b/apps/class-solid/src/lib/runner.ts index ec30ae9f..dec59e69 100644 --- a/apps/class-solid/src/lib/runner.ts +++ b/apps/class-solid/src/lib/runner.ts @@ -1,5 +1,6 @@ import type { BmiClass } from "@classmodel/class/bmi"; import type { Config } from "@classmodel/class/config"; +import type { ClassOutput } from "@classmodel/class/runner"; import { type PartialConfig, parse } from "@classmodel/class/validate"; import { wrap } from "comlink"; @@ -8,13 +9,18 @@ const worker = new Worker(new URL("./worker.ts", import.meta.url), { }); export const AsyncBmiClass = wrap(worker); -export async function runClass(config: PartialConfig) { - const parsedConfig: Config = parse(config); - const model = await new AsyncBmiClass(); - await model.initialize(parsedConfig); - const output = await model.run({ - var_names: ["h", "theta", "q", "dtheta", "dq"], - }); - console.log(output); - return output; +export async function runClass(config: PartialConfig): Promise { + try { + const parsedConfig: Config = parse(config); + const model = await new AsyncBmiClass(); + await model.initialize(parsedConfig); + const output = await model.run({ + var_names: ["h", "theta", "q", "dtheta", "dq"], + }); + return output; + } catch (error) { + console.error({ config, error }); + // TODO use toast to give feedback to the user + } + throw new Error("Model run failed"); } From 0dff547cfd12e19796186261e2d28fe084ccbd2d Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 8 Nov 2024 16:05:39 +0100 Subject: [PATCH 08/21] Take output out of solid store + finer progress report --- apps/class-solid/src/components/Analysis.tsx | 112 ++++++++++------ .../class-solid/src/components/Experiment.tsx | 19 ++- apps/class-solid/src/lib/download.ts | 23 ++-- apps/class-solid/src/lib/store.ts | 122 +++++++++++------- 4 files changed, 176 insertions(+), 100 deletions(-) diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index 9990db71..61887ac8 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -1,6 +1,12 @@ -import { For, Match, Switch, createMemo, createUniqueId } from "solid-js"; +import { For, Match, Show, Switch, createMemo, createUniqueId } from "solid-js"; import { getVerticalProfiles } from "~/lib/profiles"; -import { type Analysis, deleteAnalysis, experiments } from "~/lib/store"; +import { + type Analysis, + deleteAnalysis, + experiments, + outputForExperiment, + outputForPermutation, +} from "~/lib/store"; import LinePlot from "./LinePlot"; import { MdiCog, MdiContentCopy, MdiDelete, MdiDownload } from "./icons"; import { Button } from "./ui/button"; @@ -29,21 +35,23 @@ const linestyles = ["none", "5,5", "10,10", "15,5,5,5", "20,10,5,5,5,10"]; export function TimeSeriesPlot() { const chartData = createMemo(() => { return experiments - .filter((e) => e.reference.output) + .filter((e) => e.running === false) // Skip running experiments .flatMap((e, i) => { + const experimentOutput = outputForExperiment(e); const permutationRuns = e.permutations.map((perm, j) => { + const permOutput = outputForPermutation(experimentOutput, j); return { label: `${e.name}/${perm.name}`, - y: perm.output === undefined ? [] : perm.output.h, - x: perm.output === undefined ? [] : perm.output.t, + y: permOutput.h ?? [], + x: permOutput.t ?? [], color: colors[(j + 1) % 10], linestyle: linestyles[i % 5], }; }); return [ { - y: e.reference.output === undefined ? [] : e.reference.output.h, - x: e.reference.output === undefined ? [] : e.reference.output.t, + y: experimentOutput?.reference.h ?? [], + x: experimentOutput?.reference.t ?? [], label: e.name, color: colors[0], linestyle: linestyles[i], @@ -66,33 +74,42 @@ export function VerticalProfilePlot() { const variable = "theta"; const time = -1; const profileData = createMemo(() => { - return experiments.flatMap((e, i) => { - const permutations = e.permutations.map((p, j) => { - // TODO get additional config info from reference - // permutations probably usually don't have gammaq/gammatetha set? - return { - color: colors[(j + 1) % 10], - linestyle: linestyles[i % 5], - label: `${e.name}/${p.name}`, - ...getVerticalProfiles(p.output, p.config, variable, time), - }; - }); + return experiments + .filter((e) => e.running === false) // Skip running experiments + .flatMap((e, i) => { + const experimentOutput = outputForExperiment(e); + const permutations = e.permutations.map((p, j) => { + // TODO get additional config info from reference + // permutations probably usually don't have gammaq/gammatetha set? + const permOutput = outputForPermutation(experimentOutput, j); + return { + color: colors[(j + 1) % 10], + linestyle: linestyles[i % 5], + label: `${e.name}/${p.name}`, + ...getVerticalProfiles(permOutput, p.config, variable, time), + }; + }); - return [ - { - label: e.name, - color: colors[0], - linestyle: linestyles[i], - ...getVerticalProfiles( - e.reference.output, - e.reference.config, - variable, - time, - ), - }, - ...permutations, - ]; - }); + return [ + { + label: e.name, + color: colors[0], + linestyle: linestyles[i], + ...getVerticalProfiles( + experimentOutput?.reference ?? { + t: [], + h: [], + theta: [], + dtheta: [], + }, + e.reference.config, + variable, + time, + ), + }, + ...permutations, + ]; + }); }); return ( {(experiment) => { - const h = () => - experiment.reference.output?.h[ - experiment.reference.output.h.length - 1 - ] || 0; + const h = () => { + const experimentOutput = outputForExperiment(experiment); + return ( + experimentOutput?.reference.h[ + experimentOutput.reference.h.length - 1 + ] || 0 + ); + }; return ( - <> +
  • {experiment.name}: {h().toFixed()} m
  • - {(perm) => { - const h = () => perm.output?.h[perm.output.h.length - 1] || 0; + {(perm, permIndex) => { + const h = () => { + const experimentOutput = outputForExperiment(experiment); + const permOutput = outputForPermutation( + experimentOutput, + permIndex(), + ); + return permOutput.h?.length + ? permOutput.h[permOutput.h.length - 1] + : 0; + }; return (
  • {experiment.name}/{perm.name}: {h().toFixed()} m @@ -128,7 +158,7 @@ function FinalHeights() { ); }} - + ); }} diff --git a/apps/class-solid/src/components/Experiment.tsx b/apps/class-solid/src/components/Experiment.tsx index 579b579c..11333b37 100644 --- a/apps/class-solid/src/components/Experiment.tsx +++ b/apps/class-solid/src/components/Experiment.tsx @@ -52,7 +52,7 @@ export function AddExperimentDialog(props: { description: "", reference: { config: {} }, permutations: [], - running: false, + running: false as const, }; }; @@ -130,14 +130,15 @@ export function ExperimentSettingsDialog(props: { ); } -function RunningIndicator() { +function RunningIndicator(props: { progress: number | false }) { return ( -
    +
    - Running ... + + Running {props.progress ? (props.progress * 100).toFixed() : 100}% ... +
    ); } @@ -180,6 +183,9 @@ function DownloadExperimentArchive(props: { experiment: Experiment }) { const [url, setUrl] = createSignal(""); createEffect(async () => { const archive = await createArchive(props.experiment); + if (!archive) { + return; + } const objectUrl = URL.createObjectURL(archive); setUrl(objectUrl); onCleanup(() => URL.revokeObjectURL(objectUrl)); @@ -242,7 +248,10 @@ export function ExperimentCard(props: { /> - }> + } + > { name: string; config: C; - output?: ClassOutput | undefined; // TODO Could use per run state to show progress of run of reference and each permutation // running: boolean; } @@ -22,16 +21,44 @@ export interface Experiment { name: string; description: string; reference: { + // TODO change reference.config to config, as there are no other keys in reference config: PartialConfig; - output?: ClassOutput | undefined; }; permutations: Permutation[]; - running: boolean; + running: number | false; } export const [experiments, setExperiments] = createStore([]); export const [analyses, setAnalyses] = createStore([]); +interface ExperimentOutput { + reference: ClassOutput; + permutations: ClassOutput[]; +} + +// Outputs must store outside store as they are too big to wrap in proxy +export const outputs: ExperimentOutput[] = []; + +export function outputForExperiment( + index: number | Experiment, +): ExperimentOutput | undefined { + if (typeof index === "object") { + const i = experiments.indexOf(index); + return outputs[i]; + } + return outputs[index]; +} + +export function outputForPermutation( + experiment: ExperimentOutput | undefined, + permutationIndex: number, +) { + if (!experiment || experiment.permutations.length <= permutationIndex) { + return { t: [], h: [], theta: [], dtheta: [] }; + } + return experiment.permutations[permutationIndex]; +} + // biome-ignore lint/suspicious/noExplicitAny: recursion is hard to type function mergeConfigurations(reference: any, permutation: any) { const merged = { ...reference }; @@ -52,51 +79,59 @@ function mergeConfigurations(reference: any, permutation: any) { } export async function runExperiment(id: number) { - const exp = findExperiment(id); + const exp = experiments[id]; - setExperiments( - id, - produce((e) => { - e.running = true; - }), - ); + setExperiments(id, "running", 0); // TODO make lazy, if config does not change do not rerun // or make more specific like runReference and runPermutation + console.time("Running experiment"); + + // TODO figure out why duplicating experiment with permutation sweep takes to so long + // slowest item now is updating progress in running + // as it most likely triggering a rerender of the most of the app + // if I remove the timeseries analysis it is much faster // Run reference - const newOutput = await runClass(exp.reference.config); + console.time("Running reference"); + const referenceConfig = unwrap(exp.reference.config); + const newOutput = await runClass(referenceConfig); + console.timeEnd("Running reference"); - setExperiments( - id, - produce((e) => { - e.reference.output = newOutput; - }), - ); + console.time("Store reference output"); + setExperiments(id, "running", 1 / (exp.permutations.length + 1)); + + outputs[id] = { + reference: newOutput, + permutations: [], + }; + console.timeEnd("Store reference output"); // Run permutations - for (const key in exp.permutations) { - const perm = exp.permutations[key]; - const combinedConfig = mergeConfigurations( - exp.reference.config, - perm.config, - ); + let permCounter = 0; + for (const proxiedPerm of exp.permutations) { + const permConfig = unwrap(proxiedPerm.config); + const combinedConfig = mergeConfigurations(referenceConfig, permConfig); + console.time(`Running permutation ${permCounter}`); const newOutput = await runClass(combinedConfig); + console.timeEnd(`Running permutation ${permCounter}`); + console.time(`Store permutation ${permCounter} progress`); setExperiments( id, - produce((e) => { - e.permutations[key].output = newOutput; - }), + "running", + (1 + permCounter) / (exp.permutations.length + 1), ); + console.timeEnd(`Store permutation ${permCounter} progress`); + console.time(`Store permutation ${permCounter} output`); + outputs[id].permutations[permCounter] = newOutput; + console.timeEnd(`Store permutation ${permCounter} output`); + permCounter++; } - setExperiments( - id, - produce((e) => { - e.running = false; - }), - ); + console.timeEnd("Running experiment"); + + setExperiments(id, "running", false); // If no analyis are set then add all of them if (analyses.length === 0) { @@ -151,25 +186,19 @@ export async function uploadExperiment(rawData: unknown) { export function duplicateExperiment(id: number) { const original = structuredClone(findExperiment(id)); - addExperiment( - original.reference.config, - `Copy of ${original.name}`, - original.description, - ); - let key = 0; - for (const perm of original.permutations) { - setPermutationConfigInExperiment( - experiments.length - 1, - key++, - perm.config, - perm.name, - ); - } + const newExperiment = { + ...original, + name: `Copy of ${original.name}`, + description: original.description, + running: 0, + }; + setExperiments(experiments.length, newExperiment); runExperiment(experiments.length - 1); } export function deleteExperiment(index: number) { setExperiments(experiments.filter((_, i) => i !== index)); + outputs.splice(index, 1); } export async function modifyExperiment( @@ -223,6 +252,7 @@ export async function deletePermutationFromExperiment( setExperiments(experimentIndex, "permutations", (perms) => perms.filter((_, i) => i !== permutationIndex), ); + outputs[experimentIndex].permutations.splice(permutationIndex, 1); } export function findPermutation(exp: Experiment, permutationName: string) { From cfbf6ac4792aa830c48e0418d3d8e14494bd7a92 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 8 Nov 2024 16:06:43 +0100 Subject: [PATCH 09/21] Add permutation sweep button --- .../src/components/PermutationSweepButton.tsx | 127 ++++++++++++++++++ .../src/components/PermutationsList.tsx | 7 +- packages/class/package.json | 6 + packages/class/src/sweep.test.ts | 110 +++++++++++++++ packages/class/src/sweep.ts | 53 ++++++++ 5 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 apps/class-solid/src/components/PermutationSweepButton.tsx create mode 100644 packages/class/src/sweep.test.ts create mode 100644 packages/class/src/sweep.ts diff --git a/apps/class-solid/src/components/PermutationSweepButton.tsx b/apps/class-solid/src/components/PermutationSweepButton.tsx new file mode 100644 index 00000000..ebec8f18 --- /dev/null +++ b/apps/class-solid/src/components/PermutationSweepButton.tsx @@ -0,0 +1,127 @@ +import { type Sweep, performSweep } from "@classmodel/class/sweep"; +import { + type PartialConfig, + overwriteDefaultsInJsonSchema, +} from "@classmodel/class/validate"; +import { For, createMemo, createSignal } from "solid-js"; +import { unwrap } from "solid-js/store"; +import { Button } from "~/components/ui/button"; +import { + type Experiment, + type Permutation, + runExperiment, + setExperiments, +} from "~/lib/store"; +import { jsonSchemaOfNamedConfig } from "./NamedConfig"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./ui/dialog"; + +function nameForPermutation(config: PartialConfig): string { + const chunks = []; + for (const [section, params] of Object.entries(config)) { + const paramChunks = []; + for (const [param, value] of Object.entries(params)) { + paramChunks.push(`${param}=${value}`); + } + // Add section? + chunks.push(paramChunks.join(",")); + } + return chunks.join(","); +} + +function config2permutation(config: PartialConfig): Permutation { + return { + config, + name: nameForPermutation(config), + }; +} + +function configs2Permutations(configs: PartialConfig[]): Permutation[] { + return configs.map(config2permutation); +} + +export function PermutationSweepButton(props: { + experiment: Experiment; + experimentIndex: number; +}) { + const jsonSchemaOfPermutation = createMemo(() => { + return overwriteDefaultsInJsonSchema( + jsonSchemaOfNamedConfig, + unwrap(props.experiment.reference.config), + ); + }); + + const sweeps: Sweep[] = [ + { + section: "initialState", + parameter: "h_0", + start: 100, + step: 100, + steps: 5, + }, + { + section: "mixedLayer", + parameter: "beta", + start: 0.1, + step: 0.1, + steps: 5, + }, + ]; + + function addSweep() { + const configs = performSweep(sweeps); + const perms = configs2Permutations(configs); + setOpen(false); + // TODO overwrite or append to existing permutations? + setExperiments(props.experimentIndex, "permutations", perms); + runExperiment(props.experimentIndex); + } + const [open, setOpen] = createSignal(false); + return ( + + } + > + S + + + + + Perform a sweep over parameters + + + {/* TODO make configure by user, using a form with user can pick the parameter, start, step, and steps. */} +
    +

    + This will create a set of permutations, for combination of the + following parameters: +

    +
      + + {(sweep) => ( +
    • + {sweep.section}.{sweep.parameter} from {sweep.start} with + increment of {sweep.step} for {sweep.steps} steps +
    • + )} +
      +
    +
    + + + +
    +
    + ); +} diff --git a/apps/class-solid/src/components/PermutationsList.tsx b/apps/class-solid/src/components/PermutationsList.tsx index 19dfcd53..e249f138 100644 --- a/apps/class-solid/src/components/PermutationsList.tsx +++ b/apps/class-solid/src/components/PermutationsList.tsx @@ -21,6 +21,7 @@ import { validate, } from "./NamedConfig"; import { ObjectField } from "./ObjectField"; +import { PermutationSweepButton } from "./PermutationSweepButton"; import { ajvForm } from "./ajvForm"; import { MdiCakeVariantOutline, @@ -318,8 +319,12 @@ export function PermutationsList(props: { experiment={props.experiment} experimentIndex={props.experimentIndex} /> + -
      +
        {(perm, permutationIndex) => (
      • diff --git a/packages/class/package.json b/packages/class/package.json index 7013b2ea..0c63bc68 100644 --- a/packages/class/package.json +++ b/packages/class/package.json @@ -41,6 +41,12 @@ "default": "./dist/validate.js", "types": "./dist/validate.d.ts" } + }, + "./sweep": { + "import": { + "default": "./dist/sweep.js", + "types": "./dist/sweep.d.ts" + } } }, "homepage": "https://classmodel.github.io/class-web", diff --git a/packages/class/src/sweep.test.ts b/packages/class/src/sweep.test.ts new file mode 100644 index 00000000..2d277119 --- /dev/null +++ b/packages/class/src/sweep.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert"; +import test, { describe } from "node:test"; +import { performSweep } from "./sweep.js"; + +describe("performSweep", () => { + test("zero sweeps", () => { + const perms = performSweep([]); + assert.deepEqual(perms, []); + }); + + test("one sweep", () => { + const sweeps = [ + { + section: "initialState", + parameter: "h_0", + start: 100, + step: 100, + steps: 5, + }, + ]; + + const perms = performSweep(sweeps); + + const expected = [ + { + initialState: { + h_0: 100, + }, + }, + { + initialState: { + h_0: 200, + }, + }, + { + initialState: { + h_0: 300, + }, + }, + { + initialState: { + h_0: 400, + }, + }, + { + initialState: { + h_0: 500, + }, + }, + ]; + assert.deepEqual(perms, expected); + }); + + test("two sweeps", () => { + const sweeps = [ + { + section: "initialState", + parameter: "h_0", + start: 100, + step: 100, + steps: 2, + }, + { + section: "mixedLayer", + parameter: "beta", + start: 0.1, + step: 0.1, + steps: 2, + }, + ]; + + const perms = performSweep(sweeps); + + const expected = [ + { + initialState: { + h_0: 100, + }, + mixedLayer: { + beta: 0.1, + }, + }, + { + initialState: { + h_0: 100, + }, + mixedLayer: { + beta: 0.2, + }, + }, + { + initialState: { + h_0: 200, + }, + mixedLayer: { + beta: 0.1, + }, + }, + { + initialState: { + h_0: 200, + }, + mixedLayer: { + beta: 0.2, + }, + }, + ]; + assert.deepEqual(perms, expected); + }); +}); diff --git a/packages/class/src/sweep.ts b/packages/class/src/sweep.ts new file mode 100644 index 00000000..2777cb31 --- /dev/null +++ b/packages/class/src/sweep.ts @@ -0,0 +1,53 @@ +import type { PartialConfig } from "./validate.js"; + +export interface Sweep { + section: string; + parameter: string; + start: number; + step: number; + steps: number; +} +function cartesianProduct(values: PartialConfig[][]): PartialConfig[] { + if (values.length === 0) return []; + return values.reduce( + (acc, curr) => { + return acc.flatMap((a) => + curr.map((b) => { + const merged = { ...a }; + for (const [section, params] of Object.entries(b)) { + merged[section as keyof typeof merged] = { + ...merged[section as keyof typeof merged], + ...params, + }; + } + return merged; + }), + ); + }, + [{}], + ); +} +export function performSweep(sweeps: Sweep[]): PartialConfig[] { + if (sweeps.length === 0) { + return []; + } + + const values = []; + for (const sweep of sweeps) { + const sweepValues = []; + for (let i = 0; i < sweep.steps; i++) { + const value = Number.parseFloat( + (sweep.start + i * sweep.step).toFixed(4), + ); + const perm = { + [sweep.section]: { + [sweep.parameter]: value, + }, + }; + sweepValues.push(perm); + } + values.push(sweepValues); + } + + return cartesianProduct(values); +} From c9eaeadc2b4dbde32a136d4e568055a0c52be269 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 8 Nov 2024 16:09:00 +0100 Subject: [PATCH 10/21] Add test for when state is too big, still need to implement --- apps/class-solid/tests/big-app-state.json | 304 ++++++++++++++++++++++ apps/class-solid/tests/share.spec.ts | 30 ++- 2 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 apps/class-solid/tests/big-app-state.json diff --git a/apps/class-solid/tests/big-app-state.json b/apps/class-solid/tests/big-app-state.json new file mode 100644 index 00000000..be9450c8 --- /dev/null +++ b/apps/class-solid/tests/big-app-state.json @@ -0,0 +1,304 @@ +{ + "name": "My experiment 1", + "description": "", + "reference": { + "initialState": { + "h_0": 200, + "theta_0": 288, + "dtheta_0": 1, + "q_0": 0.008, + "dq_0": -0.001 + }, + "timeControl": { + "dt": 60, + "runtime": 43200 + }, + "mixedLayer": { + "wtheta": 0.1, + "advtheta": 0, + "gammatheta": 0.006, + "wq": 0.0001, + "advq": 0, + "gammaq": 0, + "divU": 0, + "beta": 0.2 + } + }, + "permutations": [ + { + "name": "h_0=100, beta=0.1", + "config": { + "initialState": { + "h_0": 100 + }, + "mixedLayer": { + "beta": 0.1 + } + } + }, + { + "name": "h_0=100, beta=0.3", + "config": { + "initialState": { + "h_0": 100 + }, + "mixedLayer": { + "beta": 0.3 + } + } + }, + { + "name": "h_0=100, beta=0.4", + "config": { + "initialState": { + "h_0": 100 + }, + "mixedLayer": { + "beta": 0.4 + } + } + }, + { + "name": "h_0=100, beta=0.5", + "config": { + "initialState": { + "h_0": 100 + }, + "mixedLayer": { + "beta": 0.5 + } + } + }, + { + "name": "h_0=100, beta=0.6", + "config": { + "initialState": { + "h_0": 100 + }, + "mixedLayer": { + "beta": 0.6 + } + } + }, + { + "name": "h_0=300, beta=0.1", + "config": { + "initialState": { + "h_0": 300 + }, + "mixedLayer": { + "beta": 0.1 + } + } + }, + { + "name": "h_0=300, beta=0.3", + "config": { + "initialState": { + "h_0": 300 + }, + "mixedLayer": { + "beta": 0.3 + } + } + }, + { + "name": "h_0=300, beta=0.4", + "config": { + "initialState": { + "h_0": 300 + }, + "mixedLayer": { + "beta": 0.4 + } + } + }, + { + "name": "h_0=300, beta=0.5", + "config": { + "initialState": { + "h_0": 300 + }, + "mixedLayer": { + "beta": 0.5 + } + } + }, + { + "name": "h_0=300, beta=0.6", + "config": { + "initialState": { + "h_0": 300 + }, + "mixedLayer": { + "beta": 0.6 + } + } + }, + { + "name": "h_0=400, beta=0.1", + "config": { + "initialState": { + "h_0": 400 + }, + "mixedLayer": { + "beta": 0.1 + } + } + }, + { + "name": "h_0=400, beta=0.3", + "config": { + "initialState": { + "h_0": 400 + }, + "mixedLayer": { + "beta": 0.3 + } + } + }, + { + "name": "h_0=400, beta=0.4", + "config": { + "initialState": { + "h_0": 400 + }, + "mixedLayer": { + "beta": 0.4 + } + } + }, + { + "name": "h_0=400, beta=0.5", + "config": { + "initialState": { + "h_0": 400 + }, + "mixedLayer": { + "beta": 0.5 + } + } + }, + { + "name": "h_0=400, beta=0.6", + "config": { + "initialState": { + "h_0": 400 + }, + "mixedLayer": { + "beta": 0.6 + } + } + }, + { + "name": "h_0=500, beta=0.1", + "config": { + "initialState": { + "h_0": 500 + }, + "mixedLayer": { + "beta": 0.1 + } + } + }, + { + "name": "h_0=500, beta=0.3", + "config": { + "initialState": { + "h_0": 500 + }, + "mixedLayer": { + "beta": 0.3 + } + } + }, + { + "name": "h_0=500, beta=0.4", + "config": { + "initialState": { + "h_0": 500 + }, + "mixedLayer": { + "beta": 0.4 + } + } + }, + { + "name": "h_0=500, beta=0.5", + "config": { + "initialState": { + "h_0": 500 + }, + "mixedLayer": { + "beta": 0.5 + } + } + }, + { + "name": "h_0=500, beta=0.6", + "config": { + "initialState": { + "h_0": 500 + }, + "mixedLayer": { + "beta": 0.6 + } + } + }, + { + "name": "h_0=600, beta=0.1", + "config": { + "initialState": { + "h_0": 600 + }, + "mixedLayer": { + "beta": 0.1 + } + } + }, + { + "name": "h_0=600, beta=0.3", + "config": { + "initialState": { + "h_0": 600 + }, + "mixedLayer": { + "beta": 0.3 + } + } + }, + { + "name": "h_0=600, beta=0.4", + "config": { + "initialState": { + "h_0": 600 + }, + "mixedLayer": { + "beta": 0.4 + } + } + }, + { + "name": "h_0=600, beta=0.5", + "config": { + "initialState": { + "h_0": 600 + }, + "mixedLayer": { + "beta": 0.5 + } + } + }, + { + "name": "h_0=600, beta=0.6", + "config": { + "initialState": { + "h_0": 600 + }, + "mixedLayer": { + "beta": 0.6 + } + } + } + ] +} diff --git a/apps/class-solid/tests/share.spec.ts b/apps/class-solid/tests/share.spec.ts index 0ed2b09e..3dbdce14 100644 --- a/apps/class-solid/tests/share.spec.ts +++ b/apps/class-solid/tests/share.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { type Page, expect, test } from "@playwright/test"; import { parseDownload } from "./helpers"; test("Create share link from an experiment", async ({ page }) => { @@ -45,3 +45,31 @@ test("Create share link from an experiment", async ({ page }) => { /My experiment 1: \d+ m/, ); }); + +test("Given large app state, sharing is not possible", async ({ page }) => { + await page.goto("/"); + // Upload a big experiment X times + const x = 10; + for (let i = 0; i < x; i++) { + await uploadBigExperiment(page); + } + + await page.getByRole("button", { name: "Share" }).click(); + await page.waitForSelector( + "text=Cannot share application state, it is too large. Please download each experiment by itself or make it smaller by removing permutations and/or experiments.", + ); +}); + +async function uploadBigExperiment(page: Page) { + await page.getByRole("button", { name: "Add experiment" }).click(); + const thisfile = new URL(import.meta.url).pathname; + // Could not get playwrigth to work same way as human + // as file chooser is not shown sometimes + // so using input element directly + const file = thisfile.replace("share.spec.ts", "big-app-state.json"); + await page.locator("input[type=file]").setInputFiles(file); + // Wait for experiment to run to completion + await expect(page.getByRole("status", { name: "Running" })).not.toBeVisible(); + // Close popup + await page.getByRole("heading", { name: "Experiments" }).click(); +} From 4616f40476248ef1a2691a6a03f65d3d30681932 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 8 Nov 2024 16:19:04 +0100 Subject: [PATCH 11/21] Make share link less ugly --- apps/class-solid/src/lib/encode.ts | 4 ++-- apps/class-solid/src/lib/onPageTransition.ts | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/class-solid/src/lib/encode.ts b/apps/class-solid/src/lib/encode.ts index 45959477..d0756042 100644 --- a/apps/class-solid/src/lib/encode.ts +++ b/apps/class-solid/src/lib/encode.ts @@ -3,7 +3,7 @@ import { unwrap } from "solid-js/store"; import type { Analysis, Experiment } from "./store"; export function decodeAppState(encoded: string): [Experiment[], Analysis[]] { - const decoded = decodeURIComponent(encoded); + const decoded = decodeURI(encoded); const parsed = JSON.parse(decoded); // TODO use ajv to validate experiment, permutation, config and analysis const experiments: Experiment[] = parsed.experiments.map( @@ -69,5 +69,5 @@ export function encodeAppState( type: ana.type, })), }; - return encodeURIComponent(JSON.stringify(minimizedState, undefined, 0)); + return encodeURI(JSON.stringify(minimizedState, undefined, 0)); } diff --git a/apps/class-solid/src/lib/onPageTransition.ts b/apps/class-solid/src/lib/onPageTransition.ts index 5e7707ec..a8d88317 100644 --- a/apps/class-solid/src/lib/onPageTransition.ts +++ b/apps/class-solid/src/lib/onPageTransition.ts @@ -70,8 +70,5 @@ export function saveAppState() { ) { localStorage.removeItem(localStorageName); } - // TODO instead of storing to local storage store to url - // pro: multiple tabs will not share state - // con: ugly urls localStorage.setItem(localStorageName, appState); } From f49d7cf7d9474557a89d7db488e50563f803d402 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 11 Nov 2024 09:16:15 +0100 Subject: [PATCH 12/21] More tests --- packages/class/src/sweep.test.ts | 56 ++++++++++++++++++++++++++++++++ packages/class/src/sweep.ts | 2 ++ 2 files changed, 58 insertions(+) diff --git a/packages/class/src/sweep.test.ts b/packages/class/src/sweep.test.ts index 2d277119..e0fd06fd 100644 --- a/packages/class/src/sweep.test.ts +++ b/packages/class/src/sweep.test.ts @@ -107,4 +107,60 @@ describe("performSweep", () => { ]; assert.deepEqual(perms, expected); }); + + test("3 uneven sweeps", () => { + const sweeps = [ + { + section: "initialState", + parameter: "h_0", + start: 100, + step: 100, + steps: 2, + }, + { + section: "mixedLayer", + parameter: "beta", + start: 0.1, + step: 0.1, + steps: 3, + }, + { + section: "initialState", + parameter: "theta_0", + start: 268, + step: 5, + steps: 4, + }, + ]; + + const perms = performSweep(sweeps); + + const expected = [ + { initialState: { h_0: 100, theta_0: 268 }, mixedLayer: { beta: 0.1 } }, + { initialState: { h_0: 100, theta_0: 273 }, mixedLayer: { beta: 0.1 } }, + { initialState: { h_0: 100, theta_0: 278 }, mixedLayer: { beta: 0.1 } }, + { initialState: { h_0: 100, theta_0: 283 }, mixedLayer: { beta: 0.1 } }, + { initialState: { h_0: 100, theta_0: 268 }, mixedLayer: { beta: 0.2 } }, + { initialState: { h_0: 100, theta_0: 273 }, mixedLayer: { beta: 0.2 } }, + { initialState: { h_0: 100, theta_0: 278 }, mixedLayer: { beta: 0.2 } }, + { initialState: { h_0: 100, theta_0: 283 }, mixedLayer: { beta: 0.2 } }, + { initialState: { h_0: 100, theta_0: 268 }, mixedLayer: { beta: 0.3 } }, + { initialState: { h_0: 100, theta_0: 273 }, mixedLayer: { beta: 0.3 } }, + { initialState: { h_0: 100, theta_0: 278 }, mixedLayer: { beta: 0.3 } }, + { initialState: { h_0: 100, theta_0: 283 }, mixedLayer: { beta: 0.3 } }, + { initialState: { h_0: 200, theta_0: 268 }, mixedLayer: { beta: 0.1 } }, + { initialState: { h_0: 200, theta_0: 273 }, mixedLayer: { beta: 0.1 } }, + { initialState: { h_0: 200, theta_0: 278 }, mixedLayer: { beta: 0.1 } }, + { initialState: { h_0: 200, theta_0: 283 }, mixedLayer: { beta: 0.1 } }, + { initialState: { h_0: 200, theta_0: 268 }, mixedLayer: { beta: 0.2 } }, + { initialState: { h_0: 200, theta_0: 273 }, mixedLayer: { beta: 0.2 } }, + { initialState: { h_0: 200, theta_0: 278 }, mixedLayer: { beta: 0.2 } }, + { initialState: { h_0: 200, theta_0: 283 }, mixedLayer: { beta: 0.2 } }, + { initialState: { h_0: 200, theta_0: 268 }, mixedLayer: { beta: 0.3 } }, + { initialState: { h_0: 200, theta_0: 273 }, mixedLayer: { beta: 0.3 } }, + { initialState: { h_0: 200, theta_0: 278 }, mixedLayer: { beta: 0.3 } }, + { initialState: { h_0: 200, theta_0: 283 }, mixedLayer: { beta: 0.3 } }, + ]; + assert.deepEqual(perms, expected); + }); }); diff --git a/packages/class/src/sweep.ts b/packages/class/src/sweep.ts index 2777cb31..67e873ab 100644 --- a/packages/class/src/sweep.ts +++ b/packages/class/src/sweep.ts @@ -13,6 +13,8 @@ function cartesianProduct(values: PartialConfig[][]): PartialConfig[] { (acc, curr) => { return acc.flatMap((a) => curr.map((b) => { + // TODO move config merging to a separate function + // TODO make recursive and handle literals and arrays const merged = { ...a }; for (const [section, params] of Object.entries(b)) { merged[section as keyof typeof merged] = { From b5c9bc6b2823c35064889db610bc5fa64de8281f Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 11 Nov 2024 11:08:38 +0100 Subject: [PATCH 13/21] Skip shareable link too long test --- apps/class-solid/tests/share.spec.ts | 40 +++++++++++++++------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/apps/class-solid/tests/share.spec.ts b/apps/class-solid/tests/share.spec.ts index 3dbdce14..f2f722df 100644 --- a/apps/class-solid/tests/share.spec.ts +++ b/apps/class-solid/tests/share.spec.ts @@ -1,4 +1,4 @@ -import { type Page, expect, test } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { parseDownload } from "./helpers"; test("Create share link from an experiment", async ({ page }) => { @@ -47,11 +47,27 @@ test("Create share link from an experiment", async ({ page }) => { }); test("Given large app state, sharing is not possible", async ({ page }) => { + test.skip( + true, + "Plotting is too slow, to render 13 experiments with 24 permuations each", + ); await page.goto("/"); - // Upload a big experiment X times - const x = 10; - for (let i = 0; i < x; i++) { - await uploadBigExperiment(page); + + // Create a new experiment + await page.getByTitle("Add experiment").click(); + await page.getByRole("menuitem", { name: "From scratch" }).click(); + await page.getByRole("button", { name: "Run" }).click(); + // Add permutation sweep + await page.getByRole("button", { name: "S", exact: true }).click(); + await page.getByRole("button", { name: "Perform sweep" }).click(); + + // Duplicate the experiment 12 times + const times = 12; + for (let i = 0; i < times; i++) { + await page + .getByLabel("My experiment 1", { exact: true }) + .getByRole("button", { name: "Duplicate experiment" }) + .click(); } await page.getByRole("button", { name: "Share" }).click(); @@ -59,17 +75,3 @@ test("Given large app state, sharing is not possible", async ({ page }) => { "text=Cannot share application state, it is too large. Please download each experiment by itself or make it smaller by removing permutations and/or experiments.", ); }); - -async function uploadBigExperiment(page: Page) { - await page.getByRole("button", { name: "Add experiment" }).click(); - const thisfile = new URL(import.meta.url).pathname; - // Could not get playwrigth to work same way as human - // as file chooser is not shown sometimes - // so using input element directly - const file = thisfile.replace("share.spec.ts", "big-app-state.json"); - await page.locator("input[type=file]").setInputFiles(file); - // Wait for experiment to run to completion - await expect(page.getByRole("status", { name: "Running" })).not.toBeVisible(); - // Close popup - await page.getByRole("heading", { name: "Experiments" }).click(); -} From bc3f82068c7bd9a79e3e6e81f65d70fee672d2f5 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 11 Nov 2024 11:14:18 +0100 Subject: [PATCH 14/21] Dont update progress for performance reasons --- apps/class-solid/src/lib/store.ts | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index 87a5a0e0..65d85192 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -85,7 +85,6 @@ export async function runExperiment(id: number) { // TODO make lazy, if config does not change do not rerun // or make more specific like runReference and runPermutation - console.time("Running experiment"); // TODO figure out why duplicating experiment with permutation sweep takes to so long // slowest item now is updating progress in running @@ -93,43 +92,35 @@ export async function runExperiment(id: number) { // if I remove the timeseries analysis it is much faster // Run reference - console.time("Running reference"); const referenceConfig = unwrap(exp.reference.config); const newOutput = await runClass(referenceConfig); - console.timeEnd("Running reference"); - console.time("Store reference output"); - setExperiments(id, "running", 1 / (exp.permutations.length + 1)); + // TODO updating progress, triggers cascade of rerenders causing slow down + // for now disable progress updates + // console.time("Store reference output"); + // setExperiments(id, "running", 1 / (exp.permutations.length + 1)); outputs[id] = { reference: newOutput, permutations: [], }; - console.timeEnd("Store reference output"); // Run permutations let permCounter = 0; for (const proxiedPerm of exp.permutations) { const permConfig = unwrap(proxiedPerm.config); const combinedConfig = mergeConfigurations(referenceConfig, permConfig); - console.time(`Running permutation ${permCounter}`); const newOutput = await runClass(combinedConfig); - console.timeEnd(`Running permutation ${permCounter}`); - - console.time(`Store permutation ${permCounter} progress`); - setExperiments( - id, - "running", - (1 + permCounter) / (exp.permutations.length + 1), - ); - console.timeEnd(`Store permutation ${permCounter} progress`); - console.time(`Store permutation ${permCounter} output`); + + // setExperiments( + // id, + // "running", + // (1 + permCounter) / (exp.permutations.length + 1), + // ); outputs[id].permutations[permCounter] = newOutput; - console.timeEnd(`Store permutation ${permCounter} output`); permCounter++; } - console.timeEnd("Running experiment"); setExperiments(id, "running", false); From 3439d352a8e8bd17a411bce3b0d6644faa980ac0 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 11 Nov 2024 11:14:29 +0100 Subject: [PATCH 15/21] Dont share link when it is too big --- .../src/components/ShareButton.tsx | 69 +++++++++++-------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/apps/class-solid/src/components/ShareButton.tsx b/apps/class-solid/src/components/ShareButton.tsx index 12f883c1..e769802e 100644 --- a/apps/class-solid/src/components/ShareButton.tsx +++ b/apps/class-solid/src/components/ShareButton.tsx @@ -10,7 +10,6 @@ import { import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, DialogTrigger, @@ -18,6 +17,8 @@ import { import { TextField, TextFieldInput } from "./ui/text-field"; import { showToast } from "./ui/toast"; +const MAX_SHAREABLE_LINK_LENGTH = 32_000; + export function ShareButton() { const [open, setOpen] = createSignal(false); const [isCopied, setIsCopied] = createSignal(false); @@ -65,7 +66,18 @@ export function ShareButton() { Share link - + + + Cannot share application state, it is too large. Please download + each experiment by itself or make it smaller by removing + permutations and/or experiments. +

        + } + > + +
        + + + + +
        +
        Link copied to clipboard
        From 2450904f0be1bf817150202b9098002154193c05 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 11 Nov 2024 11:40:12 +0100 Subject: [PATCH 16/21] Move TODOs to issues and implement some todos --- .../src/components/PermutationSweepButton.tsx | 2 - .../src/components/ShareButton.tsx | 82 ++++++++++--------- apps/class-solid/src/lib/encode.ts | 3 +- apps/class-solid/src/lib/store.ts | 17 ---- packages/class/src/sweep.ts | 2 +- 5 files changed, 47 insertions(+), 59 deletions(-) diff --git a/apps/class-solid/src/components/PermutationSweepButton.tsx b/apps/class-solid/src/components/PermutationSweepButton.tsx index ebec8f18..d83f0f0a 100644 --- a/apps/class-solid/src/components/PermutationSweepButton.tsx +++ b/apps/class-solid/src/components/PermutationSweepButton.tsx @@ -78,7 +78,6 @@ export function PermutationSweepButton(props: { const configs = performSweep(sweeps); const perms = configs2Permutations(configs); setOpen(false); - // TODO overwrite or append to existing permutations? setExperiments(props.experimentIndex, "permutations", perms); runExperiment(props.experimentIndex); } @@ -99,7 +98,6 @@ export function PermutationSweepButton(props: { Perform a sweep over parameters - {/* TODO make configure by user, using a form with user can pick the parameter, start, step, and steps. */}

        This will create a set of permutations, for combination of the diff --git a/apps/class-solid/src/components/ShareButton.tsx b/apps/class-solid/src/components/ShareButton.tsx index e769802e..35f27061 100644 --- a/apps/class-solid/src/components/ShareButton.tsx +++ b/apps/class-solid/src/components/ShareButton.tsx @@ -59,7 +59,6 @@ export function ShareButton() { return (

        - {/* TODO disable when there are zero experiments or analyses */} Share @@ -77,43 +76,50 @@ export function ShareButton() {

        } > -
        - Anyone with{" "} - - this link - {" "} - will be able to view the current application state in their web - browser. -
        -
        - - - - -
        + 0} + fallback={ +

        Nothing to share. Please add at least one experiment.

        + } + > +
        + Anyone with{" "} + + this link + {" "} + will be able to view the current application state in their web + browser. +
        +
        + + + + +
        +
        Link copied to clipboard diff --git a/apps/class-solid/src/lib/encode.ts b/apps/class-solid/src/lib/encode.ts index d0756042..8778a9e4 100644 --- a/apps/class-solid/src/lib/encode.ts +++ b/apps/class-solid/src/lib/encode.ts @@ -5,7 +5,8 @@ import type { Analysis, Experiment } from "./store"; export function decodeAppState(encoded: string): [Experiment[], Analysis[]] { const decoded = decodeURI(encoded); const parsed = JSON.parse(decoded); - // TODO use ajv to validate experiment, permutation, config and analysis + // TODO use ajv to validate experiment, permutation, and analysis + // now only config is validated const experiments: Experiment[] = parsed.experiments.map( (exp: { name: string; diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index 65d85192..307ddb23 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -86,20 +86,10 @@ export async function runExperiment(id: number) { // TODO make lazy, if config does not change do not rerun // or make more specific like runReference and runPermutation - // TODO figure out why duplicating experiment with permutation sweep takes to so long - // slowest item now is updating progress in running - // as it most likely triggering a rerender of the most of the app - // if I remove the timeseries analysis it is much faster - // Run reference const referenceConfig = unwrap(exp.reference.config); const newOutput = await runClass(referenceConfig); - // TODO updating progress, triggers cascade of rerenders causing slow down - // for now disable progress updates - // console.time("Store reference output"); - // setExperiments(id, "running", 1 / (exp.permutations.length + 1)); - outputs[id] = { reference: newOutput, permutations: [], @@ -111,17 +101,10 @@ export async function runExperiment(id: number) { const permConfig = unwrap(proxiedPerm.config); const combinedConfig = mergeConfigurations(referenceConfig, permConfig); const newOutput = await runClass(combinedConfig); - - // setExperiments( - // id, - // "running", - // (1 + permCounter) / (exp.permutations.length + 1), - // ); outputs[id].permutations[permCounter] = newOutput; permCounter++; } - setExperiments(id, "running", false); // If no analyis are set then add all of them diff --git a/packages/class/src/sweep.ts b/packages/class/src/sweep.ts index 67e873ab..818fe79d 100644 --- a/packages/class/src/sweep.ts +++ b/packages/class/src/sweep.ts @@ -13,7 +13,7 @@ function cartesianProduct(values: PartialConfig[][]): PartialConfig[] { (acc, curr) => { return acc.flatMap((a) => curr.map((b) => { - // TODO move config merging to a separate function + // TODO move config merging to a separate function or reuse // TODO make recursive and handle literals and arrays const merged = { ...a }; for (const [section, params] of Object.entries(b)) { From 130eeb92326731475437572b0e3844e9651e410f Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 11 Nov 2024 12:34:53 +0100 Subject: [PATCH 17/21] Move start experiment buttons to own file + when no experiments then show start cards Refs #63 --- apps/class-solid/src/components/Nav.tsx | 4 +- .../src/components/StartButtons.tsx | 245 ++++++++++++++++++ .../src/components/UploadExperiment.tsx | 57 ---- apps/class-solid/src/components/icons.tsx | 69 +++++ .../src/lib/{onPageTransition.ts => state.ts} | 7 +- apps/class-solid/src/routes/index.tsx | 37 +-- 6 files changed, 326 insertions(+), 93 deletions(-) create mode 100644 apps/class-solid/src/components/StartButtons.tsx delete mode 100644 apps/class-solid/src/components/UploadExperiment.tsx rename apps/class-solid/src/lib/{onPageTransition.ts => state.ts} (92%) diff --git a/apps/class-solid/src/components/Nav.tsx b/apps/class-solid/src/components/Nav.tsx index 33a232cd..d5216554 100644 --- a/apps/class-solid/src/components/Nav.tsx +++ b/apps/class-solid/src/components/Nav.tsx @@ -1,5 +1,5 @@ import { useLocation } from "@solidjs/router"; -import { saveAppState } from "~/lib/onPageTransition"; +import { saveToLocalStorage } from "~/lib/state"; import { ShareButton } from "./ShareButton"; import { MdiContentSave } from "./icons"; @@ -23,7 +23,7 @@ export default function Nav() { + + ); +} + +function StartFromSratchButton(props: { onClick: () => void }) { + return ( + + ); +} + +function StartFromUploadButton() { + let ref: HTMLInputElement | undefined; + + function openFilePicker() { + ref?.click(); + } + + function onUpload( + event: Event & { + currentTarget: HTMLInputElement; + target: HTMLInputElement; + }, + ) { + if (!event.target.files) { + return; + } + const file = event.target.files[0]; + file + .text() + .then((body) => { + const rawData = JSON.parse(body); + return uploadExperiment(rawData); + }) + .then(() => { + showToast({ + title: "Experiment uploaded", + variant: "success", + duration: 1000, + }); + }) + .catch((error) => { + console.error(error); + showToast({ + title: "Failed to upload experiment", + description: `${error}`, + variant: "error", + }); + }); + } + + return ( + <> + + + + ); +} + +function PresetPicker(props: { + open: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + + + + Pick a preset + +

        Presets are not implemented yet

        +
        +
        + ); +} + +function StartFromPresetButton() { + const [open, setOpen] = createSignal(false); + return ( + <> + + + + ); +} + +export function StartButtons(props: { + onFromSratchClick: () => void; +}) { + return ( + + + + + + + ); +} + +export function UploadExperiment() { + let ref: HTMLInputElement | undefined; + + function openFilePicker() { + ref?.click(); + } + + function onUpload( + event: Event & { + currentTarget: HTMLInputElement; + target: HTMLInputElement; + }, + ) { + if (!event.target.files) { + return; + } + const file = event.target.files[0]; + file + .text() + .then((body) => { + const rawData = JSON.parse(body); + return uploadExperiment(rawData); + }) + .then(() => { + showToast({ + title: "Experiment uploaded", + variant: "success", + duration: 1000, + }); + }) + .catch((error) => { + console.error(error); + showToast({ + title: "Failed to upload experiment", + description: `${error}`, + variant: "error", + }); + }); + } + return ( + <> + + + + ); +} + +export function StartMenu(props: { + onFromSratchClick: () => void; +}) { + const [open, setOpen] = createSignal(false); + return ( + + + + + + + Add experiment + + + From scratch + + + + + + + From preset + + + + + ); +} diff --git a/apps/class-solid/src/components/UploadExperiment.tsx b/apps/class-solid/src/components/UploadExperiment.tsx deleted file mode 100644 index 149a2df6..00000000 --- a/apps/class-solid/src/components/UploadExperiment.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { uploadExperiment } from "~/lib/store"; -import { showToast } from "./ui/toast"; - -export function UploadExperiment() { - let ref: HTMLInputElement | undefined; - - function openFilePicker() { - ref?.click(); - } - - function onUpload( - event: Event & { - currentTarget: HTMLInputElement; - target: HTMLInputElement; - }, - ) { - if (!event.target.files) { - return; - } - const file = event.target.files[0]; - file - .text() - .then((body) => { - const rawData = JSON.parse(body); - return uploadExperiment(rawData); - }) - .then(() => { - showToast({ - title: "Experiment uploaded", - variant: "success", - duration: 1000, - }); - }) - .catch((error) => { - console.error(error); - showToast({ - title: "Failed to upload experiment", - description: `${error}`, - variant: "error", - }); - }); - } - return ( - <> - - - - ); -} diff --git a/apps/class-solid/src/components/icons.tsx b/apps/class-solid/src/components/icons.tsx index 24b37460..02f34995 100644 --- a/apps/class-solid/src/components/icons.tsx +++ b/apps/class-solid/src/components/icons.tsx @@ -250,3 +250,72 @@ export function MdiContentSave(props: JSX.IntrinsicElements["svg"]) { ); } + +export function MdiBackupRestore(props: JSX.IntrinsicElements["svg"]) { + return ( + + Restore + + + ); +} + +export function MdiUpload(props: JSX.IntrinsicElements["svg"]) { + return ( + + Upload + + + ); +} + +export function MdiBeakerPlus(props: JSX.IntrinsicElements["svg"]) { + return ( + + Add + + + ); +} + +export function MdiFileDocumentOutline(props: JSX.IntrinsicElements["svg"]) { + return ( + + Preset + + + ); +} diff --git a/apps/class-solid/src/lib/onPageTransition.ts b/apps/class-solid/src/lib/state.ts similarity index 92% rename from apps/class-solid/src/lib/onPageTransition.ts rename to apps/class-solid/src/lib/state.ts index a8d88317..e6e974d3 100644 --- a/apps/class-solid/src/lib/onPageTransition.ts +++ b/apps/class-solid/src/lib/state.ts @@ -63,7 +63,7 @@ export async function onPageLoad() { navigate("/"); } -export function saveAppState() { +export function saveToLocalStorage() { const appState = encodeAppState(experiments, analyses); if ( appState === "%7B%22experiments%22%3A%5B%5D%2C%22analyses%22%3A%5B%5D%7D" @@ -71,4 +71,9 @@ export function saveAppState() { localStorage.removeItem(localStorageName); } localStorage.setItem(localStorageName, appState); + showToast({ + title: "State saved to local storage", + variant: "success", + duration: 1000, + }); } diff --git a/apps/class-solid/src/routes/index.tsx b/apps/class-solid/src/routes/index.tsx index 6a637308..1c5615c9 100644 --- a/apps/class-solid/src/routes/index.tsx +++ b/apps/class-solid/src/routes/index.tsx @@ -2,9 +2,8 @@ import { For, Show, createSignal, onMount } from "solid-js"; import { AnalysisCard } from "~/components/Analysis"; import { AddExperimentDialog, ExperimentCard } from "~/components/Experiment"; -import { UploadExperiment } from "~/components/UploadExperiment"; +import { StartButtons, StartMenu } from "~/components/StartButtons"; import { MdiPlusBox } from "~/components/icons"; -import { Button } from "~/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -15,11 +14,7 @@ import { } from "~/components/ui/dropdown-menu"; import { Flex } from "~/components/ui/flex"; import { Toaster } from "~/components/ui/toast"; -import { - hasLocalStorage, - loadFromLocalStorage, - onPageLoad, -} from "~/lib/onPageTransition"; +import { onPageLoad } from "~/lib/state"; import { addAnalysis, experiments } from "~/lib/store"; import { analyses } from "~/lib/store"; @@ -33,27 +28,7 @@ export default function Home() {

        Experiments - - - - - - Add experiment - - setOpenAddDialog(true)} - class="cursor-pointer" - > - From scratch - - - - - - Preset (not implemented) - - - + setOpenAddDialog(true)} />

        - - - + setOpenAddDialog(true)} /> {(experiment, index) => ( From b3d7026705b9cd31eb4107c5d3eb60e8c169ff7c Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 11 Nov 2024 13:09:35 +0100 Subject: [PATCH 18/21] Replace drop down menu for start experiment with dialog --- .../src/components/StartButtons.tsx | 145 ++++++++++++------ apps/class-solid/src/routes/index.tsx | 7 +- 2 files changed, 102 insertions(+), 50 deletions(-) diff --git a/apps/class-solid/src/components/StartButtons.tsx b/apps/class-solid/src/components/StartButtons.tsx index 81a8fbcd..8c339ddc 100644 --- a/apps/class-solid/src/components/StartButtons.tsx +++ b/apps/class-solid/src/components/StartButtons.tsx @@ -9,48 +9,77 @@ import { MdiUpload, } from "./icons"; import { Button } from "./ui/button"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "./ui/dropdown-menu"; + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./ui/dialog"; import { showToast } from "./ui/toast"; -function ResumeSessionButton() { +function ResumeSessionButton(props: { afterClick: () => void }) { return ( ); } -function StartFromSratchButton(props: { onClick: () => void }) { +function StartFromSratchButton(props: { + onClick: () => void; + afterClick: () => void; +}) { return ( ); } -function StartFromUploadButton() { +function StartFromUploadButton(props: { + afterClick: () => void; +}) { let ref: HTMLInputElement | undefined; function openFilePicker() { @@ -74,6 +103,7 @@ function StartFromUploadButton() { return uploadExperiment(rawData); }) .then(() => { + props.afterClick(); showToast({ title: "Experiment uploaded", variant: "success", @@ -81,6 +111,7 @@ function StartFromUploadButton() { }); }) .catch((error) => { + props.afterClick(); console.error(error); showToast({ title: "Failed to upload experiment", @@ -98,7 +129,9 @@ function StartFromUploadButton() { class="flex h-44 w-56 flex-col gap-2 border border-dashed" > -

        Start from upload

        + Start from upload

        }> +

        From upload

        +
        void; +}) { const [open, setOpen] = createSignal(false); return ( <> - + { + if (!v) { + props.afterClick(); + } + setOpen(v); + }} + /> ); @@ -146,14 +191,18 @@ function StartFromPresetButton() { export function StartButtons(props: { onFromSratchClick: () => void; + afterClick: () => void; }) { return ( - - - - - - + <> + + + + + ); } @@ -218,28 +267,26 @@ export function StartMenu(props: { const [open, setOpen] = createSignal(false); return ( - - - - - - Add experiment - - - From scratch - - - - - - - From preset - - - + + } + variant="ghost" + class="align-middle" + > + + + + + Add experiment + +
        + setOpen(false)} + onFromSratchClick={props.onFromSratchClick} + /> +
        +
        +
        ); } diff --git a/apps/class-solid/src/routes/index.tsx b/apps/class-solid/src/routes/index.tsx index 1c5615c9..24024ddd 100644 --- a/apps/class-solid/src/routes/index.tsx +++ b/apps/class-solid/src/routes/index.tsx @@ -37,7 +37,12 @@ export default function Home() { /> - setOpenAddDialog(true)} /> + + setOpenAddDialog(true)} + afterClick={() => {}} + /> + {(experiment, index) => ( From 5c8f2cef83ca63b359868f9c281548ae41cb7d62 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 11 Nov 2024 13:20:30 +0100 Subject: [PATCH 19/21] Use single button to create experiment in tests --- apps/class-solid/tests/experiment.spec.ts | 15 +++++---------- apps/class-solid/tests/share.spec.ts | 6 ++---- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/apps/class-solid/tests/experiment.spec.ts b/apps/class-solid/tests/experiment.spec.ts index f01728bb..e0e309d4 100644 --- a/apps/class-solid/tests/experiment.spec.ts +++ b/apps/class-solid/tests/experiment.spec.ts @@ -5,8 +5,7 @@ test("Duplicate experiment with a permutation", async ({ page }, testInfo) => { await page.goto("/"); // Create a new experiment - await page.getByTitle("Add experiment").click(); - await page.getByRole("menuitem", { name: "From scratch" }).click(); + await page.getByRole("button", { name: "Start from scratch" }).click(); await page.getByRole("button", { name: "Run" }).click(); // Add a permutation @@ -66,8 +65,7 @@ test("Swap permutation with default reference", async ({ page }) => { await page.goto("/"); // Create a new experiment - await page.getByTitle("Add experiment").click(); - await page.getByRole("menuitem", { name: "From scratch" }).click(); + await page.getByRole("button", { name: "Start from scratch" }).click(); await page.getByRole("button", { name: "Run" }).click(); // Add a permutation @@ -98,8 +96,7 @@ test("Swap permutation with custom reference", async ({ page }) => { await page.goto("/"); // Create a new experiment - await page.getByTitle("Add experiment").click(); - await page.getByRole("menuitem", { name: "From scratch" }).click(); + await page.getByRole("button", { name: "Start from scratch" }).click(); await page.getByRole("button", { name: "Initial State" }).click(); await page.getByLabel("ABL height").fill("400"); await page.getByLabel("Mixed-layer potential temperature").fill("265"); @@ -138,8 +135,7 @@ test("Promote permutation to a new experiment", async ({ page }) => { await page.goto("/"); // Create a new experiment - await page.getByTitle("Add experiment").click(); - await page.getByRole("menuitem", { name: "From scratch" }).click(); + await page.getByRole("button", { name: "Start from scratch" }).click(); await page.getByRole("button", { name: "Run" }).click(); // Add a permutation @@ -173,8 +169,7 @@ test("Duplicate permutation", async ({ page }) => { await page.goto("/"); // Create a new experiment - await page.getByTitle("Add experiment").click(); - await page.getByRole("menuitem", { name: "From scratch" }).click(); + await page.getByRole("button", { name: "Start from scratch" }).click(); await page.getByRole("button", { name: "Run" }).click(); // Add a permutation diff --git a/apps/class-solid/tests/share.spec.ts b/apps/class-solid/tests/share.spec.ts index f2f722df..eb11f88c 100644 --- a/apps/class-solid/tests/share.spec.ts +++ b/apps/class-solid/tests/share.spec.ts @@ -5,8 +5,7 @@ test("Create share link from an experiment", async ({ page }) => { await page.goto("/"); // Create a new experiment - await page.getByTitle("Add experiment").click(); - await page.getByRole("menuitem", { name: "From scratch" }).click(); + await page.getByRole("button", { name: "Start from scratch" }).click(); await page.getByRole("button", { name: "Initial State" }).click(); await page.getByLabel("ABL height").fill("800"); await page.getByRole("button", { name: "Run" }).click(); @@ -54,8 +53,7 @@ test("Given large app state, sharing is not possible", async ({ page }) => { await page.goto("/"); // Create a new experiment - await page.getByTitle("Add experiment").click(); - await page.getByRole("menuitem", { name: "From scratch" }).click(); + await page.getByRole("button", { name: "Start from scratch" }).click(); await page.getByRole("button", { name: "Run" }).click(); // Add permutation sweep await page.getByRole("button", { name: "S", exact: true }).click(); From 00180dd96947df3381a29db9624f224a973ade34 Mon Sep 17 00:00:00 2001 From: sverhoeven Date: Tue, 12 Nov 2024 11:00:27 +0100 Subject: [PATCH 20/21] Drop analyis from share link --- .../src/components/StartButtons.tsx | 4 ++-- apps/class-solid/src/lib/encode.ts | 24 +------------------ apps/class-solid/src/lib/store.ts | 1 - 3 files changed, 3 insertions(+), 26 deletions(-) diff --git a/apps/class-solid/src/components/StartButtons.tsx b/apps/class-solid/src/components/StartButtons.tsx index 8c339ddc..d91b1300 100644 --- a/apps/class-solid/src/components/StartButtons.tsx +++ b/apps/class-solid/src/components/StartButtons.tsx @@ -35,12 +35,12 @@ function ResumeSessionButton(props: { afterClick: () => void }) { fallback={ <>

        Resume from

        -

        previous session

        +

        saved session

        } >

        Restore

        -

        previous session

        +

        saved session

        diff --git a/apps/class-solid/src/lib/encode.ts b/apps/class-solid/src/lib/encode.ts index 8778a9e4..fb46f1ba 100644 --- a/apps/class-solid/src/lib/encode.ts +++ b/apps/class-solid/src/lib/encode.ts @@ -25,21 +25,7 @@ export function decodeAppState(encoded: string): [Experiment[], Analysis[]] { })), }), ); - const analyses: Analysis[] = parsed.analyses.map( - (ana: { - name: string; - id: string; - experiments: string[]; - type: string; - }) => ({ - name: ana.name, - id: ana.id, - experiments: experiments.filter((exp) => - ana.experiments.includes(exp.name), - ), - type: ana.type, - }), - ); + const analyses: Analysis[] = []; return [experiments, analyses]; } @@ -61,14 +47,6 @@ export function encodeAppState( ]), ), })), - analyses: unwrap(analyses).map((ana) => ({ - name: ana.name, - id: ana.id, - experiments: ana.experiments - ? ana.experiments.map((exp) => exp.name) - : [], - type: ana.type, - })), }; return encodeURI(JSON.stringify(minimizedState, undefined, 0)); } diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index 307ddb23..a88b1eb7 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -289,7 +289,6 @@ export async function loadStateFromString(rawState: string): Promise { const [loadedExperiments, loadedAnalyses] = decodeAppState(rawState); setExperiments(loadedExperiments); await Promise.all(loadedExperiments.map((_, i) => runExperiment(i))); - setAnalyses(loadedAnalyses); } const analysisNames = { From aac745a1c45af6f55f7be0eb077f0f9be2f158c6 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Tue, 12 Nov 2024 12:14:13 +0100 Subject: [PATCH 21/21] Move outputs back into experiments store Partially reverts 0dff547cfd12e19796186261e2d28fe084ccbd2d --- apps/class-solid/src/components/Analysis.tsx | 59 ++++++++------------ apps/class-solid/src/lib/download.ts | 21 +++---- apps/class-solid/src/lib/store.ts | 41 ++------------ 3 files changed, 34 insertions(+), 87 deletions(-) diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index 61887ac8..3b0b5269 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -1,12 +1,6 @@ import { For, Match, Show, Switch, createMemo, createUniqueId } from "solid-js"; import { getVerticalProfiles } from "~/lib/profiles"; -import { - type Analysis, - deleteAnalysis, - experiments, - outputForExperiment, - outputForPermutation, -} from "~/lib/store"; +import { type Analysis, deleteAnalysis, experiments } from "~/lib/store"; import LinePlot from "./LinePlot"; import { MdiCog, MdiContentCopy, MdiDelete, MdiDownload } from "./icons"; import { Button } from "./ui/button"; @@ -37,21 +31,22 @@ export function TimeSeriesPlot() { return experiments .filter((e) => e.running === false) // Skip running experiments .flatMap((e, i) => { - const experimentOutput = outputForExperiment(e); - const permutationRuns = e.permutations.map((perm, j) => { - const permOutput = outputForPermutation(experimentOutput, j); - return { - label: `${e.name}/${perm.name}`, - y: permOutput.h ?? [], - x: permOutput.t ?? [], - color: colors[(j + 1) % 10], - linestyle: linestyles[i % 5], - }; - }); + const experimentOutput = e.reference.output; + const permutationRuns = e.permutations + .filter((perm) => perm.output !== undefined) + .map((perm, j) => { + return { + label: `${e.name}/${perm.name}`, + y: perm.output?.h ?? [], + x: perm.output?.t ?? [], + color: colors[(j + 1) % 10], + linestyle: linestyles[i % 5], + }; + }); return [ { - y: experimentOutput?.reference.h ?? [], - x: experimentOutput?.reference.t ?? [], + y: experimentOutput?.h ?? [], + x: experimentOutput?.t ?? [], label: e.name, color: colors[0], linestyle: linestyles[i], @@ -77,16 +72,14 @@ export function VerticalProfilePlot() { return experiments .filter((e) => e.running === false) // Skip running experiments .flatMap((e, i) => { - const experimentOutput = outputForExperiment(e); const permutations = e.permutations.map((p, j) => { // TODO get additional config info from reference // permutations probably usually don't have gammaq/gammatetha set? - const permOutput = outputForPermutation(experimentOutput, j); return { color: colors[(j + 1) % 10], linestyle: linestyles[i % 5], label: `${e.name}/${p.name}`, - ...getVerticalProfiles(permOutput, p.config, variable, time), + ...getVerticalProfiles(p.output, p.config, variable, time), }; }); @@ -96,7 +89,7 @@ export function VerticalProfilePlot() { color: colors[0], linestyle: linestyles[i], ...getVerticalProfiles( - experimentOutput?.reference ?? { + e.reference.output ?? { t: [], h: [], theta: [], @@ -127,12 +120,8 @@ function FinalHeights() { {(experiment) => { const h = () => { - const experimentOutput = outputForExperiment(experiment); - return ( - experimentOutput?.reference.h[ - experimentOutput.reference.h.length - 1 - ] || 0 - ); + const experimentOutput = experiment.reference.output; + return experimentOutput?.h[experimentOutput?.h.length - 1] || 0; }; return ( @@ -140,14 +129,10 @@ function FinalHeights() { {experiment.name}: {h().toFixed()} m
      • - {(perm, permIndex) => { + {(perm) => { const h = () => { - const experimentOutput = outputForExperiment(experiment); - const permOutput = outputForPermutation( - experimentOutput, - permIndex(), - ); - return permOutput.h?.length + const permOutput = perm.output; + return permOutput?.h?.length ? permOutput.h[permOutput.h.length - 1] : 0; }; diff --git a/apps/class-solid/src/lib/download.ts b/apps/class-solid/src/lib/download.ts index 7bf36420..7fdc9cfd 100644 --- a/apps/class-solid/src/lib/download.ts +++ b/apps/class-solid/src/lib/download.ts @@ -1,7 +1,7 @@ import type { ClassOutput } from "@classmodel/class/runner"; import type { ExperimentConfigSchema } from "@classmodel/class/validate"; import { BlobReader, BlobWriter, ZipWriter } from "@zip.js/zip.js"; -import { type Experiment, outputForExperiment } from "./store"; +import type { Experiment } from "./store"; export function toConfig(experiment: Experiment): ExperimentConfigSchema { return { @@ -40,29 +40,22 @@ export async function createArchive(experiment: Experiment) { type: "application/json", }); await zipWriter.add("config.json", new BlobReader(configBlob)); - const output = outputForExperiment(experiment); - if (!output) { - return; - } - if (output.reference) { - const csvBlob = new Blob([outputToCsv(output.reference)], { + if (experiment.reference.output) { + const csvBlob = new Blob([outputToCsv(experiment.reference.output)], { type: "text/csv", }); await zipWriter.add(`${experiment.name}.csv`, new BlobReader(csvBlob)); } - let permIndex = 0; - for (const perm of experiment.permutations) { - const name = perm.name; - const permutationOutput = output.permutations[permIndex]; - if (output && name) { + for (const permutation of experiment.permutations) { + const permutationOutput = permutation.output; + if (permutationOutput) { const csvBlob = new Blob([outputToCsv(permutationOutput)], { type: "text/csv", }); - await zipWriter.add(`${name}.csv`, new BlobReader(csvBlob)); + await zipWriter.add(`${permutation.name}.csv`, new BlobReader(csvBlob)); } - permIndex++; } await zipWriter.close(); return await zipFileWriter.getData(); diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index a88b1eb7..a1bb6210 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -13,6 +13,7 @@ import { runClass } from "./runner"; export interface Permutation { name: string; config: C; + output?: ClassOutput | undefined; // TODO Could use per run state to show progress of run of reference and each permutation // running: boolean; } @@ -23,6 +24,7 @@ export interface Experiment { reference: { // TODO change reference.config to config, as there are no other keys in reference config: PartialConfig; + output?: ClassOutput | undefined; }; permutations: Permutation[]; running: number | false; @@ -31,34 +33,6 @@ export interface Experiment { export const [experiments, setExperiments] = createStore([]); export const [analyses, setAnalyses] = createStore([]); -interface ExperimentOutput { - reference: ClassOutput; - permutations: ClassOutput[]; -} - -// Outputs must store outside store as they are too big to wrap in proxy -export const outputs: ExperimentOutput[] = []; - -export function outputForExperiment( - index: number | Experiment, -): ExperimentOutput | undefined { - if (typeof index === "object") { - const i = experiments.indexOf(index); - return outputs[i]; - } - return outputs[index]; -} - -export function outputForPermutation( - experiment: ExperimentOutput | undefined, - permutationIndex: number, -) { - if (!experiment || experiment.permutations.length <= permutationIndex) { - return { t: [], h: [], theta: [], dtheta: [] }; - } - return experiment.permutations[permutationIndex]; -} - // biome-ignore lint/suspicious/noExplicitAny: recursion is hard to type function mergeConfigurations(reference: any, permutation: any) { const merged = { ...reference }; @@ -81,7 +55,7 @@ function mergeConfigurations(reference: any, permutation: any) { export async function runExperiment(id: number) { const exp = experiments[id]; - setExperiments(id, "running", 0); + setExperiments(id, "running", 0.0001); // TODO make lazy, if config does not change do not rerun // or make more specific like runReference and runPermutation @@ -90,10 +64,7 @@ export async function runExperiment(id: number) { const referenceConfig = unwrap(exp.reference.config); const newOutput = await runClass(referenceConfig); - outputs[id] = { - reference: newOutput, - permutations: [], - }; + setExperiments(id, "reference", "output", newOutput); // Run permutations let permCounter = 0; @@ -101,7 +72,7 @@ export async function runExperiment(id: number) { const permConfig = unwrap(proxiedPerm.config); const combinedConfig = mergeConfigurations(referenceConfig, permConfig); const newOutput = await runClass(combinedConfig); - outputs[id].permutations[permCounter] = newOutput; + setExperiments(id, "permutations", permCounter, "output", newOutput); permCounter++; } @@ -172,7 +143,6 @@ export function duplicateExperiment(id: number) { export function deleteExperiment(index: number) { setExperiments(experiments.filter((_, i) => i !== index)); - outputs.splice(index, 1); } export async function modifyExperiment( @@ -226,7 +196,6 @@ export async function deletePermutationFromExperiment( setExperiments(experimentIndex, "permutations", (perms) => perms.filter((_, i) => i !== permutationIndex), ); - outputs[experimentIndex].permutations.splice(permutationIndex, 1); } export function findPermutation(exp: Experiment, permutationName: string) {