diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index cf43f18..3b0b526 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 { For, Match, Show, 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,54 +23,30 @@ 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 */ 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 permutationRuns = e.permutations.map((perm, j) => { - return { - label: `${e.name}/${perm.name}`, - y: perm.output === undefined ? [] : perm.output.h, - x: perm.output === undefined ? [] : perm.output.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: e.reference.output === undefined ? [] : e.reference.output.h, - x: e.reference.output === undefined ? [] : e.reference.output.t, + y: experimentOutput?.h ?? [], + x: experimentOutput?.t ?? [], label: e.name, color: colors[0], linestyle: linestyles[i], @@ -94,33 +69,40 @@ 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 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 [ - { - 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( + e.reference.output ?? { + 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 = experiment.reference.output; + return experimentOutput?.h[experimentOutput?.h.length - 1] || 0; + }; return ( - <> +
  • {experiment.name}: {h().toFixed()} m
  • {(perm) => { - const h = () => perm.output?.h[perm.output.h.length - 1] || 0; + const h = () => { + const permOutput = perm.output; + return permOutput?.h?.length + ? permOutput.h[permOutput.h.length - 1] + : 0; + }; return (
  • {experiment.name}/{perm.name}: {h().toFixed()} m @@ -156,7 +143,7 @@ function FinalHeights() { ); }} - + ); }} @@ -196,7 +183,7 @@ export function AnalysisCard(analysis: Analysis) { Unknown analysis type

    }> - + diff --git a/apps/class-solid/src/components/Experiment.tsx b/apps/class-solid/src/components/Experiment.tsx index 020505e..11333b3 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, @@ -53,7 +52,7 @@ export function AddExperimentDialog(props: { description: "", reference: { config: {} }, permutations: [], - running: false, + running: false as const, }; }; @@ -131,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}% ... +
    ); } @@ -181,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)); @@ -243,7 +248,10 @@ export function ExperimentCard(props: { /> - }> + } + > - diff --git a/apps/class-solid/src/components/Nav.tsx b/apps/class-solid/src/components/Nav.tsx index ca8cc53..d521655 100644 --- a/apps/class-solid/src/components/Nav.tsx +++ b/apps/class-solid/src/components/Nav.tsx @@ -1,4 +1,7 @@ import { useLocation } from "@solidjs/router"; +import { saveToLocalStorage } from "~/lib/state"; +import { ShareButton } from "./ShareButton"; +import { MdiContentSave } from "./icons"; export default function Nav() { const location = useLocation(); @@ -8,13 +11,27 @@ export default function Nav() { : "border-transparent hover:border-sky-600"; return ( ); diff --git a/apps/class-solid/src/components/PermutationSweepButton.tsx b/apps/class-solid/src/components/PermutationSweepButton.tsx new file mode 100644 index 0000000..d83f0f0 --- /dev/null +++ b/apps/class-solid/src/components/PermutationSweepButton.tsx @@ -0,0 +1,125 @@ +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); + setExperiments(props.experimentIndex, "permutations", perms); + runExperiment(props.experimentIndex); + } + const [open, setOpen] = createSignal(false); + return ( + + } + > + S + + + + + Perform a sweep over parameters + + +
    +

    + 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 19dfcd5..e249f13 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/apps/class-solid/src/components/ShareButton.tsx b/apps/class-solid/src/components/ShareButton.tsx index 45e0109..35f2706 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, @@ -10,7 +10,6 @@ import { import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, DialogTrigger, @@ -18,7 +17,9 @@ import { import { TextField, TextFieldInput } from "./ui/text-field"; import { showToast } from "./ui/toast"; -export function ShareButton(props: { experiment: Accessor }) { +const MAX_SHAREABLE_LINK_LENGTH = 32_000; + +export function ShareButton() { const [open, setOpen] = createSignal(false); const [isCopied, setIsCopied] = createSignal(false); let inputRef: HTMLInputElement | undefined; @@ -26,8 +27,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,50 +59,68 @@ export function ShareButton(props: { experiment: Accessor }) { return ( - }> - + + Share Share link - - Anyone with{" "} - - this link - {" "} - will be able to view the current experiment in their web browser. - - -
        - - - - -
        +
        + 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/components/StartButtons.tsx b/apps/class-solid/src/components/StartButtons.tsx new file mode 100644 index 0000000..d91b130 --- /dev/null +++ b/apps/class-solid/src/components/StartButtons.tsx @@ -0,0 +1,292 @@ +import { Show, createSignal } from "solid-js"; +import { hasLocalStorage, loadFromLocalStorage } from "~/lib/state"; +import { experiments, uploadExperiment } from "~/lib/store"; +import { + MdiBackupRestore, + MdiBeakerPlus, + MdiFileDocumentOutline, + MdiPlusBox, + MdiUpload, +} from "./icons"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./ui/dialog"; +import { showToast } from "./ui/toast"; + +function ResumeSessionButton(props: { afterClick: () => void }) { + return ( + + + + ); +} + +function StartFromSratchButton(props: { + onClick: () => void; + afterClick: () => void; +}) { + return ( + + ); +} + +function StartFromUploadButton(props: { + afterClick: () => void; +}) { + 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(() => { + props.afterClick(); + showToast({ + title: "Experiment uploaded", + variant: "success", + duration: 1000, + }); + }) + .catch((error) => { + props.afterClick(); + 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(props: { + afterClick: () => void; +}) { + const [open, setOpen] = createSignal(false); + return ( + <> + { + if (!v) { + props.afterClick(); + } + setOpen(v); + }} + /> + + + ); +} + +export function StartButtons(props: { + onFromSratchClick: () => void; + afterClick: () => 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 ( + + + } + variant="ghost" + class="align-middle" + > + + + + + Add experiment + +
        + setOpen(false)} + onFromSratchClick={props.onFromSratchClick} + /> +
        +
        +
        +
        + ); +} diff --git a/apps/class-solid/src/components/UploadExperiment.tsx b/apps/class-solid/src/components/UploadExperiment.tsx deleted file mode 100644 index 149a2df..0000000 --- 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 16c5a71..02f3499 100644 --- a/apps/class-solid/src/components/icons.tsx +++ b/apps/class-solid/src/components/icons.tsx @@ -232,3 +232,90 @@ export function MdiClipboardCheck(props: JSX.IntrinsicElements["svg"]) { ); } + +export function MdiContentSave(props: JSX.IntrinsicElements["svg"]) { + return ( + + + Save + + ); +} + +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/download.ts b/apps/class-solid/src/lib/download.ts index bf0f189..7fdc9cf 100644 --- a/apps/class-solid/src/lib/download.ts +++ b/apps/class-solid/src/lib/download.ts @@ -49,9 +49,9 @@ export async function createArchive(experiment: Experiment) { } for (const permutation of experiment.permutations) { - const output = permutation.output; - if (output) { - const csvBlob = new Blob([outputToCsv(output)], { + const permutationOutput = permutation.output; + if (permutationOutput) { + const csvBlob = new Blob([outputToCsv(permutationOutput)], { type: "text/csv", }); await zipWriter.add(`${permutation.name}.csv`, new BlobReader(csvBlob)); diff --git a/apps/class-solid/src/lib/encode.ts b/apps/class-solid/src/lib/encode.ts index 7f70af7..fb46f1b 100644 --- a/apps/class-solid/src/lib/encode.ts +++ b/apps/class-solid/src/lib/encode.ts @@ -1,47 +1,52 @@ -import { - type ExperimentConfigSchema, - type PartialConfig, - parseExperimentConfig, -} from "@classmodel/class/validate"; -import type { Experiment } from "./store"; +import { parse, pruneDefaults } from "@classmodel/class/validate"; +import { unwrap } from "solid-js/store"; +import type { Analysis, Experiment } from "./store"; -/** - * URL safe representation of an experiment - * - * @param experiment - * @returns - */ -export function encodeExperiment(experiment: Experiment) { - const minimizedExperiment = { - n: experiment.name, - d: experiment.description, - r: experiment.reference.config, - p: experiment.permutations.map((perm) => ({ - n: perm.name, - c: perm.config, - })), - }; - return encodeURIComponent(JSON.stringify(minimizedExperiment, undefined, 0)); +export function decodeAppState(encoded: string): [Experiment[], Analysis[]] { + const decoded = decodeURI(encoded); + const parsed = JSON.parse(decoded); + // TODO use ajv to validate experiment, permutation, and analysis + // now only config is validated + const experiments: Experiment[] = parsed.experiments.map( + (exp: { + name: string; + description: string; + reference: unknown; + permutations: Record; + }) => ({ + name: exp.name, + description: exp.description, + reference: { + config: parse(exp.reference), + }, + permutations: Object.entries(exp.permutations).map(([name, config]) => ({ + name, + config: parse(config), + })), + }), + ); + const analyses: Analysis[] = []; + return [experiments, analyses]; } -/** - * Decode an experiment config from a URL safe string - * - * @param encoded - * @returns - * - */ -export function decodeExperiment(encoded: string): ExperimentConfigSchema { - const decoded = decodeURIComponent(encoded); - const parsed = JSON.parse(decoded); - const rawExperiment = { - name: parsed.n, - description: parsed.d, - reference: parsed.r, - permutations: parsed.p.map((perm: { n: string; c: PartialConfig }) => ({ - name: perm.n, - config: perm.c, +export function encodeAppState( + experiments: Experiment[], + analyses: Analysis[], +) { + const rawExperiments = unwrap(experiments); + const minimizedState = { + experiments: rawExperiments.map((exp) => ({ + name: exp.name, + description: exp.description, + reference: pruneDefaults(exp.reference.config), + permutations: Object.fromEntries( + exp.permutations.map((perm) => [ + perm.name, + // TODO if reference.var and prem.var are the same also remove prem.var + pruneDefaults(perm.config), + ]), + ), })), }; - return parseExperimentConfig(rawExperiment); + return encodeURI(JSON.stringify(minimizedState, undefined, 0)); } diff --git a/apps/class-solid/src/lib/runner.ts b/apps/class-solid/src/lib/runner.ts index ec30ae9..dec59e6 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"); } diff --git a/apps/class-solid/src/lib/state.ts b/apps/class-solid/src/lib/state.ts new file mode 100644 index 0000000..e6e974d --- /dev/null +++ b/apps/class-solid/src/lib/state.ts @@ -0,0 +1,79 @@ +import { useLocation, useNavigate } from "@solidjs/router"; +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(); + const rawState = location.hash.substring(1); + if (!rawState) { + return; + } + try { + // TODO show loading spinner + await loadStateFromString(rawState); + showToast({ + title: "State loaded from URL", + variant: "success", + duration: 1000, + }); + } catch (error) { + console.error(error); + showToast({ + title: "Failed to load state from URL", + description: `${error}`, + variant: "error", + }); + } + // Remove hash after loading experiment from URL, + // as after editing the experiment the hash out of sync + navigate("/"); +} + +export function saveToLocalStorage() { + const appState = encodeAppState(experiments, analyses); + if ( + appState === "%7B%22experiments%22%3A%5B%5D%2C%22analyses%22%3A%5B%5D%7D" + ) { + 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/lib/store.ts b/apps/class-solid/src/lib/store.ts index e5959be..a1bb621 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -6,7 +6,8 @@ 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"; export interface Permutation { @@ -21,11 +22,12 @@ 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([]); @@ -51,51 +53,37 @@ 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.0001); // TODO make lazy, if config does not change do not rerun // or make more specific like runReference and runPermutation // Run reference - const newOutput = await runClass(exp.reference.config); + const referenceConfig = unwrap(exp.reference.config); + const newOutput = await runClass(referenceConfig); - setExperiments( - id, - produce((e) => { - e.reference.output = newOutput; - }), - ); + setExperiments(id, "reference", "output", newOutput); // 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); const newOutput = await runClass(combinedConfig); - - setExperiments( - id, - produce((e) => { - e.permutations[key].output = newOutput; - }), - ); + setExperiments(id, "permutations", permCounter, "output", newOutput); + permCounter++; } - setExperiments( - id, - produce((e) => { - e.running = false; - }), - ); + setExperiments(id, "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) { @@ -143,20 +131,13 @@ 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); } @@ -272,3 +253,39 @@ export function swapPermutationAndReferenceConfiguration( // TODO should names also be swapped? runExperiment(experimentIndex); } + +export async function loadStateFromString(rawState: string): Promise { + const [loadedExperiments, loadedAnalyses] = decodeAppState(rawState); + setExperiments(loadedExperiments); + await Promise.all(loadedExperiments.map((_, i) => runExperiment(i))); +} + +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/about.tsx b/apps/class-solid/src/routes/about.tsx index 1a29ce8..97aef42 100644 --- a/apps/class-solid/src/routes/about.tsx +++ b/apps/class-solid/src/routes/about.tsx @@ -1,10 +1,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 @@ -21,13 +20,6 @@ export default function About() { classmodel.github.io {" "}

        -

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

        ); } diff --git a/apps/class-solid/src/routes/index.tsx b/apps/class-solid/src/routes/index.tsx index ee58b74..24024dd 100644 --- a/apps/class-solid/src/routes/index.tsx +++ b/apps/class-solid/src/routes/index.tsx @@ -1,9 +1,8 @@ -import { useLocation, useNavigate } from "@solidjs/router"; 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 { StartButtons, StartMenu } from "~/components/StartButtons"; import { MdiPlusBox } from "~/components/icons"; import { DropdownMenu, @@ -14,66 +13,22 @@ import { DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; import { Flex } from "~/components/ui/flex"; -import { Toaster, showToast } from "~/components/ui/toast"; -import { decodeExperiment } from "~/lib/encode"; +import { Toaster } from "~/components/ui/toast"; +import { onPageLoad } from "~/lib/state"; -import { experiments, uploadExperiment } from "~/lib/store"; +import { addAnalysis, experiments } from "~/lib/store"; import { analyses } from "~/lib/store"; export default function Home() { const [openAddDialog, setOpenAddDialog] = createSignal(false); - onMount(async () => { - const location = useLocation(); - const navigate = useNavigate(); - const rawExperiment = location.hash.substring(1); - if (!rawExperiment) return; - try { - const experimentConfig = decodeExperiment(rawExperiment); - await uploadExperiment(experimentConfig); - showToast({ - title: "Experiment loaded from URL", - variant: "success", - duration: 1000, - }); - } catch (error) { - console.error(error); - showToast({ - title: "Failed to load experiment from URL", - description: `${error}`, - variant: "error", - }); - } - // Remove hash after loading experiment from URL, - // as after editing the experiment the hash out of sync - navigate("/"); - }); + onMount(onPageLoad); return (

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

        + + setOpenAddDialog(true)} + afterClick={() => {}} + /> + {(experiment, index) => ( @@ -99,7 +60,7 @@ export default function Home() { Add analysis - addAnalysis()}> + addAnalysis("finalheight")}> Final height addAnalysis("timeseries")}> 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 0000000..be9450c --- /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/experiment.spec.ts b/apps/class-solid/tests/experiment.spec.ts index dd0745a..e0e309d 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 @@ -20,10 +19,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 +53,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", }); }); @@ -67,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 @@ -99,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"); @@ -139,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 @@ -174,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/index.spec.ts b/apps/class-solid/tests/index.spec.ts index d22f736..363523b 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 9042ed4..eb11f88 100644 --- a/apps/class-solid/tests/share.spec.ts +++ b/apps/class-solid/tests/share.spec.ts @@ -5,17 +5,13 @@ 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(); // 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(); @@ -36,9 +32,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", }); @@ -50,3 +44,32 @@ 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 }) => { + test.skip( + true, + "Plotting is too slow, to render 13 experiments with 24 permuations each", + ); + await page.goto("/"); + + // Create a new experiment + 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(); + 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(); + 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.", + ); +}); diff --git a/packages/class/package.json b/packages/class/package.json index 7013b2e..0c63bc6 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 0000000..e0fd06f --- /dev/null +++ b/packages/class/src/sweep.test.ts @@ -0,0 +1,166 @@ +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); + }); + + 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 new file mode 100644 index 0000000..818fe79 --- /dev/null +++ b/packages/class/src/sweep.ts @@ -0,0 +1,55 @@ +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) => { + // 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)) { + 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); +}