diff --git a/apps/class-solid/README.md b/apps/class-solid/README.md index d0d7002c..ae888fad 100644 --- a/apps/class-solid/README.md +++ b/apps/class-solid/README.md @@ -48,3 +48,16 @@ pnpm test -- --ui --headed This allows you to trigger tests from the [playwright ui](https://playwright.dev/docs/test-ui-mode) and enable [watch mode](https://playwright.dev/docs/test-ui-mode#watch-mode). ## This project was created with the [Solid CLI](https://solid-cli.netlify.app) + +## Presets + +An experiment can get started from a preset. + +The presets are stored in the `src/lib/presets/` directory. +The format is JSON with content adhering to the [JSON schema](https://github.com/classmodel/class-web/blob/main/packages/class/src/config.json). + +The `src/lib/presets.ts` is used as an index of presets. +If you add a preset the `src/lib/presets.ts` file needs to be updated. + +An experiment from a preset can be opened from a url like `?preset=`. +For example to load use `http://localhost:3000/?preset=Death%20Valley`. diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index 51690bc7..d7da2178 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -1,6 +1,6 @@ import { BmiClass } from "@classmodel/class/bmi"; +import type { Config } from "@classmodel/class/config"; import type { ClassOutput } from "@classmodel/class/runner"; -import type { PartialConfig } from "@classmodel/class/validate"; import { type Accessor, For, @@ -59,30 +59,33 @@ interface FlatExperiment { label: string; color: string; linestyle: string; - config: PartialConfig; + config: Config; output?: ClassOutput; } // Create a derived store for looping over all outputs: const flatExperiments: () => FlatExperiment[] = createMemo(() => { return experiments - .filter((e) => e.running === false) // skip running experiments + .filter((e) => e.output.running === false) // skip running experiments .flatMap((e, i) => { const reference = { color: colors[0], linestyle: linestyles[i % 5], - label: e.name, - config: e.reference.config, - output: e.reference.output, + label: e.config.reference.name, + config: e.config.reference, + output: e.output.reference, }; - const permutations = e.permutations.map((p, j) => ({ - label: `${e.name}/${p.name}`, - color: colors[(j + 1) % 10], - linestyle: linestyles[i % 5], - config: p.config, - output: p.output, - })); + const permutations = e.config.permutations.map((config, j) => { + const output = e.output.permutations[j]; + return { + label: `${e.config.reference.name}/${config.name}`, + color: colors[(j + 1) % 10], + linestyle: linestyles[i % 5], + config, + output, + }; + }); return [reference, ...permutations]; }); }); diff --git a/apps/class-solid/src/components/Experiment.tsx b/apps/class-solid/src/components/Experiment.tsx index 11333b37..0e3e5ada 100644 --- a/apps/class-solid/src/components/Experiment.tsx +++ b/apps/class-solid/src/components/Experiment.tsx @@ -6,8 +6,10 @@ import { createUniqueId, onCleanup, } from "solid-js"; +import { unwrap } from "solid-js/store"; import { Button, buttonVariants } from "~/components/ui/button"; import { createArchive, toConfigBlob } from "~/lib/download"; +import { findPresetByName } from "~/lib/presets"; import { type Experiment, addExperiment, @@ -46,15 +48,17 @@ export function AddExperimentDialog(props: { onClose: () => void; open: boolean; }) { - const initialExperiment = () => { + const defaultPreset = findPresetByName(); + const initialExperimentConfig = createMemo(() => { return { - name: `My experiment ${props.nextIndex}`, - description: "", - reference: { config: {} }, + preset: "Default", + reference: { + ...structuredClone(defaultPreset.config), + name: `My experiment ${props.nextIndex}`, + }, permutations: [], - running: false as const, }; - }; + }); function setOpen(value: boolean) { if (!value) { @@ -66,19 +70,19 @@ export function AddExperimentDialog(props: { - Experiment + + Experiment + + Preset: {defaultPreset.config.name} + + { props.onClose(); - const { title, description, ...strippedConfig } = newConfig; - addExperiment( - strippedConfig, - title ?? initialExperiment().name, - description ?? initialExperiment().description, - ); + addExperiment(newConfig); }} /> @@ -104,20 +108,19 @@ export function ExperimentSettingsDialog(props: { - Experiment + + Experiment + + Preset: {props.experiment.config.preset} + + { setOpen(false); - const { title, description, ...strippedConfig } = newConfig; - modifyExperiment( - props.experimentIndex, - strippedConfig, - title ?? props.experiment.name, - description ?? props.experiment.description, - ); + modifyExperiment(props.experimentIndex, newConfig); }} /> @@ -164,14 +167,14 @@ function RunningIndicator(props: { progress: number | false }) { function DownloadExperimentConfiguration(props: { experiment: Experiment }) { const downloadUrl = createMemo(() => { - return URL.createObjectURL(toConfigBlob(props.experiment)); + return URL.createObjectURL(toConfigBlob(unwrap(props.experiment.config))); }); onCleanup(() => { URL.revokeObjectURL(downloadUrl()); }); - const filename = `class-${props.experiment.name}.json`; + const filename = `class-${props.experiment.config.reference.name}.json`; return ( Configuration @@ -182,7 +185,7 @@ function DownloadExperimentConfiguration(props: { experiment: Experiment }) { function DownloadExperimentArchive(props: { experiment: Experiment }) { const [url, setUrl] = createSignal(""); createEffect(async () => { - const archive = await createArchive(props.experiment); + const archive = await createArchive(unwrap(props.experiment)); if (!archive) { return; } @@ -191,7 +194,7 @@ function DownloadExperimentArchive(props: { experiment: Experiment }) { onCleanup(() => URL.revokeObjectURL(objectUrl)); }); - const filename = `class-${props.experiment.name}.zip`; + const filename = `class-${props.experiment.config.reference.name}.zip`; return ( Configuration and output @@ -236,9 +239,9 @@ export function ExperimentCard(props: { aria-describedby={descriptionId} > - {experiment().name} + {experiment().config.reference.name} - {experiment().description} + {experiment().config.reference.description} @@ -247,10 +250,10 @@ export function ExperimentCard(props: { experimentIndex={experimentIndex()} /> - + } + when={!experiment().output.running} + fallback={} > void; + experiment: ExperimentConfig; + onSubmit: (c: Config) => void; }) { - const initialValues = createMemo(() => { - return { - title: experiment.name, - description: experiment.description, - ...pruneDefaults(experiment.reference.config), - }; - }); - const [_, { Form, Field }] = createForm({ + const preset = createMemo(() => findPresetByName(experiment.preset)); + + const initialValues = createMemo(() => + pruneConfig(unwrap(experiment.reference), unwrap(preset().config)), + ); + const [_, { Form, Field }] = createForm({ initialValues: initialValues(), - validate: ajvForm(validate), + validate: ajvForm(preset().validate), }); - const handleSubmit: SubmitHandler = (values, event) => { - // Use validate to coerce strings to numbers - validate(values); + const handleSubmit: SubmitHandler = (values, event) => { + // Use ajv to coerce strings to numbers and fill in defaults + preset().validate(values); onSubmit(values); }; @@ -46,8 +42,8 @@ export function ExperimentConfigForm({ >
diff --git a/apps/class-solid/src/components/NamedConfig.tsx b/apps/class-solid/src/components/NamedConfig.tsx deleted file mode 100644 index 0dd89d0a..00000000 --- a/apps/class-solid/src/components/NamedConfig.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { jsonSchemaOfConfig } from "@classmodel/class/config"; -import { type PartialConfig, ajv } from "@classmodel/class/validate"; -import type { JSONSchemaType } from "ajv"; - -type NamedAndDescription = { - title: string; - description: string; -}; -export type NamedConfig = NamedAndDescription & PartialConfig; - -export const jsonSchemaOfNamedConfig = { - ...jsonSchemaOfConfig, - properties: { - title: { type: "string", title: "Title", minLength: 1 }, - description: { type: "string", title: "Description" }, - ...jsonSchemaOfConfig.properties, - }, - required: [...jsonSchemaOfConfig.required, "title"], -} as JSONSchemaType; - -export const validate = ajv.compile(jsonSchemaOfNamedConfig); diff --git a/apps/class-solid/src/components/PermutationSweepButton.tsx b/apps/class-solid/src/components/PermutationSweepButton.tsx index d83f0f0a..e5a40848 100644 --- a/apps/class-solid/src/components/PermutationSweepButton.tsx +++ b/apps/class-solid/src/components/PermutationSweepButton.tsx @@ -1,18 +1,13 @@ -import { type Sweep, performSweep } from "@classmodel/class/sweep"; +import type { Config } from "@classmodel/class/config"; import { type PartialConfig, - overwriteDefaultsInJsonSchema, -} from "@classmodel/class/validate"; -import { For, createMemo, createSignal } from "solid-js"; + mergeConfigurations, +} from "@classmodel/class/config_utils"; +import { type Sweep, performSweep } from "@classmodel/class/sweep"; +import { For, 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 { type Experiment, runExperiment, setExperiments } from "~/lib/store"; import { Dialog, DialogContent, @@ -35,28 +30,25 @@ function nameForPermutation(config: PartialConfig): string { return chunks.join(","); } -function config2permutation(config: PartialConfig): Permutation { +function config2permutation(reference: Config, config: PartialConfig): Config { return { - config, + ...mergeConfigurations(reference, config), name: nameForPermutation(config), + description: "", }; } -function configs2Permutations(configs: PartialConfig[]): Permutation[] { - return configs.map(config2permutation); +function configs2Permutations( + reference: Config, + configs: PartialConfig[], +): Config[] { + return configs.map((c) => config2permutation(reference, c)); } export function PermutationSweepButton(props: { experiment: Experiment; experimentIndex: number; }) { - const jsonSchemaOfPermutation = createMemo(() => { - return overwriteDefaultsInJsonSchema( - jsonSchemaOfNamedConfig, - unwrap(props.experiment.reference.config), - ); - }); - const sweeps: Sweep[] = [ { section: "initialState", @@ -76,9 +68,12 @@ export function PermutationSweepButton(props: { function addSweep() { const configs = performSweep(sweeps); - const perms = configs2Permutations(configs); + const perms = configs2Permutations( + unwrap(props.experiment.config.reference), + configs, + ); setOpen(false); - setExperiments(props.experimentIndex, "permutations", perms); + setExperiments(props.experimentIndex, "config", "permutations", perms); runExperiment(props.experimentIndex); } const [open, setOpen] = createSignal(false); diff --git a/apps/class-solid/src/components/PermutationsList.tsx b/apps/class-solid/src/components/PermutationsList.tsx index 1ef8047a..19895d20 100644 --- a/apps/class-solid/src/components/PermutationsList.tsx +++ b/apps/class-solid/src/components/PermutationsList.tsx @@ -1,25 +1,22 @@ +import type { Config } from "@classmodel/class/config"; import { - type PartialConfig, overwriteDefaultsInJsonSchema, - pruneDefaults, -} from "@classmodel/class/validate"; + pruneConfig, +} from "@classmodel/class/config_utils"; +import { ajv } from "@classmodel/class/validate"; import { type SubmitHandler, createForm } from "@modular-forms/solid"; import { For, createMemo, createSignal, createUniqueId } from "solid-js"; +import { unwrap } from "solid-js/store"; import { Button } from "~/components/ui/button"; +import { findPresetByName } from "~/lib/presets"; import { type Experiment, - type Permutation, deletePermutationFromExperiment, duplicatePermutation, promotePermutationToExperiment, setPermutationConfigInExperiment, swapPermutationAndReferenceConfiguration, } from "~/lib/store"; -import { - type NamedConfig, - jsonSchemaOfNamedConfig, - validate, -} from "./NamedConfig"; import { ObjectField } from "./ObjectField"; import { PermutationSweepButton } from "./PermutationSweepButton"; import { ajvForm } from "./ajvForm"; @@ -49,28 +46,28 @@ import { function PermutationConfigForm(props: { id: string; - onSubmit: (config: NamedConfig) => void; - permutationName?: string; - reference: PartialConfig; - config: PartialConfig; + onSubmit: (config: Config) => void; + reference: Config; + config: Config; // Config of the permutation + preset: string; }) { const jsonSchemaOfPermutation = createMemo(() => { - return overwriteDefaultsInJsonSchema( - jsonSchemaOfNamedConfig, - props.reference, - ); + const jsonSchemaOfPreset = findPresetByName(props.preset).schema; + return overwriteDefaultsInJsonSchema(jsonSchemaOfPreset, props.reference); }); - const [_, { Form, Field }] = createForm({ - initialValues: { - title: props.permutationName ?? "", - ...pruneDefaults(props.config), - }, - validate: ajvForm(validate), + + const initialValues = createMemo(() => + pruneConfig(unwrap(props.config), unwrap(props.reference)), + ); + + const [_, { Form, Field }] = createForm({ + initialValues: initialValues(), + validate: ajvForm(ajv.compile(jsonSchemaOfPermutation())), }); - const handleSubmit: SubmitHandler = (values: NamedConfig) => { - // Use validate to coerce strings to numbers - validate(values); + const handleSubmit: SubmitHandler = (values: Config) => { + // Use ajv to coerce strings to numbers and fill in defaults + ajv.compile(jsonSchemaOfPermutation())(values); props.onSubmit(values); }; @@ -84,7 +81,7 @@ function PermutationConfigForm(props: {
@@ -97,7 +94,14 @@ function AddPermutationButton(props: { experimentIndex: number; }) { const [open, setOpen] = createSignal(false); - const permutationName = () => `${props.experiment.permutations.length + 1}`; + + const initialPermutationConfig = createMemo(() => { + const config = structuredClone(unwrap(props.experiment.config.reference)); + config.name = `${props.experiment.config.permutations.length + 1}`; + config.description = ""; + return config; + }); + return ( Permutation on reference configuration of experiment{" "} - {props.experiment.name} + {props.experiment.config.reference.name} { - const { title, description, ...strippedConfig } = config; - setPermutationConfigInExperiment( - props.experimentIndex, - -1, - strippedConfig, - title ?? permutationName(), - ); + setPermutationConfigInExperiment(props.experimentIndex, -1, config); setOpen(false); }} /> @@ -147,8 +145,7 @@ function EditPermutationButton(props: { permutationIndex: number; }) { const [open, setOpen] = createSignal(false); - const permutationName = - props.experiment.permutations[props.permutationIndex].name; + return ( Permutation on reference configuration of experiment{" "} - {props.experiment.name} + {props.experiment.config.reference.name} { - const { title, description, ...strippedConfig } = config; setPermutationConfigInExperiment( props.experimentIndex, props.permutationIndex, - strippedConfig, - title ?? permutationName, + config, ); setOpen(false); }} @@ -192,16 +187,31 @@ function EditPermutationButton(props: { } function PermutationDifferenceButton(props: { - reference: PartialConfig; - permutation: PartialConfig; + reference: Config; + permutation: Config; }) { const [open, setOpen] = createSignal(false); - const prunedReference = createMemo(() => - JSON.stringify(pruneDefaults(props.reference), null, 2), - ); - const prunedPermutation = createMemo(() => - JSON.stringify(pruneDefaults(props.permutation), null, 2), - ); + + const prunedReference = createMemo(() => { + if (!open()) { + return ""; // Don't compute anything if the dialog is closed + } + const { name, description, ...pruned } = pruneConfig( + unwrap(props.reference), + unwrap(props.permutation), + ); + return JSON.stringify(pruned, null, 2); + }); + const prunedPermutation = createMemo(() => { + if (!open()) { + return ""; + } + const { name, description, ...pruned } = pruneConfig( + unwrap(props.permutation), + unwrap(props.reference), + ); + return JSON.stringify(pruned, null, 2); + }); return ( {props.perm.name}
    - + {(perm, permutationIndex) => (
  • void }) { @@ -47,7 +50,7 @@ function ResumeSessionButton(props: { afterClick: () => void }) { ); } -function StartFromSratchButton(props: { +function StartFromScratchButton(props: { onClick: () => void; afterClick: () => void; }) { @@ -154,7 +157,43 @@ function PresetPicker(props: { Pick a preset -

    Presets are not implemented yet

    + + + {(preset) => ( + + )} + +
); @@ -195,7 +234,7 @@ export function StartButtons(props: { }) { return ( <> - diff --git a/apps/class-solid/src/lib/download.ts b/apps/class-solid/src/lib/download.ts index 7fdc9cfd..aa58c306 100644 --- a/apps/class-solid/src/lib/download.ts +++ b/apps/class-solid/src/lib/download.ts @@ -1,24 +1,11 @@ import type { ClassOutput } from "@classmodel/class/runner"; -import type { ExperimentConfigSchema } from "@classmodel/class/validate"; import { BlobReader, BlobWriter, ZipWriter } from "@zip.js/zip.js"; +import { toPartial } from "./encode"; +import type { ExperimentConfig } from "./experiment_config"; import type { Experiment } from "./store"; -export function toConfig(experiment: Experiment): ExperimentConfigSchema { - return { - name: experiment.name, - description: experiment.description, - reference: experiment.reference.config, - permutations: experiment.permutations.map(({ name, config }) => { - return { - name, - config, - }; - }), - }; -} - -export function toConfigBlob(experiment: Experiment) { - const data = toConfig(experiment); +export function toConfigBlob(experiment: ExperimentConfig) { + const data = toPartial(experiment); return new Blob([JSON.stringify(data, undefined, 2)], { type: "application/json", }); @@ -36,25 +23,32 @@ function outputToCsv(output: ClassOutput) { export async function createArchive(experiment: Experiment) { const zipFileWriter = new BlobWriter(); const zipWriter = new ZipWriter(zipFileWriter); - const configBlob = new Blob([JSON.stringify(toConfig(experiment))], { - type: "application/json", - }); + const configBlob = new Blob( + [JSON.stringify(toPartial(experiment.config), undefined, 2)], + { + type: "application/json", + }, + ); await zipWriter.add("config.json", new BlobReader(configBlob)); - if (experiment.reference.output) { - const csvBlob = new Blob([outputToCsv(experiment.reference.output)], { + if (experiment.output.reference) { + const csvBlob = new Blob([outputToCsv(experiment.output.reference)], { type: "text/csv", }); - await zipWriter.add(`${experiment.name}.csv`, new BlobReader(csvBlob)); + await zipWriter.add( + `${experiment.config.reference.name}.csv`, + new BlobReader(csvBlob), + ); } - for (const permutation of experiment.permutations) { - const permutationOutput = permutation.output; + for (let index = 0; index < experiment.config.permutations.length; index++) { + const permConfig = experiment.config.permutations[index]; + const permutationOutput = experiment.output.permutations[index]; if (permutationOutput) { const csvBlob = new Blob([outputToCsv(permutationOutput)], { type: "text/csv", }); - await zipWriter.add(`${permutation.name}.csv`, new BlobReader(csvBlob)); + await zipWriter.add(`${permConfig.name}.csv`, new BlobReader(csvBlob)); } } await zipWriter.close(); diff --git a/apps/class-solid/src/lib/encode.ts b/apps/class-solid/src/lib/encode.ts index fb46f1ba..a2fbd5ac 100644 --- a/apps/class-solid/src/lib/encode.ts +++ b/apps/class-solid/src/lib/encode.ts @@ -1,30 +1,34 @@ -import { parse, pruneDefaults } from "@classmodel/class/validate"; +import { pruneConfig } from "@classmodel/class/config_utils"; import { unwrap } from "solid-js/store"; +import { + type ExperimentConfig, + type PartialExperimentConfig, + parseExperimentConfig, +} from "./experiment_config"; +import { findPresetByName } from "./presets"; 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, 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 experiments: Experiment[] = []; + if (typeof parsed === "object" && Array.isArray(parsed.experiments)) { + for (const exp of parsed.experiments) { + const config = parseExperimentConfig(exp); + const experiment: Experiment = { + config, + output: { + running: false, + permutations: [], + }, + }; + experiments.push(experiment); + } + } else { + console.error("No experiments found in ", encoded); + } + const analyses: Analysis[] = []; return [experiments, analyses]; } @@ -35,18 +39,25 @@ export function encodeAppState( ) { 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), - ]), - ), - })), + experiments: rawExperiments.map((exp) => toPartial(exp.config)), }; return encodeURI(JSON.stringify(minimizedState, undefined, 0)); } + +export function toPartial(config: ExperimentConfig): PartialExperimentConfig { + const preset = findPresetByName(config.preset); + const reference = pruneConfig(config.reference, preset.config); + return { + reference, + preset: config.preset, + permutations: config.permutations.map((perm) => + pruneConfig(perm, config.reference, preset.config), + ), + }; +} + +export function fromPartial( + partial: PartialExperimentConfig, +): ExperimentConfig { + return parseExperimentConfig(partial); +} diff --git a/apps/class-solid/src/lib/experiment_config.ts b/apps/class-solid/src/lib/experiment_config.ts new file mode 100644 index 00000000..11cd6483 --- /dev/null +++ b/apps/class-solid/src/lib/experiment_config.ts @@ -0,0 +1,99 @@ +import type { Config } from "@classmodel/class/config"; +import { + type PartialConfig, + overwriteDefaultsInJsonSchema, +} from "@classmodel/class/config_utils"; +import { ValidationError, ajv } from "@classmodel/class/validate"; +import type { DefinedError, JSONSchemaType } from "ajv"; +import { findPresetByName } from "./presets"; + +/** + * An experiment configuration is a combination of a preset name and + * a reference configuration and a set of permutation configurations. + */ +export interface ExperimentConfig { + preset: string; + reference: C; + permutations: C[]; +} + +/** + * A partial experiment configuration used for input and output. + * + * Parameters in permutation which are same as in reference or preset are absent. + * Parameters in reference which are same as in preset are absent. + */ +export type PartialExperimentConfig = ExperimentConfig; + +/** + * JSON schema of experiment config without checking the configs, thats done by the presets[x].parse() + */ +const jsonSchemaOfExperimentConfigBase = { + type: "object", + properties: { + preset: { type: "string", default: "Default" }, + reference: { + type: "object", + additionalProperties: true, + }, + permutations: { + type: "array", + items: { + type: "object", + additionalProperties: true, + }, + default: [], + }, + }, + required: ["preset", "reference", "permutations"], +} as unknown as JSONSchemaType>; + +const validateExperimentConfigBase = ajv.compile( + jsonSchemaOfExperimentConfigBase, +); + +function parseExperimentConfigBase(input: unknown): ExperimentConfig { + if (!validateExperimentConfigBase(input)) { + throw new ValidationError( + validateExperimentConfigBase.errors as DefinedError[], + ); + } + return input; +} + +/** Parse unknown input into an Experiment configuration + * + * The input can be partial, i.e. only contain the fields that are different from the preset or reference. + * + * @param input - The input to be parsed. + * @returns The validated input as a Experiment configuration object. + * @throws {ValidationError} If the input is not valid according to the validation rules. + */ +export function parseExperimentConfig(input: unknown): ExperimentConfig { + const base = parseExperimentConfigBase(input); + const preset = findPresetByName(base.preset); + const reference = preset.parse(base.reference); + + // The partial permutation should be parsed with the reference as the base. + // For example given parameter in preset is 10 and in reference is 20 + // If permutation has 30 then should stay 30. + // If permutation has undefined then should be 20. + const referenceSchema = overwriteDefaultsInJsonSchema( + preset.schema, + reference, + ); + const referenceValidate = ajv.compile(referenceSchema); + function permParse(input: unknown): Config { + if (!referenceValidate(input)) { + throw new ValidationError(referenceValidate.errors as DefinedError[]); + } + return input; + } + + const permutations = base.permutations.map(permParse); + return { + reference, + preset: base.preset, + permutations, + }; +} diff --git a/apps/class-solid/src/lib/presets.ts b/apps/class-solid/src/lib/presets.ts new file mode 100644 index 00000000..5feb812b --- /dev/null +++ b/apps/class-solid/src/lib/presets.ts @@ -0,0 +1,53 @@ +import { type Config, jsonSchemaOfConfig } from "@classmodel/class/config"; +import { overwriteDefaultsInJsonSchema } from "@classmodel/class/config_utils"; +import { + ValidationError, + ajv, + parse as origParse, +} from "@classmodel/class/validate"; +import type { DefinedError, JSONSchemaType, ValidateFunction } from "ajv"; +// TODO replace with preset of a forest fire +import deathValley from "./presets/death-valley.json"; + +const presetConfigs = [ + { + name: "Default", + description: "The classic default configuration", + }, + deathValley, +] as const; + +export interface Preset { + config: Config; + schema: JSONSchemaType; + validate: ValidateFunction; + parse: (input: unknown) => Config; +} + +function loadPreset(preset: unknown): Preset { + const config = origParse(preset); + const schema = overwriteDefaultsInJsonSchema(jsonSchemaOfConfig, config); + const validate = ajv.compile(schema); + + function parse(input: unknown): Config { + if (!validate(input)) { + throw new ValidationError(validate.errors as DefinedError[]); + } + return input; + } + + return { config, schema, validate, parse }; +} + +export const presets = presetConfigs.map(loadPreset); + +/** + * Finds a preset by its name. + * + * @param name - The name of the preset configuration to find. If undefined, the default preset is returned. + * @returns The preset that matches the given name, or the default preset if no match is found. + */ +export function findPresetByName(name?: string): Preset { + if (!name) return presets[0]; + return presets.find((preset) => preset.config.name === name) ?? presets[0]; +} diff --git a/apps/class-solid/src/lib/presets/death-valley.json b/apps/class-solid/src/lib/presets/death-valley.json new file mode 100644 index 00000000..cd713d66 --- /dev/null +++ b/apps/class-solid/src/lib/presets/death-valley.json @@ -0,0 +1,25 @@ +{ + "name": "Death Valley", + "description": "Preset with Death Valley conditions", + "initialState": { + "theta_0": 323, + "h_0": 200, + "dtheta_0": 1, + "q_0": 0.008, + "dq_0": -0.001 + }, + "timeControl": { + "dt": 60, + "runtime": 4320 + }, + "mixedLayer": { + "wtheta": 0.1, + "advtheta": 0, + "gammatheta": 0.006, + "wq": 0.0001, + "advq": 0, + "gammaq": 0, + "divU": 0, + "beta": 0.2 + } +} diff --git a/apps/class-solid/src/lib/profiles.ts b/apps/class-solid/src/lib/profiles.ts index ec1e81cf..77c93194 100644 --- a/apps/class-solid/src/lib/profiles.ts +++ b/apps/class-solid/src/lib/profiles.ts @@ -1,11 +1,11 @@ +import type { Config } from "@classmodel/class/config"; import type { ClassOutput } from "@classmodel/class/runner"; -import type { PartialConfig } from "@classmodel/class/validate"; import type { Point } from "~/components/plots/Line"; // Get vertical profiles for a single class run export function getVerticalProfiles( output: ClassOutput | undefined, - config: PartialConfig, + config: Config, variable = "theta", t = -1, ): Point[] { @@ -22,8 +22,7 @@ export function getVerticalProfiles( // Extract potential temperature profile const theta = output.theta.slice(t)[0]; const dtheta = output.dtheta.slice(t)[0]; - // TODO: make sure config contains gammatheta - const gammatheta = config.mixedLayer?.gammatheta ?? 0.006; + const gammatheta = config.mixedLayer.gammatheta; const thetaProfile = [ theta, theta, @@ -36,8 +35,7 @@ export function getVerticalProfiles( // Extract humidity profile const q = output.q.slice(t)[0]; const dq = output.dq.slice(t)[0]; - // TODO: make sure config contains gammaq - const gammaq = config.mixedLayer?.gammaq ?? 0; + const gammaq = config.mixedLayer.gammaq; const qProfile = [q, q, q + dq, q + dq + dh * gammaq]; return hProfile.map((h, i) => ({ x: qProfile[i], y: h })); } @@ -100,7 +98,7 @@ const thickness = (T: number, q: number, p: number, dp: number) => { export function getThermodynamicProfiles( output: ClassOutput | undefined, - config: PartialConfig, + config: Config, t = -1, ) { // Guard against undefined output @@ -113,9 +111,8 @@ export function getThermodynamicProfiles( const dtheta = output.dtheta.slice(t)[0]; const dq = output.dq.slice(t)[0]; const h = output.h.slice(t)[0]; - // TODO: ensure config contains gammatheta and gammaq - const gammaTheta = config.mixedLayer?.gammatheta ?? 0.006; - const gammaq = config.mixedLayer?.gammaq ?? 0; + const gammaTheta = config.mixedLayer.gammatheta; + const gammaq = config.mixedLayer.gammaq; const nz = 25; let dz = h / nz; diff --git a/apps/class-solid/src/lib/runner.ts b/apps/class-solid/src/lib/runner.ts index be66133b..b1a29ed4 100644 --- a/apps/class-solid/src/lib/runner.ts +++ b/apps/class-solid/src/lib/runner.ts @@ -1,7 +1,7 @@ import { 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 { parse } from "@classmodel/class/validate"; import { wrap } from "comlink"; const worker = new Worker(new URL("./worker.ts", import.meta.url), { @@ -9,7 +9,7 @@ const worker = new Worker(new URL("./worker.ts", import.meta.url), { }); export const AsyncBmiClass = wrap(worker); -export async function runClass(config: PartialConfig): Promise { +export async function runClass(config: Config): Promise { try { const parsedConfig: Config = parse(config); const model = await new AsyncBmiClass(); diff --git a/apps/class-solid/src/lib/state.ts b/apps/class-solid/src/lib/state.ts index e6e974d3..a4be0fbe 100644 --- a/apps/class-solid/src/lib/state.ts +++ b/apps/class-solid/src/lib/state.ts @@ -1,7 +1,13 @@ import { useLocation, useNavigate } from "@solidjs/router"; import { showToast } from "~/components/ui/toast"; import { encodeAppState } from "./encode"; -import { analyses, experiments, loadStateFromString } from "./store"; +import { findPresetByName } from "./presets"; +import { + analyses, + experiments, + loadStateFromString, + uploadExperiment, +} from "./store"; const localStorageName = "class-state"; @@ -38,6 +44,10 @@ export function loadFromLocalStorage() { export async function onPageLoad() { const location = useLocation(); const navigate = useNavigate(); + const presetUrl = location.query.preset; + if (presetUrl) { + return await loadExperimentPreset(presetUrl); + } const rawState = location.hash.substring(1); if (!rawState) { return; @@ -63,6 +73,31 @@ export async function onPageLoad() { navigate("/"); } +async function loadExperimentPreset(presetName: string) { + const navigate = useNavigate(); + try { + const reference = findPresetByName(presetName).config; + await uploadExperiment({ + preset: presetName, + reference, + permutations: [], + }); + showToast({ + title: "Experiment preset loaded", + variant: "success", + duration: 1000, + }); + } catch (error) { + console.error(error); + showToast({ + title: "Failed to load preset", + description: `${error}`, + variant: "error", + }); + } + navigate("/"); +} + export function saveToLocalStorage() { const appState = encodeAppState(experiments, analyses); if ( diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index cd06d5b0..ca8f0b45 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -1,82 +1,61 @@ +import { createUniqueId } from "solid-js"; import { createStore, produce, unwrap } from "solid-js/store"; +import type { Config } from "@classmodel/class/config"; import type { ClassOutput } from "@classmodel/class/runner"; + import { - type PartialConfig, - parseExperimentConfig, - pruneDefaults, -} from "@classmodel/class/validate"; -import { createUniqueId } from "solid-js"; + mergeConfigurations, + pruneConfig, +} from "@classmodel/class/config_utils"; import { decodeAppState } from "./encode"; +import { parseExperimentConfig } from "./experiment_config"; +import type { ExperimentConfig } from "./experiment_config"; +import { findPresetByName } from "./presets"; 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; -} - -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[]; +interface ExperimentOutput { + reference?: ClassOutput; + permutations: Array; running: number | false; } +export type Experiment = { + config: ExperimentConfig; + output: ExperimentOutput; +}; + export const [experiments, setExperiments] = createStore([]); export const [analyses, setAnalyses] = createStore([]); -// biome-ignore lint/suspicious/noExplicitAny: recursion is hard to type -function mergeConfigurations(reference: any, permutation: any) { - const merged = { ...reference }; - - for (const key in permutation) { - if ( - permutation[key] && - typeof permutation[key] === "object" && - !Array.isArray(permutation[key]) - ) { - merged[key] = mergeConfigurations(reference[key], permutation[key]); - } else { - merged[key] = permutation[key]; - } - } - - return merged; -} - export async function runExperiment(id: number) { const exp = experiments[id]; - setExperiments(id, "running", 0.0001); + setExperiments(id, "output", "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 referenceConfig = unwrap(exp.reference.config); + const referenceConfig = unwrap(exp.config.reference); const newOutput = await runClass(referenceConfig); - setExperiments(id, "reference", "output", newOutput); + setExperiments(id, "output", "reference", newOutput); // Run permutations let permCounter = 0; - for (const proxiedPerm of exp.permutations) { - const permConfig = unwrap(proxiedPerm.config); - const combinedConfig = mergeConfigurations(referenceConfig, permConfig); + for (const proxiedPerm of exp.config.permutations) { + const permConfig = unwrap(proxiedPerm); + const combinedConfig = mergeConfigurations( + referenceConfig, + permConfig, + ) as Config; const newOutput = await runClass(combinedConfig); - setExperiments(id, "permutations", permCounter, "output", newOutput); + setExperiments(id, "output", "permutations", permCounter, newOutput); permCounter++; } - setExperiments(id, "running", false); + setExperiments(id, "output", "running", false); // If no analyis are set then add all of them if (analyses.length === 0) { @@ -95,48 +74,52 @@ function findExperiment(index: number) { return exp; } -export async function addExperiment( - config: PartialConfig = {}, - name?: string, - description?: string, -) { +export async function addExperiment(reference: Config) { const newExperiment: Experiment = { - name: name ?? `My experiment ${experiments.length}`, - description: description ?? "Standard experiment", - reference: { - config, + config: { + preset: "Default", + reference, + permutations: [], + }, + output: { + permutations: [], + running: false, }, - permutations: [], - running: false, }; setExperiments(experiments.length, newExperiment); await runExperiment(experiments.length - 1); } export async function uploadExperiment(rawData: unknown) { + const defaultPreset = findPresetByName(); const upload = parseExperimentConfig(rawData); const experiment: Experiment = { - name: upload.name, // TODO check name is not already used - description: upload.description ?? "", - reference: { - config: upload.reference, + config: { + preset: upload.preset ?? defaultPreset.config.name, + reference: upload.reference, + permutations: upload.permutations, + }, + output: { + permutations: [], + running: false, }, - permutations: upload.permutations, - running: false, }; setExperiments(experiments.length, experiment); await runExperiment(experiments.length - 1); } export function duplicateExperiment(id: number) { - const original = structuredClone(findExperiment(id)); - - const newExperiment = { - ...original, - name: `Copy of ${original.name}`, - description: original.description, - running: 0, + const config = structuredClone(findExperiment(id).config); + config.reference.name = `Copy of ${config.reference.name}`; + const newExperiment: Experiment = { + config: config, + output: { + reference: undefined, + permutations: [], + running: false, + }, }; + setExperiments(experiments.length, newExperiment); runExperiment(experiments.length - 1); } @@ -145,27 +128,17 @@ export function deleteExperiment(index: number) { setExperiments(experiments.filter((_, i) => i !== index)); } -export async function modifyExperiment( - index: number, - newConfig: PartialConfig, - name: string, - description: string, -) { +export async function modifyExperiment(index: number, newConfig: Config) { setExperiments( index, + "config", produce((e) => { - e.reference.config = newConfig; - e.name = name; - e.description = description; + const oldConfig = unwrap(e.reference); + e.reference = newConfig; e.permutations = e.permutations.map((perm) => { - const config = mergeConfigurations( - newConfig, - pruneDefaults(perm.config), - ); - return { - ...perm, - config, - }; + const oldPermConfig = unwrap(perm); + const permPrunedConfig = pruneConfig(oldPermConfig, oldConfig); + return mergeConfigurations(newConfig, permPrunedConfig); }); }), ); @@ -174,17 +147,17 @@ export async function modifyExperiment( export async function setPermutationConfigInExperiment( experimentIndex: number, - permutationIndex: number, - config: PartialConfig, - name: string, + permutationIndex: number, // use -1 to add a permutation + config: Config, ) { setExperiments( experimentIndex, + "config", "permutations", permutationIndex === -1 - ? findExperiment(experimentIndex).permutations.length + ? findExperiment(experimentIndex).config.permutations.length : permutationIndex, - { config, name }, + config, ); await runExperiment(experimentIndex); } @@ -193,13 +166,15 @@ export async function deletePermutationFromExperiment( experimentIndex: number, permutationIndex: number, ) { - setExperiments(experimentIndex, "permutations", (perms) => + setExperiments(experimentIndex, "config", "permutations", (perms) => perms.filter((_, i) => i !== permutationIndex), ); } export function findPermutation(exp: Experiment, permutationName: string) { - const perm = exp.permutations.find((perm) => perm.name === permutationName); + const perm = exp.config.permutations.find( + (perm) => perm.name === permutationName, + ); if (!perm) { throw new Error(`No permutation with name ${permutationName}`); } @@ -211,10 +186,10 @@ export function promotePermutationToExperiment( permutationIndex: number, ) { const exp = findExperiment(experimentIndex); - const perm = exp.permutations[permutationIndex]; + const perm = exp.config.permutations[permutationIndex]; - const newConfig = structuredClone(perm.config); - addExperiment(newConfig, perm.name, ""); + const newConfig = structuredClone(perm); + addExperiment(newConfig); // TODO should permutation be removed from original experiment? } @@ -223,13 +198,9 @@ export function duplicatePermutation( permutationIndex: number, ) { const exp = findExperiment(experimentIndex); - const perm = exp.permutations[permutationIndex]; - setPermutationConfigInExperiment( - experimentIndex, - -1, - structuredClone(perm.config), - `Copy of ${perm.name}`, - ); + const perm = structuredClone(exp.config.permutations[permutationIndex]); + perm.name = `Copy of ${perm.name}`; + setPermutationConfigInExperiment(experimentIndex, -1, perm); runExperiment(experimentIndex); } @@ -238,19 +209,23 @@ export function swapPermutationAndReferenceConfiguration( permutationIndex: number, ) { const exp = findExperiment(experimentIndex); - const refConfig = structuredClone(exp.reference.config); - const perm = exp.permutations[permutationIndex]; - const permConfig = structuredClone(perm.config); + const refConfig = structuredClone(exp.config.reference); + const perm = exp.config.permutations[permutationIndex]; + const permConfig = structuredClone(perm); - setExperiments(experimentIndex, "reference", "config", permConfig); + setExperiments(experimentIndex, "config", "reference", permConfig); setExperiments( experimentIndex, + "config", "permutations", permutationIndex, - "config", refConfig, ); // TODO should names also be swapped? + + // TODO update all other permutations? + // As they are full configs which where based on the reference + runExperiment(experimentIndex); } diff --git a/apps/class-solid/tests/experiment.spec.ts b/apps/class-solid/tests/experiment.spec.ts index e0e309d4..d658d37b 100644 --- a/apps/class-solid/tests/experiment.spec.ts +++ b/apps/class-solid/tests/experiment.spec.ts @@ -36,20 +36,15 @@ test("Duplicate experiment with a permutation", async ({ page }, testInfo) => { const downloadPromise1 = page.waitForEvent("download"); await page.getByRole("link", { name: "Configuration", exact: true }).click(); const config1 = await parseDownload(downloadPromise1); - expect(config1.reference.initialState?.h_0).toEqual(200); - expect(config1.reference.mixedLayer?.beta).toEqual(0.2); - expect(config1.permutations[0].config.initialState?.h_0).toEqual(800); - expect(config1.permutations[0].config.mixedLayer?.beta).toEqual(0.2); + expect(config1.permutations[0].initialState?.h_0).toEqual(800); // Download configuration of experiment 2 experiment2.getByRole("button", { name: "Download" }).click(); const downloadPromise2 = page.waitForEvent("download"); await page.getByRole("link", { name: "Configuration", exact: true }).click(); const config2 = await parseDownload(downloadPromise2); - expect(config2.reference.initialState?.h_0).toEqual(200); expect(config2.reference.mixedLayer?.beta).toEqual(0.3); - expect(config2.permutations[0].config.initialState?.h_0).toEqual(800); - expect(config2.permutations[0].config.mixedLayer?.beta).toEqual(0.3); + expect(config2.permutations[0].initialState?.h_0).toEqual(800); // visually check that timeseries plot has 4 non-overlapping lines await testInfo.attach("timeseries plot with 4 non-overlapping lines", { @@ -84,12 +79,12 @@ test("Swap permutation with default reference", async ({ page }) => { await page.getByRole("menuitem", { name: "Swap permutation with" }).click(); // Assert - experiment.getByRole("button", { name: "Download" }).click(); + const renamedExperiment = page.getByLabel("1", { exact: true }); + renamedExperiment.getByRole("button", { name: "Download" }).click(); const downloadPromise1 = page.waitForEvent("download"); await page.getByRole("link", { name: "Configuration", exact: true }).click(); const config1 = await parseDownload(downloadPromise1); expect(config1.reference.initialState?.h_0).toEqual(800); - expect(config1.permutations[0].config.initialState?.h_0).toEqual(200); }); test("Swap permutation with custom reference", async ({ page }) => { @@ -119,16 +114,14 @@ test("Swap permutation with custom reference", async ({ page }) => { await page.getByRole("menuitem", { name: "Swap permutation with" }).click(); // Assert that parameters are swapped and default values are not overwritten. - experiment.getByRole("button", { name: "Download" }).click(); + const renamedExperiment = page.getByLabel("1", { exact: true }); + renamedExperiment.getByRole("button", { name: "Download" }).click(); const downloadPromise1 = page.waitForEvent("download"); await page.getByRole("link", { name: "Configuration", exact: true }).click(); const config1 = await parseDownload(downloadPromise1); expect(config1.reference.initialState?.h_0).toEqual(800); - expect(config1.reference.initialState?.theta_0).toEqual(288); // the default expect(config1.reference.initialState?.dtheta_0).toEqual(0.8); - expect(config1.permutations[0].config.initialState?.h_0).toEqual(400); - expect(config1.permutations[0].config.initialState?.theta_0).toEqual(265); - expect(config1.permutations[0].config.initialState?.dtheta_0).toEqual(1); // the default + expect(config1.permutations[0].initialState?.h_0).toEqual(400); }); test("Promote permutation to a new experiment", async ({ page }) => { @@ -146,7 +139,7 @@ test("Promote permutation to a new experiment", async ({ page }) => { ) .click(); await page.getByRole("button", { name: "Initial State" }).click(); - await page.getByLabel("Title").fill("perm1"); + await page.getByLabel("Name").fill("perm1"); await page.getByLabel("ABL height").fill("800"); await page.getByRole("button", { name: "Run" }).click(); @@ -197,8 +190,7 @@ test("Duplicate permutation", async ({ page }) => { const downloadPromise1 = page.waitForEvent("download"); await page.getByRole("link", { name: "Configuration", exact: true }).click(); const config1 = await parseDownload(downloadPromise1); - expect(config1.reference.initialState?.h_0).toEqual(200); expect(config1.permutations.length).toEqual(2); - expect(config1.permutations[0].config.initialState?.h_0).toEqual(800); - expect(config1.permutations[1].config.initialState?.h_0).toEqual(400); + expect(config1.permutations[0].initialState?.h_0).toEqual(800); + expect(config1.permutations[1].initialState?.h_0).toEqual(400); }); diff --git a/apps/class-solid/tests/helpers.ts b/apps/class-solid/tests/helpers.ts index 13b26f2d..950a375b 100644 --- a/apps/class-solid/tests/helpers.ts +++ b/apps/class-solid/tests/helpers.ts @@ -1,10 +1,9 @@ import type { Download } from "@playwright/test"; - -import type { ExperimentConfigSchema } from "@classmodel/class/validate"; +import type { PartialExperimentConfig } from "~/lib/experiment_config"; export async function parseDownload( downloadPromise: Promise, -): Promise { +): Promise { const download = await downloadPromise; const readStream = await download.createReadStream(); const body = await new Promise((resolve, reject) => { @@ -13,5 +12,5 @@ export async function parseDownload( readStream.on("end", () => resolve(chunks.join(""))); readStream.on("error", reject); }); - return JSON.parse(body) as ExperimentConfigSchema; + return JSON.parse(body) as PartialExperimentConfig; } diff --git a/packages/class/README.md b/packages/class/README.md index aaafb3d8..a1531612 100644 --- a/packages/class/README.md +++ b/packages/class/README.md @@ -20,8 +20,12 @@ The CLASS web application that uses this package is available at https://classmo The class model can be run from the command line. ```shell -# Generate default config file -pnpx @classmodel/class generate --output config.json +# Generate config file with default values +pnpx @classmodel/class generate -o config.json +# Or download one of the presets from +# https://github.com/classmodel/class-web/tree/main/apps/class-solid/src/lib/presets + +# Edit the config file # Run the model pnpx @classmodel/class run config.json @@ -110,3 +114,7 @@ pnpm run docs Which will write HTML files to `docs/` directory. The documentation of the latest release is published at [https://classmodel.github.io/class-web/docs/](https://classmodel.github.io/class-web/docs/). + +## Disclaimer + +This project includes code that was generated with the assistance of a language model (LLM). All code generated by the LLM has been reviewed by the development team. diff --git a/packages/class/package.json b/packages/class/package.json index 34e7bb49..6f70ab8a 100644 --- a/packages/class/package.json +++ b/packages/class/package.json @@ -24,6 +24,12 @@ } }, "./config.json": "./dist/config.json", + "./config_utils": { + "import": { + "default": "./dist/config_utils.js", + "types": "./dist/config_utils.d.ts" + } + }, "./runner": { "import": { "default": "./dist/runner.js", diff --git a/packages/class/scripts/json2ts.mjs b/packages/class/scripts/json2ts.mjs index fb42b262..5821a3e7 100644 --- a/packages/class/scripts/json2ts.mjs +++ b/packages/class/scripts/json2ts.mjs @@ -52,6 +52,16 @@ async function json2ts(jsonSchemaPath, schemaTsPath) { bannerComment: "", }); + // Modular forms does not like interface definitions + // See https://github.com/fabian-hiller/modular-forms/issues/2 + // and https://github.com/bcherny/json-schema-to-typescript/issues/307 + // So, replace interface with type + // for example "interface Foo {" becomes "type Foo = {" + const tsOfJsonSchemaTypesOnly = tsOfJsonSchema.replaceAll( + /interface\s+(\w+) {/g, + "type $1 = {", + ); + const prefix = prefixOfJsonSchema(jsonSchemaPath); // Read JSON schema file @@ -65,7 +75,7 @@ async function json2ts(jsonSchemaPath, schemaTsPath) { * and run "pnpm json2ts" to regenerate this file. */ import type { JSONSchemaType } from "ajv/dist/2019.js"; -${tsOfJsonSchema} +${tsOfJsonSchemaTypesOnly} export type JsonSchemaOf${prefix} = JSONSchemaType<${prefix}>; /** * JSON schema of ${jsonSchemaPath} embedded in a TypeScript file. diff --git a/packages/class/src/class.test.ts b/packages/class/src/class.test.ts index 47d8339e..b33a3573 100644 --- a/packages/class/src/class.test.ts +++ b/packages/class/src/class.test.ts @@ -36,7 +36,6 @@ describe("CLASS model", () => { test("produces realistic results", () => { const config = parse({}); const output = runClass(config); - console.log(output); assert.ok(output); }); }); diff --git a/packages/class/src/config.json b/packages/class/src/config.json index 7dec05a1..8649de82 100644 --- a/packages/class/src/config.json +++ b/packages/class/src/config.json @@ -1,6 +1,8 @@ { "type": "object", "properties": { + "name": { "type": "string", "title": "Name", "default": "" }, + "description": { "type": "string", "title": "Description", "default": "" }, "initialState": { "type": "object", "properties": { @@ -130,6 +132,12 @@ } }, "additionalProperties": false, - "required": ["initialState", "timeControl", "mixedLayer"], + "required": [ + "name", + "description", + "initialState", + "timeControl", + "mixedLayer" + ], "$schema": "https://json-schema.org/draft/2019-09/schema" } diff --git a/packages/class/src/config.ts b/packages/class/src/config.ts index 99deca75..2dff8cf9 100644 --- a/packages/class/src/config.ts +++ b/packages/class/src/config.ts @@ -4,6 +4,8 @@ * and run "pnpm json2ts" to regenerate this file. */ import type { JSONSchemaType } from "ajv/dist/2019.js"; +export type Name = string; +export type Description = string; export type ABLHeight = number; /** * The potential temperature of the mixed layer at the initial time. @@ -23,23 +25,25 @@ export type FreeAtmosphereSpecificHumidityLapseRate = number; export type HorizontalLargeScaleDivergenceOfWind = number; export type EntrainmentRatioForVirtualHeat = number; -export interface Config { +export type Config = { + name: Name; + description: Description; initialState: InitialState; timeControl: TimeControl; mixedLayer: MixedLayer; -} -export interface InitialState { +}; +export type InitialState = { h_0: ABLHeight; theta_0: MixedLayerPotentialTemperature; dtheta_0: TemperatureJumpAtH; q_0: MixedLayerSpecificHumidity; dq_0: SpecificHumidityJumpAtH; -} -export interface TimeControl { +}; +export type TimeControl = { dt: TimeStep; runtime: TotalRunTime; -} -export interface MixedLayer { +}; +export type MixedLayer = { wtheta: SurfaceKinematicHeatFlux; advtheta: AdvectionOfHeat; gammatheta: FreeAtmospherePotentialTemperatureLapseRate; @@ -48,7 +52,7 @@ export interface MixedLayer { gammaq: FreeAtmosphereSpecificHumidityLapseRate; divU: HorizontalLargeScaleDivergenceOfWind; beta: EntrainmentRatioForVirtualHeat; -} +}; export type JsonSchemaOfConfig = JSONSchemaType; /** @@ -57,6 +61,8 @@ export type JsonSchemaOfConfig = JSONSchemaType; export const jsonSchemaOfConfig = { type: "object", properties: { + name: { type: "string", title: "Name", default: "" }, + description: { type: "string", title: "Description", default: "" }, initialState: { type: "object", properties: { @@ -187,6 +193,12 @@ export const jsonSchemaOfConfig = { }, }, additionalProperties: false, - required: ["initialState", "timeControl", "mixedLayer"], + required: [ + "name", + "description", + "initialState", + "timeControl", + "mixedLayer", + ], $schema: "https://json-schema.org/draft/2019-09/schema", } as unknown as JsonSchemaOfConfig; diff --git a/packages/class/src/config_utils.test.ts b/packages/class/src/config_utils.test.ts new file mode 100644 index 00000000..45a97ec0 --- /dev/null +++ b/packages/class/src/config_utils.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert"; +import test, { describe } from "node:test"; +import { jsonSchemaOfConfig } from "./config.js"; +import { overwriteDefaultsInJsonSchema, pruneConfig } from "./config_utils.js"; + +describe("overwriteDefaultsInJsonSchema", () => { + test("given new default for initialState.h_0 should return schema with given default", () => { + const schema = structuredClone(jsonSchemaOfConfig); + const defaults = { + name: "Default", + description: "Default configuration", + initialState: { + theta_0: 288, + h_0: 42, // changed + 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, + }, + }; + + const result = overwriteDefaultsInJsonSchema(schema, defaults); + + const expected = structuredClone(jsonSchemaOfConfig); + expected.properties.initialState.properties.h_0.default = 42; + + assert.deepEqual(result, expected); + }); +}); + +describe("pruneConfig()", () => { + test("given 3 real configs", () => { + const preset = { + name: "Default", + description: "Default configuration", + initialState: { + theta_0: 323, + h_0: 200, + dtheta_0: 1, + q_0: 0.008, + dq_0: -0.001, + }, + timeControl: { + dt: 60, + runtime: 4320, + }, + mixedLayer: { + wtheta: 0.1, + advtheta: 0, + gammatheta: 0.006, + wq: 0.0001, + advq: 0, + gammaq: 0, + divU: 0, + beta: 0.2, + }, + }; + const reference = { + name: "Higher and Hotter", + description: "Higher h_0", + initialState: { + h_0: 211, + theta_0: 323, + dtheta_0: 1, + q_0: 0.008, + dq_0: -0.001, + }, + timeControl: { + dt: 60, + runtime: 4320, + }, + mixedLayer: { + wtheta: 0.1, + advtheta: 0, + gammatheta: 0.006, + wq: 0.0001, + advq: 0, + gammaq: 0, + divU: 0, + beta: 0.2, + }, + }; + const permutation = { + name: "Higher", + description: "", + initialState: { + h_0: 222, + theta_0: 323, + dtheta_0: 1, + q_0: 0.008, + dq_0: -0.001, + }, + timeControl: { + dt: 60, + runtime: 4320, + }, + mixedLayer: { + wtheta: 0.1, + advtheta: 0, + gammatheta: 0.006, + wq: 0.0001, + advq: 0, + gammaq: 0, + divU: 0, + beta: 0.212, + }, + }; + const result = pruneConfig(permutation, reference, preset); + const expected = { + name: "Higher", + description: "", + initialState: { + h_0: 222, + }, + mixedLayer: { + beta: 0.212, + }, + }; + assert.deepEqual(result, expected); + }); +}); diff --git a/packages/class/src/config_utils.ts b/packages/class/src/config_utils.ts new file mode 100644 index 00000000..b0ab1533 --- /dev/null +++ b/packages/class/src/config_utils.ts @@ -0,0 +1,139 @@ +import type { Config, JsonSchemaOfConfig } from "./config.js"; + +/** + * Overwrite values in first configuration with second configuration. + * + * @param first + * @param second + * @returns Shallow copy of first with values of second. + */ +// biome-ignore lint/suspicious/noExplicitAny: recursion is hard to type +export function mergeConfigurations(first: any, second: any) { + const merged = { ...first }; + + for (const key in second) { + if ( + second[key] && + typeof second[key] === "object" && + !Array.isArray(second[key]) + ) { + merged[key] = mergeConfigurations(first[key], second[key]); + } else { + merged[key] = second[key]; + } + } + + return merged; +} + +type RecursivePartial = { + [P in keyof T]?: T[P] extends (infer U)[] + ? RecursivePartial[] + : T[P] extends object | undefined + ? RecursivePartial + : T[P]; +}; + +/** + * Config with all values optional. + */ +export type PartialConfig = RecursivePartial; + +/** + * + * From first config remove all parameters that are the same as in the second config or third config. + * + * @param permutation + * @param reference + * @param preset + * @returns Pruned permutation configuration + */ +export function pruneConfig( + permutation: Config, + reference: Config, + preset?: Config, +): PartialConfig { + let config = structuredClone(permutation); + let config2 = reference; + if (preset) { + config = pruneConfig(permutation, reference) as Config; + config2 = preset; + } + for (const section in config) { + const s = config[section as keyof typeof config]; + const s2 = config2[section as keyof typeof config2]; + if (s === undefined || s2 === undefined) { + continue; + } + if (typeof s === "string") { + // Do not prune name and description + continue; + } + for (const key in s) { + const k = key as keyof typeof s; + const k2 = key as keyof typeof s2; + if (s[k] === s2[k2]) { + delete s[k]; + } + } + if (Object.keys(s).length === 0) { + delete config[section as keyof typeof config]; + } + } + return config; +} + +/** + * Overwrites the default values in a JSON schema with the provided defaults. + * + * @param schema - The original JSON schema to be modified. + * @param defaults - An object containing the default values to overwrite in the schema. + * @returns A new JSON schema with the default values overwritten. + * + * @remarks + * This function currently only handles objects of objects and needs to be made more generic. + * + * @example + * ```typescript + * const schema = { + * properties: { + * setting1: { + * properties: { + * subsetting1: { type: 'string', default: 'oldValue' } + * } + * } + * } + * }; + * + * const defaults = { + * setting1: { + * subsetting1: 'newValue' + * } + * }; + * + * const newSchema = overwriteDefaultsInJsonSchema(schema, defaults); + * console.log(newSchema.properties.setting1.properties.subsetting1.default); // 'newValue' + * ``` + */ +export function overwriteDefaultsInJsonSchema( + schema: JsonSchemaOfConfig, + defaults: Config, +) { + const newSchema = structuredClone(schema); + // TODO make more generic, now only handles object of objects + for (const key in defaults) { + const val = defaults[key as keyof Config]; + if (typeof val !== "object") { + continue; + } + for (const subkey in val) { + const subval = val[subkey as keyof typeof val]; + const prop = + newSchema.properties[key as keyof Config].properties[ + subkey as keyof typeof val + ]; + prop.default = subval; + } + } + return newSchema; +} diff --git a/packages/class/src/sweep.ts b/packages/class/src/sweep.ts index 818fe79d..513a277d 100644 --- a/packages/class/src/sweep.ts +++ b/packages/class/src/sweep.ts @@ -1,31 +1,18 @@ -import type { PartialConfig } from "./validate.js"; +import { type PartialConfig, mergeConfigurations } from "./config_utils.js"; export interface Sweep { - section: string; + section: string; // Only handles single level nesting 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; - }), - ); - }, + (acc, curr) => + acc.flatMap((a) => curr.map((b) => mergeConfigurations(a, b))), [{}], ); } diff --git a/packages/class/src/validate.test.ts b/packages/class/src/validate.test.ts index 3e92aeaa..a07fdb8b 100644 --- a/packages/class/src/validate.test.ts +++ b/packages/class/src/validate.test.ts @@ -1,14 +1,7 @@ import assert from "node:assert"; import test, { describe } from "node:test"; -import { type Config, jsonSchemaOfConfig } from "./config.js"; -import { - type PartialConfig, - overwriteDefaultsInJsonSchema, - parse, - pruneDefaults, - validate, -} from "./validate.js"; +import { parse, validate } from "./validate.js"; describe("validate", () => { test("should validate a valid config", () => { @@ -53,6 +46,8 @@ describe("parse", () => { const output = parse(input); const expected = { + name: "", + description: "", initialState: { h_0: 200, theta_0: 288, @@ -113,85 +108,3 @@ describe("parse", () => { }); }); }); - -describe("pruneDefaults", () => { - test("given all defaults should return empty object", () => { - const input: Config = { - 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, - }, - }; - - const output = pruneDefaults(input); - - assert.deepEqual(output, {}); - }); - - test("given 1 non defaults should return object with single key", () => { - const input: Config = { - initialState: { - h_0: 300, - 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, - }, - }; - - const output = pruneDefaults(input); - - const expected: PartialConfig = { - initialState: { h_0: 300 }, - }; - assert.deepEqual(output, expected); - }); -}); - -describe("overwriteDefaultsInJsonSchema", () => { - test("given zero defaults should return original schema", () => { - const schema = structuredClone(jsonSchemaOfConfig); - const defaults = {}; - - const result = overwriteDefaultsInJsonSchema(schema, defaults); - - const expected = structuredClone(jsonSchemaOfConfig); - assert.deepEqual(result, expected); - }); - - test("given default for initialState.h_0 should return schema with given default", () => { - const schema = structuredClone(jsonSchemaOfConfig); - const defaults = { initialState: { h_0: 42 } }; - - const result = overwriteDefaultsInJsonSchema(schema, defaults); - - const expected = structuredClone(jsonSchemaOfConfig); - expected.properties.initialState.properties.h_0.default = 42; - assert.deepEqual(result, expected); - }); -}); diff --git a/packages/class/src/validate.ts b/packages/class/src/validate.ts index 2a1bee73..57bde91f 100644 --- a/packages/class/src/validate.ts +++ b/packages/class/src/validate.ts @@ -4,7 +4,7 @@ * @module */ import { Ajv2019 } from "ajv/dist/2019.js"; -import type { DefinedError, JSONSchemaType } from "ajv/dist/2019.js"; +import type { DefinedError } from "ajv/dist/2019.js"; import { type Config, jsonSchemaOfConfig } from "./config.js"; export const ajv = new Ajv2019({ @@ -53,152 +53,3 @@ export function parse(input: unknown): Config { } return input; } - -type RecursivePartial = { - [P in keyof T]?: T[P] extends (infer U)[] - ? RecursivePartial[] - : T[P] extends object | undefined - ? RecursivePartial - : T[P]; -}; - -export type PartialConfig = RecursivePartial; - -/** - * Prunes the default values from the given configuration object. - * - * This function compares the provided configuration object with the default - * configuration and removes any properties that match the default values. - * It currently handles only objects of objects. - * - * @param config - The configuration object to prune. - * @returns A new configuration object with default values removed. - */ -export function pruneDefaults(config: PartialConfig): PartialConfig { - const newConfig: PartialConfig = {}; - const defaultConfig = parse({}); - // TODO make more generic, now only handles object of objects - for (const [key, value] of Object.entries(config)) { - const tkey = key as keyof Config; - if (typeof value === "object") { - for (const [subKey, subValue] of Object.entries(value)) { - const defaultParent = defaultConfig[tkey]; - const defaultValue = - defaultParent[subKey as keyof typeof defaultParent]; - if (subValue !== defaultValue) { - if (!newConfig[tkey]) { - newConfig[tkey] = {}; - } - (newConfig[tkey] as Record)[subKey] = subValue; - } - } - } - } - - return newConfig; -} - -/** - * Overwrites the default values in a JSON schema with the provided defaults. - * - * @param schema - The original JSON schema to be modified. - * @param defaults - An object containing the default values to overwrite in the schema. - * @returns A new JSON schema with the default values overwritten. - * - * @remarks - * This function currently only handles objects of objects and needs to be made more generic. - * - * @example - * ```typescript - * const schema = { - * properties: { - * setting1: { - * properties: { - * subsetting1: { type: 'string', default: 'oldValue' } - * } - * } - * } - * }; - * - * const defaults = { - * setting1: { - * subsetting1: 'newValue' - * } - * }; - * - * const newSchema = overwriteDefaultsInJsonSchema(schema, defaults); - * console.log(newSchema.properties.setting1.properties.subsetting1.default); // 'newValue' - * ``` - */ -export function overwriteDefaultsInJsonSchema( - schema: JSONSchemaType, - defaults: RecursivePartial, -) { - const newSchema = structuredClone(schema); - // TODO make more generic, now only handles object of objects - for (const key in defaults) { - const val = defaults[key as keyof RecursivePartial]; - for (const subkey in val) { - const subval = val[subkey as keyof typeof val]; - const prop = - newSchema.properties[key as keyof C].properties[ - subkey as keyof typeof val - ]; - prop.default = subval; - } - } - return newSchema; -} - -// TODO move below to app, this is not a general utility, unless cli can run experiment -/** - * An experiment configuration is a combination of a reference configuration and a set of permutation configurations. - */ -export interface ExperimentConfigSchema { - name: string; - description?: string; - reference: PartialConfig; - permutations: { - name: string; - config: PartialConfig; - }[]; -} -const jsonSchemaOfExperimentConfig = { - type: "object", - properties: { - name: { type: "string" }, - description: { - type: "string", - }, - reference: jsonSchemaOfConfig, - permutations: { - type: "array", - items: { - type: "object", - properties: { - name: { type: "string" }, - config: jsonSchemaOfConfig, - }, - }, - }, - }, - required: ["name", "reference"], -} as unknown as JSONSchemaType; -// TODO remove cast via unknown - -const validateExperimentConfig = ajv.compile(jsonSchemaOfExperimentConfig); - -/** Parse unknown input into a Experiment configuration - * - * @param input - The input to be parsed. - * @returns The validated input as a Experiment configuration object. - * @throws {ValidationError} If the input is not valid according to the validation rules. - */ -export function parseExperimentConfig(input: unknown): ExperimentConfigSchema { - if (!validateExperimentConfig(input)) { - throw new ValidationError( - validateExperimentConfig.errors as DefinedError[], - ); - } - return input; -}