diff --git a/justfile b/justfile
index 750909b3..0aaab1a2 100644
--- a/justfile
+++ b/justfile
@@ -8,3 +8,7 @@ bump version:
sed -i 's/^version: .*/version: {{ version }}/' CITATION.cff && \
sed -i 's/^version = .*/version = "{{ version }}"/' pyproject.toml && \
sed -i 's/^release = .*/release = "{{ version }}"/' docs/source/conf.py
+
+build:
+ cd simopt-web && npm run build
+ uv build
diff --git a/pyproject.toml b/pyproject.toml
index 0f7c7845..c155cf77 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,6 +10,7 @@ exclude = ["build*"]
[tool.setuptools.package-data]
"simopt.data_farming" = ["*.npz"]
+"simopt.web" = ["dist/*", "dist/**/*"]
[tool.uv.sources]
simopt-extensions = { git = "https://github.com/cenwangumass/simopt-extensions", rev = "b40a9c8" }
@@ -36,6 +37,7 @@ dependencies = [
"boltons>=25.0.0",
"colorama>=0.4.6",
"cvxpy>=1.7.3",
+ "fastapi[standard]>=0.128.8",
"joblib>=1.5.2",
"matplotlib>=3.10.7",
"mrg32k3a[rust]>=2.0.0",
@@ -65,6 +67,9 @@ ext = [
"simopt-extensions",
]
+[project.scripts]
+simopt = "simopt.cli:main"
+
[project.urls]
"Homepage" = "https://github.com/simopt-admin/simopt"
"Documentation" = "https://simopt.readthedocs.io/en/latest/"
diff --git a/simopt-web/.gitignore b/simopt-web/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/simopt-web/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/simopt-web/README.md b/simopt-web/README.md
new file mode 100644
index 00000000..a45e2a0b
--- /dev/null
+++ b/simopt-web/README.md
@@ -0,0 +1,47 @@
+# Svelte + TS + Vite
+
+This template should help get you started developing with Svelte and TypeScript in Vite.
+
+## Recommended IDE Setup
+
+[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
+
+## Need an official Svelte framework?
+
+Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
+
+## Technical considerations
+
+**Why use this over SvelteKit?**
+
+- It brings its own routing solution which might not be preferable for some users.
+- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
+
+This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
+
+Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
+
+**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
+
+Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
+
+**Why include `.vscode/extensions.json`?**
+
+Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
+
+**Why enable `allowJs` in the TS template?**
+
+While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
+
+**Why is HMR not preserving my local component state?**
+
+HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
+
+If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
+
+```ts
+// store.ts
+// An extremely simple external store
+import { writable } from "svelte/store";
+export default writable(0);
+```
diff --git a/simopt-web/eslint.config.js b/simopt-web/eslint.config.js
new file mode 100644
index 00000000..97cb8a31
--- /dev/null
+++ b/simopt-web/eslint.config.js
@@ -0,0 +1,41 @@
+import path from 'node:path';
+import { includeIgnoreFile } from '@eslint/compat';
+import js from '@eslint/js';
+import svelte from 'eslint-plugin-svelte';
+import { defineConfig } from 'eslint/config';
+import globals from 'globals';
+import ts from 'typescript-eslint';
+import svelteConfig from './svelte.config.js';
+
+const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
+
+export default defineConfig(
+ includeIgnoreFile(gitignorePath),
+ js.configs.recommended,
+ ts.configs.recommended,
+ svelte.configs.recommended,
+ {
+ languageOptions: { globals: { ...globals.browser, ...globals.node } },
+ rules: {
+ // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
+ // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
+ "no-undef": 'off'
+ }
+ },
+ {
+ files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
+ languageOptions: {
+ parserOptions: {
+ projectService: true,
+ extraFileExtensions: ['.svelte'],
+ parser: ts.parser,
+ svelteConfig
+ }
+ }
+ },
+ {
+ // Override or add rule settings here, such as:
+ // 'svelte/button-has-type': 'error'
+ rules: {}
+ }
+);
diff --git a/simopt-web/index.html b/simopt-web/index.html
new file mode 100644
index 00000000..696f03ea
--- /dev/null
+++ b/simopt-web/index.html
@@ -0,0 +1,13 @@
+
+
+
Add at least one solver and one problem to see compatibility.
+ {/if}
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+ {#if showConfirm}
+
+
+
Replace current editor?
+
You already have a {confirmKind === 'solver' ? 'solver' : confirmKind === 'problem' ? 'problem' : 'plot'} open in the editor. If you continue, the current selection and any unsaved parameter changes will be replaced.
+
+
+
+
+
+
+ {/if}
+
+ {#if showCompatModal}
+
+
+
Solver–Problem Compatibility
+
+
+
+
S \ P
+ {#each summaryProblems as p (p.name)}
+
{abbrev(p.name)}
+ {/each}
+
+
+
+ {#each summarySolvers as s (s.name)}
+
+
{abbrev(s.name)}
+ {#each summaryProblems as p (p.name)}
+
+ {/each}
+
+ {/each}
+
+
+
+
+
+
+
+ {/if}
+ {/if}
+
+
+
diff --git a/simopt-web/src/app.css b/simopt-web/src/app.css
new file mode 100644
index 00000000..ed5c354b
--- /dev/null
+++ b/simopt-web/src/app.css
@@ -0,0 +1,68 @@
+:root {
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: #213547;
+ background-color: #ffffff;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+ font-weight: 500;
+ color: #2563eb; /* blue link */
+ text-decoration: none;
+}
+a:hover {
+ color: #1d4ed8;
+}
+
+body {
+ margin: 0;
+ display: block; /* no flex centering */
+ min-width: 320px;
+ min-height: 100vh;
+ background-color: #ffffff;
+}
+
+h1 {
+ font-size: 2em;
+ line-height: 1.2;
+}
+
+#app {
+ margin: 0; /* no auto centering */
+ padding: 0; /* some spacing around content */
+ text-align: left; /* left-align all content */
+ max-width: none; /* allow full width */
+}
+
+button {
+ border-radius: 6px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #2563eb; /* blue buttons */
+ color: #fff;
+ cursor: pointer;
+ transition:
+ background-color 0.25s,
+ border-color 0.25s;
+}
+
+button:hover {
+ background-color: #1d4ed8;
+}
+
+button:focus,
+button:focus-visible {
+ outline: 3px solid #93c5fd;
+ outline-offset: 2px;
+}
diff --git a/simopt-web/src/assets/hero.png b/simopt-web/src/assets/hero.png
new file mode 100644
index 00000000..02251f4b
Binary files /dev/null and b/simopt-web/src/assets/hero.png differ
diff --git a/simopt-web/src/assets/svelte.svg b/simopt-web/src/assets/svelte.svg
new file mode 100644
index 00000000..c5e08481
--- /dev/null
+++ b/simopt-web/src/assets/svelte.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/simopt-web/src/assets/vite.svg b/simopt-web/src/assets/vite.svg
new file mode 100644
index 00000000..5101b674
--- /dev/null
+++ b/simopt-web/src/assets/vite.svg
@@ -0,0 +1 @@
+
diff --git a/simopt-web/src/main.ts b/simopt-web/src/main.ts
new file mode 100644
index 00000000..d47b9308
--- /dev/null
+++ b/simopt-web/src/main.ts
@@ -0,0 +1,9 @@
+import { mount } from "svelte";
+import "./app.css";
+import App from "./App.svelte";
+
+const app = mount(App, {
+ target: document.getElementById("app")!,
+});
+
+export default app;
diff --git a/simopt-web/src/types.ts b/simopt-web/src/types.ts
new file mode 100644
index 00000000..789a3442
--- /dev/null
+++ b/simopt-web/src/types.ts
@@ -0,0 +1,40 @@
+export type Page = "Simulator" | "User Guide" | "About Us";
+export type SummaryKind = "solver" | "problem" | "plot";
+export type EditMode = { kind: SummaryKind; index: number };
+
+export type Param = {
+ name: string;
+ description: string;
+ default: unknown;
+ value: string;
+};
+
+export type SummaryEntry = {
+ name: string;
+ params: Param[];
+ expanded: boolean;
+};
+
+export type PlotSummaryEntry = SummaryEntry & {
+ solvers: string[];
+ problems: string[];
+};
+
+export type SchemaParam = {
+ name: string;
+ label: string;
+ type: "bool" | "int" | "float" | "text" | string;
+ default: string | number | boolean | null;
+};
+
+export type FixedSchema = { params: SchemaParam[] };
+export type FormValue = string | number | boolean | null | undefined;
+export type FormValues = Record;
+
+export type CompatibilityCell = {
+ compatible: boolean;
+ message?: string;
+};
+
+export type Compatibility = Record>;
+export type ParamsResponse = { parameters?: Array> };
diff --git a/simopt-web/svelte.config.js b/simopt-web/svelte.config.js
new file mode 100644
index 00000000..ed459535
--- /dev/null
+++ b/simopt-web/svelte.config.js
@@ -0,0 +1,2 @@
+/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
+export default {};
diff --git a/simopt-web/tsconfig.app.json b/simopt-web/tsconfig.app.json
new file mode 100644
index 00000000..d774b201
--- /dev/null
+++ b/simopt-web/tsconfig.app.json
@@ -0,0 +1,20 @@
+{
+ "extends": "@tsconfig/svelte/tsconfig.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2023",
+ "module": "esnext",
+ "types": ["svelte", "vite/client"],
+ "noEmit": true,
+ /**
+ * Typecheck JS in `.svelte` and `.js` files by default.
+ * Disable checkJs if you'd like to use dynamic types in JS.
+ * Note that setting allowJs false does not prevent the use
+ * of JS in `.svelte` files.
+ */
+ "allowJs": true,
+ "checkJs": true,
+ "moduleDetection": "force"
+ },
+ "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
+}
diff --git a/simopt-web/tsconfig.json b/simopt-web/tsconfig.json
new file mode 100644
index 00000000..d32ff682
--- /dev/null
+++ b/simopt-web/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "files": [],
+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
+}
diff --git a/simopt-web/tsconfig.node.json b/simopt-web/tsconfig.node.json
new file mode 100644
index 00000000..d3c52ea6
--- /dev/null
+++ b/simopt-web/tsconfig.node.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023"],
+ "module": "esnext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/simopt-web/vite.config.ts b/simopt-web/vite.config.ts
new file mode 100644
index 00000000..b6c39d72
--- /dev/null
+++ b/simopt-web/vite.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from "vite";
+import { svelte } from "@sveltejs/vite-plugin-svelte";
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [svelte()],
+ build: {
+ outDir: "../simopt/web/dist",
+ emptyOutDir: true,
+ },
+});
diff --git a/simopt/cli.py b/simopt/cli.py
new file mode 100644
index 00000000..6b467972
--- /dev/null
+++ b/simopt/cli.py
@@ -0,0 +1,59 @@
+"""Command-line interface for SimOpt."""
+
+import click
+
+
+@click.group(context_settings={"help_option_names": ["-h", "--help"]})
+def main() -> None:
+ """Run SimOpt command-line tools."""
+
+
+@main.command()
+@click.option(
+ "--host",
+ default="127.0.0.1",
+ show_default=True,
+ help="Interface to bind the web server to.",
+)
+@click.option(
+ "--port",
+ default=8000,
+ show_default=True,
+ type=click.IntRange(min=1, max=65535),
+ help="Port to bind the web server to.",
+)
+@click.option(
+ "--reload/--no-reload",
+ "reload_enabled",
+ default=True,
+ show_default=True,
+ help="Restart the server when source files change.",
+)
+@click.option(
+ "--log-level",
+ default="info",
+ show_default=True,
+ type=click.Choice(
+ ["critical", "error", "warning", "info", "debug", "trace"],
+ case_sensitive=False,
+ ),
+ help="Uvicorn log verbosity.",
+)
+def web(host: str, port: int, reload_enabled: bool, log_level: str) -> None:
+ """Start the SimOpt FastAPI web interface."""
+ import uvicorn
+
+ click.echo("Starting SimOpt Web Interface...")
+ click.echo(f"SimOpt is running at http://{host}:{port}")
+ click.echo("Press Ctrl+C to stop.")
+ uvicorn.run(
+ "simopt.web.server:app",
+ host=host,
+ port=port,
+ reload=reload_enabled,
+ log_level=log_level,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/simopt/test.py b/simopt/test.py
new file mode 100644
index 00000000..58ebe35c
--- /dev/null
+++ b/simopt/test.py
@@ -0,0 +1,150 @@
+"""Browser smoke script for the SimOpt web UI."""
+
+# ruff: noqa: RUF001
+
+from importlib import import_module
+from typing import Any
+
+
+def run(playwright: Any) -> None: # noqa: ANN401
+ """Run recorded browser interactions against the local web UI."""
+ browser = playwright.chromium.launch(headless=False)
+ context = browser.new_context()
+ page = context.new_page()
+
+ # Test 1: Check all plot functionality
+ page.goto("http://localhost:5173/")
+ page.get_by_role("combobox").first.select_option("ADAM")
+ page.get_by_role("button", name="+ Add Solver").click()
+ page.get_by_role("combobox").nth(2).select_option(
+ "CNTNEWS-1 (Max Profit for Continuous Newsvendor)"
+ )
+ page.get_by_role("button", name="+ Add Problem").click()
+ page.get_by_role("combobox").nth(2).select_option(
+ "SAN-1 (Min Mean Longest Path for Stochastic Activity Network)"
+ )
+ page.get_by_role("button", name="+ Add Problem").click()
+ page.get_by_role("combobox").nth(1).select_option("ALL")
+ page.get_by_role("button", name="+ Add Plot").click()
+ page.get_by_role("combobox").nth(1).select_option("MEAN")
+ page.get_by_role("button", name="+ Add Plot").click()
+ page.get_by_role("combobox").nth(1).select_option("QUANTILE")
+ page.get_by_role("button", name="+ Add Plot").click()
+ page.get_by_role("combobox").nth(1).select_option("AREA_MEAN")
+ page.get_by_role("button", name="+ Add Plot").click()
+ page.get_by_role("combobox").nth(1).select_option("AREA_STD_DEV")
+ page.get_by_role("button", name="+ Add Plot").click()
+ page.get_by_role("combobox").nth(1).select_option("SOLVE_TIME_QUANTILE")
+ page.get_by_role("button", name="+ Add Plot").click()
+ page.get_by_role("combobox").nth(1).select_option("SOLVE_TIME_CDF")
+ page.get_by_role("button", name="+ Add Plot").click()
+ page.get_by_role("combobox").nth(1).select_option("CDF_SOLVABILITY")
+ page.get_by_role("button", name="+ Add Plot").click()
+ page.get_by_role("combobox").nth(1).select_option("QUANTILE_SOLVABILITY")
+ page.get_by_role("button", name="+ Add Plot").click()
+ page.get_by_role("combobox").nth(1).select_option("AREA")
+ page.get_by_role("button", name="+ Add Plot").click()
+ page.get_by_role("combobox").nth(1).select_option("BOX")
+ page.get_by_role("button", name="+ Add Plot").click()
+ page.get_by_role("combobox").nth(1).select_option("VIOLIN")
+ page.get_by_role("button", name="+ Add Plot").click()
+ page.get_by_role("combobox").nth(1).select_option("TERMINAL_SCATTER")
+ page.get_by_role("button", name="+ Add Plot").click()
+ with page.expect_popup() as page1_info:
+ page.get_by_role("button", name="Run Experiment").click()
+ page1 = page1_info.value
+ page1.goto("http://localhost:5173/results/20260412_182438/index.html")
+
+ # Test 2: Edit a solver
+ page.goto("http://localhost:5173/")
+ page.get_by_role("combobox").first.select_option("ADAM")
+ page.get_by_role("button", name="+ Add Solver").click()
+ page.get_by_role("button", name="ADAM ▶ ×").click()
+ page.get_by_role("button", name="Edit").click()
+ page.get_by_role("textbox", name="r", exact=True).click()
+ page.get_by_role("textbox", name="r", exact=True).press("ArrowRight")
+ page.get_by_role("textbox", name="r", exact=True).press("ArrowRight")
+ page.get_by_role("textbox", name="r", exact=True).fill("35")
+ page.get_by_role("button", name="Apply Changes").click()
+ page.get_by_role("button", name="ADAM ▶ ×").click()
+
+ # Test 3: Replace a solver in edit panel with one from summary panel
+ page.goto("http://localhost:5173/")
+ page.get_by_role("combobox").first.select_option("ASTRODF (ASTRO-DF)")
+ page.get_by_role("button", name="+ Add Solver").click()
+ page.get_by_role("combobox").first.select_option("RNDSRCH (Random Search)")
+ page.get_by_role("button", name="ASTRODF (ASTRO-DF) ▶ ×").click()
+ page.get_by_role("button", name="Edit").click()
+ page.get_by_role("button", name="Replace").click()
+
+ # Test 4: Cancel replacement of solver in edit panel with one from summary panel
+ page.goto("http://localhost:5173/")
+ page.get_by_role("combobox").first.select_option("ALOE")
+ page.get_by_role("button", name="+ Add Solver").click()
+ page.get_by_role("combobox").first.select_option("ASTRODF (ASTRO-DF)")
+ page.get_by_role("button", name="ALOE ▶ ×").click()
+ page.get_by_role("button", name="Edit").click()
+ page.get_by_role("button", name="Cancel").click()
+
+ # Test 5: Partial plot (run plot for only select problems)
+ page.goto("http://localhost:5173/")
+ page.get_by_role("combobox").first.select_option("ADAM")
+ page.get_by_role("button", name="+ Add Solver").click()
+ page.get_by_role("combobox").nth(2).select_option(
+ "SAN-1 (Min Mean Longest Path for Stochastic Activity Network)"
+ )
+ page.get_by_role("button", name="+ Add Problem").click()
+ page.get_by_role("combobox").nth(2).select_option(
+ "CNTNEWS-1 (Max Profit for Continuous Newsvendor)"
+ )
+ page.get_by_role("button", name="+ Add Problem").click()
+ page.get_by_role("combobox").nth(1).select_option("MEAN")
+ page.get_by_role("checkbox", name="CNTNEWS-1 (Max Profit for").check()
+ page.get_by_role("button", name="+ Add Plot").click()
+ with page.expect_popup() as page2_info:
+ page.get_by_role("button", name="Run Experiment").click()
+ _ = page2_info.value
+
+ # Test 6: Check output log functionality
+ page.goto("http://localhost:5173/")
+ page.get_by_role("combobox").first.select_option("ADAM")
+ page.get_by_role("button", name="+ Add Solver").click()
+ page.get_by_role("combobox").nth(2).select_option(
+ "SAN-1 (Min Mean Longest Path for Stochastic Activity Network)"
+ )
+ page.get_by_role("button", name="+ Add Problem").click()
+ page.get_by_role("combobox").nth(1).select_option("MEAN")
+ page.get_by_role("button", name="+ Add Plot").click()
+ with page.expect_popup() as page3_info:
+ page.get_by_role("button", name="Run Experiment").click()
+ page3 = page3_info.value
+ page3.get_by_text("Output Log ▼ Auto-scroll: ON").click()
+
+ # Test 7: Check that experiment does not rerun when only plot is added
+ page.goto("http://localhost:5173/")
+ page.get_by_role("combobox").first.select_option("ADAM")
+ page.get_by_role("button", name="+ Add Solver").click()
+ page.get_by_role("combobox").nth(2).select_option(
+ "SAN-1 (Min Mean Longest Path for Stochastic Activity Network)"
+ )
+ page.get_by_role("button", name="+ Add Problem").click()
+ page.get_by_role("combobox").nth(1).select_option("MEAN")
+ page.get_by_role("button", name="+ Add Plot").click()
+ with page.expect_popup() as page5_info:
+ page.get_by_role("button", name="Run Experiment").click()
+ page5 = page5_info.value
+ page5.goto("http://localhost:5173/results/20260412_182820/index.html")
+ page.get_by_role("combobox").nth(1).select_option("BOX")
+ page.get_by_role("button", name="+ Add Plot").click()
+ with page.expect_popup() as page6_info:
+ page.get_by_role("button", name="Run Experiment").click()
+ _ = page6_info.value
+
+ # ---------------------
+ context.close()
+ browser.close()
+
+
+sync_api = import_module("playwright.sync_api")
+with sync_api.sync_playwright() as playwright:
+ run(playwright)
diff --git a/simopt/web/__init__.py b/simopt/web/__init__.py
new file mode 100644
index 00000000..e8ddc6f3
--- /dev/null
+++ b/simopt/web/__init__.py
@@ -0,0 +1 @@
+"""Web UI support for SimOpt."""
diff --git a/simopt/web/plots.py b/simopt/web/plots.py
new file mode 100644
index 00000000..1ea1ce84
--- /dev/null
+++ b/simopt/web/plots.py
@@ -0,0 +1,572 @@
+"""Plot configuration models for the SimOpt web API."""
+
+from typing import Annotated
+
+from pydantic import BaseModel, Field
+
+from simopt.experiment_base import PlotType
+
+
+class PlotProgressCurvesConfig(BaseModel):
+ """Options for experiment_base.plot_progress_curves (excluding `experiments`)."""
+
+ plot_type: Annotated[
+ PlotType,
+ Field(
+ default=PlotType.ALL,
+ description="Type of plot to produce (ALL, MEAN, or QUANTILE).",
+ ),
+ ]
+
+ beta: Annotated[
+ float,
+ Field(
+ default=0.50,
+ description=("Quantile level to plot (0 < beta < 1). Used for QUANTILE plots."),
+ gt=0.0,
+ lt=1.0,
+ ),
+ ]
+
+ normalize: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, normalize curves by optimality gaps.",
+ ),
+ ]
+
+ all_in_one: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, plot all curves in one figure.",
+ ),
+ ]
+
+ n_bootstraps: Annotated[
+ int,
+ Field(
+ default=100,
+ description="Number of bootstrap samples.",
+ ge=1,
+ ),
+ ]
+
+ conf_level: Annotated[
+ float,
+ Field(
+ default=0.95,
+ description="Confidence level for CIs (0 < conf_level < 1).",
+ gt=0.0,
+ lt=1.0,
+ ),
+ ]
+
+ plot_conf_ints: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, plot bootstrapped confidence intervals.",
+ ),
+ ]
+
+ print_max_hw: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, print caption with max half-width.",
+ ),
+ ]
+
+ plot_title: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Custom plot title (used only if all_in_one=True).",
+ ),
+ ]
+
+ legend_loc: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description='Legend location (e.g., "best", "lower right").',
+ ),
+ ]
+
+ ext: Annotated[
+ str,
+ Field(
+ default=".png",
+ description='File extension for saved plots (e.g., ".png").',
+ ),
+ ]
+
+ save_as_pickle: Annotated[
+ bool,
+ Field(
+ default=False,
+ description="If True, also save a pickle of the plot.",
+ ),
+ ]
+
+ solver_set_name: Annotated[
+ str,
+ Field(
+ default="SOLVER_SET",
+ description="Label for solver group in plot titles.",
+ min_length=1,
+ ),
+ ]
+
+
+class PlotSolvabilityCDFConfig(BaseModel):
+ """Options for experiment_base.plot_progress_curves (excluding `experiments`)."""
+
+ solve_tol: Annotated[
+ float,
+ Field(
+ default=0.1,
+ description=("Optimality gap that defines when a problem is considered solved"),
+ gt=0.0,
+ le=1.0,
+ ),
+ ]
+
+ all_in_one: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, plot all curves in one figure.",
+ ),
+ ]
+
+ n_bootstraps: Annotated[
+ int,
+ Field(
+ default=100,
+ description="Number of bootstrap samples.",
+ ge=1,
+ ),
+ ]
+
+ conf_level: Annotated[
+ float,
+ Field(
+ default=0.95,
+ description="Confidence level for CIs (0 < conf_level < 1).",
+ gt=0.0,
+ lt=1.0,
+ ),
+ ]
+
+ plot_conf_ints: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, plot bootstrapped confidence intervals.",
+ ),
+ ]
+
+ print_max_hw: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, print caption with max half-width.",
+ ),
+ ]
+
+ plot_title: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Custom plot title (used only if all_in_one=True).",
+ ),
+ ]
+
+ legend_loc: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description='Legend location (e.g., "best", "lower right").',
+ ),
+ ]
+
+ ext: Annotated[
+ str,
+ Field(
+ default=".png",
+ description='File extension for saved plots (e.g., ".png").',
+ ),
+ ]
+
+ save_as_pickle: Annotated[
+ bool,
+ Field(
+ default=False,
+ description="If True, also save a pickle of the plot.",
+ ),
+ ]
+
+ solver_set_name: Annotated[
+ str,
+ Field(
+ default="SOLVER_SET",
+ description="Label for solver group in plot titles.",
+ min_length=1,
+ ),
+ ]
+
+
+class PlotAreaScatterplotsConfig(BaseModel):
+ """Options for experiment_base.plot_area_scatterplots (excluding `experiments`)."""
+
+ all_in_one: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, plot all curves in one figure.",
+ ),
+ ]
+
+ n_bootstraps: Annotated[
+ int,
+ Field(
+ default=100,
+ description="Number of bootstrap samples.",
+ ge=1,
+ ),
+ ]
+
+ conf_level: Annotated[
+ float,
+ Field(
+ default=0.95,
+ description="Confidence level for CIs (0 < conf_level < 1).",
+ gt=0.0,
+ lt=1.0,
+ ),
+ ]
+
+ plot_conf_ints: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, plot bootstrapped confidence intervals.",
+ ),
+ ]
+
+ print_max_hw: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, print caption with max half-width.",
+ ),
+ ]
+
+ plot_title: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Custom plot title (used only if all_in_one=True).",
+ ),
+ ]
+
+ legend_loc: Annotated[
+ str,
+ Field(
+ default="best",
+ description='Legend location (e.g., "best", "lower right").',
+ ),
+ ]
+
+ ext: Annotated[
+ str,
+ Field(
+ default=".png",
+ description='File extension for saved plots (e.g., ".png").',
+ ),
+ ]
+
+ save_as_pickle: Annotated[
+ bool,
+ Field(
+ default=False,
+ description="If True, also save a pickle of the plot.",
+ ),
+ ]
+
+ solver_set_name: Annotated[
+ str,
+ Field(
+ default="SOLVER_SET",
+ description="Label for solver group in plot titles.",
+ min_length=1,
+ ),
+ ]
+
+ problem_set_name: Annotated[
+ str,
+ Field(
+ default="PROBLEM_SET",
+ description="Label for problem group in plot titles.",
+ min_length=1,
+ ),
+ ]
+
+
+class PlotSolvabilityProfilesConfig(BaseModel):
+ """Options for experiment_base.plot_solvability_profiles."""
+
+ all_in_one: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, plot all curves in one figure.",
+ ),
+ ]
+
+ n_bootstraps: Annotated[
+ int,
+ Field(
+ default=100,
+ description="Number of bootstrap samples.",
+ ge=1,
+ ),
+ ]
+
+ conf_level: Annotated[
+ float,
+ Field(
+ default=0.95,
+ description="Confidence level for CIs (0 < conf_level < 1).",
+ gt=0.0,
+ lt=1.0,
+ ),
+ ]
+
+ plot_conf_ints: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, plot bootstrapped confidence intervals.",
+ ),
+ ]
+
+ print_max_hw: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, print caption with max half-width.",
+ ),
+ ]
+
+ solve_tol: Annotated[
+ float,
+ Field(
+ default=0.1,
+ description=("Optimality gap that defines when a problem is considered solved"),
+ gt=0.0,
+ le=1.0,
+ ),
+ ]
+
+ beta: Annotated[
+ float,
+ Field(
+ default=0.5,
+ description="Quantile level for quantile solvability (0 < beta < 1).",
+ gt=0.0,
+ lt=1.0,
+ ),
+ ]
+
+ ref_solver: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Reference solver for difference plots.",
+ ),
+ ]
+
+ plot_title: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Custom plot title (used only if all_in_one=True).",
+ ),
+ ]
+
+ legend_loc: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description='Legend location (e.g., "best", "lower right").',
+ ),
+ ]
+
+ ext: Annotated[
+ str,
+ Field(
+ default=".png",
+ description='File extension for saved plots (e.g., ".png").',
+ ),
+ ]
+
+ save_as_pickle: Annotated[
+ bool,
+ Field(
+ default=False,
+ description="If True, also save a pickle of the plot.",
+ ),
+ ]
+
+ solver_set_name: Annotated[
+ str,
+ Field(
+ default="SOLVER_SET",
+ description="Label for solver group in plot titles.",
+ min_length=1,
+ ),
+ ]
+
+ problem_set_name: Annotated[
+ str,
+ Field(
+ default="PROBLEM_SET",
+ description="Label for problem group in plot titles.",
+ min_length=1,
+ ),
+ ]
+
+
+class PlotTerminalProgressCurvesConfig(BaseModel):
+ """Options for experiment_base.plot_progress_curves (excluding `experiments`)."""
+
+ plot_type: Annotated[
+ PlotType,
+ Field(
+ default=PlotType.VIOLIN,
+ description="Type of plot to produce.",
+ ),
+ ]
+
+ normalize: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, normalize curves by optimality gaps.",
+ ),
+ ]
+
+ all_in_one: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, plot all curves in one figure.",
+ ),
+ ]
+
+ plot_title: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Custom plot title (used only if all_in_one=True).",
+ ),
+ ]
+
+ ext: Annotated[
+ str,
+ Field(
+ default=".png",
+ description='File extension for saved plots (e.g., ".png").',
+ ),
+ ]
+
+ save_as_pickle: Annotated[
+ bool,
+ Field(
+ default=False,
+ description="If True, also save a pickle of the plot.",
+ ),
+ ]
+
+ solver_set_name: Annotated[
+ str,
+ Field(
+ default="SOLVER_SET",
+ description="Label for solver group in plot titles.",
+ min_length=1,
+ ),
+ ]
+
+
+class PlotTerminalScatterplotsConfig(BaseModel):
+ """Options for experiment_base.plot_progress_curves (excluding `experiments`)."""
+
+ plot_type: Annotated[
+ PlotType,
+ Field(
+ default=PlotType.TERMINAL_SCATTER,
+ description="Type of plot to produce (TERMINAL_SCATTER).",
+ ),
+ ]
+
+ all_in_one: Annotated[
+ bool,
+ Field(
+ default=True,
+ description="If True, plot all curves in one figure.",
+ ),
+ ]
+
+ plot_title: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Custom plot title (used only if all_in_one=True).",
+ ),
+ ]
+
+ legend_loc: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description='Legend location (e.g., "best", "lower right").',
+ ),
+ ]
+
+ ext: Annotated[
+ str,
+ Field(
+ default=".png",
+ description='File extension for saved plots (e.g., ".png").',
+ ),
+ ]
+
+ save_as_pickle: Annotated[
+ bool,
+ Field(
+ default=False,
+ description="If True, also save a pickle of the plot.",
+ ),
+ ]
+
+ solver_set_name: Annotated[
+ str,
+ Field(
+ default="SOLVER_SET",
+ description="Label for solver group in plot titles.",
+ min_length=1,
+ ),
+ ]
+
+ problem_set_name: Annotated[
+ str,
+ Field(
+ default="PROBLEM_SET",
+ description="Label for problem group in plot titles.",
+ min_length=1,
+ ),
+ ]
diff --git a/simopt/web/server.py b/simopt/web/server.py
new file mode 100644
index 00000000..b52720d3
--- /dev/null
+++ b/simopt/web/server.py
@@ -0,0 +1,1704 @@
+"""FastAPI server for the SimOpt web interface."""
+
+# ruff: noqa: ANN001, ANN201, ANN202, D101, D103, E501
+
+import threading
+
+import matplotlib
+
+matplotlib.use("Agg") # Non-interactive backend
+import inspect
+from pathlib import Path
+from typing import Annotated, Any
+
+import matplotlib.pyplot as plt
+from fastapi import Body, FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse, Response
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel
+
+from simopt import experiment_base as eb
+from simopt.directory import (
+ problem_directory,
+ solver_directory,
+)
+from simopt.experiment_base import ProblemsSolvers
+from simopt.web.plots import (
+ PlotAreaScatterplotsConfig,
+ PlotProgressCurvesConfig,
+ PlotSolvabilityCDFConfig,
+ PlotSolvabilityProfilesConfig,
+ PlotTerminalProgressCurvesConfig,
+ PlotTerminalScatterplotsConfig,
+)
+
+
+# ── Pydantic request models ──
+# These define the expected shape of incoming JSON payloads for each endpoint.
+class ProblemRequest(BaseModel):
+ name: str
+ rename: str | None = None
+ fixed_factors: dict[str, Any]
+ model_fixed_factors: dict[str, Any] = {}
+
+
+class SolverRequest(BaseModel):
+ name: str
+ rename: str | None = None
+ fixed_factors: dict[str, Any]
+
+
+class PlotRequest(BaseModel):
+ plot_type: str
+ params: dict[str, Any] = {}
+
+
+class ExperimentParams(BaseModel):
+ num_macroreps: int
+ num_postreps: int
+ num_postnorms: int
+
+
+class ExperimentRequest(BaseModel):
+ experiment_params: ExperimentParams
+ problems: list[ProblemRequest]
+ solvers: list[SolverRequest]
+ plots: list[PlotRequest]
+
+
+# ── Path configuration ──
+# WEB_DIR points to simopt/web; the built frontend is always served from there.
+# BASE_DIR points to the repository root for existing result-file storage.
+WEB_DIR = Path(__file__).resolve().parent
+BASE_DIR = WEB_DIR.parent.parent
+RESULTS_DIR = BASE_DIR / "simopt-web" / "results"
+DIST_DIR = WEB_DIR / "dist"
+STATIC_DIR = DIST_DIR / "assets"
+
+# ── FastAPI app setup ──
+app = FastAPI(title="SimOpt API")
+
+# Allow all origins so the frontend (served on the same server) can make API calls.
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+POST_REPLICATE_DEFAULTS = getattr(eb, "POST_REPLICATE_DEFAULTS", {})
+POST_NORMALIZE_DEFAULTS = getattr(eb, "POST_NORMALIZE_DEFAULTS", {})
+
+
+# ── Static file serving ──
+@app.get("/")
+def serve_frontend():
+ """Serve the main frontend HTML page."""
+ return FileResponse(str(DIST_DIR / "index.html"))
+
+
+@app.get("/results/{run_id}/experiment.log")
+def serve_log(run_id: str):
+ """Serve experiment log file for a given run.
+
+ Reads the file fresh on each request to avoid Content-Length mismatches
+ that occur when the static file handler caches file size at request start
+ while the background thread is still writing to the file.
+ """
+ path = RESULTS_DIR / run_id / "experiment.log"
+ if not path.exists():
+ return Response(content="", media_type="text/plain")
+ try:
+ with path.open(encoding="utf-8", errors="replace") as f:
+ content = f.read()
+ return Response(content=content, media_type="text/plain")
+ except Exception:
+ return Response(content="", media_type="text/plain")
+
+
+@app.get("/results/{run_id}/index.html")
+def serve_result(run_id: str):
+ """Serve the results page for a given run.
+
+ Same rationale as serve_log — reads fresh each time to avoid
+ Content-Length errors while update_status() is still rewriting the file.
+ """
+ path = RESULTS_DIR / run_id / "index.html"
+ if not path.exists():
+ return Response(content="", media_type="text/html")
+ content = path.read_text(encoding="utf-8", errors="replace")
+ return Response(content=content, media_type="text/html")
+
+
+# Mount static directories. Routes defined above take priority over these mounts
+# because FastAPI processes explicit routes before static mounts.
+RESULTS_DIR.mkdir(exist_ok=True)
+app.mount("/results", StaticFiles(directory=str(RESULTS_DIR)), name="results")
+app.mount("/assets", StaticFiles(directory=str(STATIC_DIR)), name="static")
+
+
+# ── Name mappings ──
+def create_name_mappings():
+ """Create bidirectional mappings between abbreviated and full names."""
+ solver_abbr_to_full = {}
+ solver_full_to_abbr = {}
+
+ for abbr_name in solver_directory:
+ solver_cls = solver_directory[abbr_name]
+ full_name = getattr(solver_cls, "class_name", abbr_name)
+ display_name = f"{abbr_name} ({full_name})" if full_name != abbr_name else abbr_name
+ solver_abbr_to_full[abbr_name] = display_name
+ solver_full_to_abbr[display_name] = abbr_name
+
+ problem_abbr_to_full = {}
+ problem_full_to_abbr = {}
+
+ for abbr_name in problem_directory:
+ problem_cls = problem_directory[abbr_name]
+ full_name = getattr(problem_cls, "class_name", abbr_name)
+ display_name = f"{abbr_name} ({full_name})" if full_name != abbr_name else abbr_name
+ problem_abbr_to_full[abbr_name] = display_name
+ problem_full_to_abbr[display_name] = abbr_name
+
+ return (
+ solver_abbr_to_full,
+ solver_full_to_abbr,
+ problem_abbr_to_full,
+ problem_full_to_abbr,
+ )
+
+
+SOLVER_ABBR_TO_FULL, SOLVER_FULL_TO_ABBR, PROBLEM_ABBR_TO_FULL, PROBLEM_FULL_TO_ABBR = (
+ create_name_mappings()
+)
+
+
+# ── Schema endpoints ──
+@app.get("/postreplicate_schema")
+def postreplicate_schema() -> dict[str, Any]:
+ """Returns a simple schema for the post-replicate form."""
+ d = {
+ "num_post_reps": 100,
+ "crn_diff_times": True,
+ "crn_diff_macroreps": True,
+ }
+ d.update(POST_REPLICATE_DEFAULTS or {})
+ return {
+ "params": [
+ {
+ "name": "num_post_reps",
+ "label": "Number of post-replications",
+ "type": "int",
+ "default": d["num_post_reps"],
+ },
+ {
+ "name": "crn_diff_times",
+ "label": "Use CRN on post-replications for solutions recommended at different times?",
+ "type": "bool",
+ "default": d["crn_diff_times"],
+ },
+ {
+ "name": "crn_diff_macroreps",
+ "label": "Use CRN on post-replications for solutions recommended on different macro-replications?",
+ "type": "bool",
+ "default": d["crn_diff_macroreps"],
+ },
+ ]
+ }
+
+
+@app.get("/postnormalize_schema")
+def postnormalize_schema() -> dict[str, Any]:
+ """Returns a simple schema for the post-normalize form."""
+ d = {
+ "num_post_reps_init_opt": 100,
+ "crn_init_opt": True,
+ }
+ d.update(POST_NORMALIZE_DEFAULTS or {})
+ return {
+ "params": [
+ {
+ "name": "num_post_reps_init_opt",
+ "label": "Number of post-replications at initial and optimal solutions",
+ "type": "int",
+ "default": d["num_post_reps_init_opt"],
+ },
+ {
+ "name": "crn_init_opt",
+ "label": "Use CRN on post-replications for initial and optimal solution?",
+ "type": "bool",
+ "default": d["crn_init_opt"],
+ },
+ ]
+ }
+
+
+@app.get("/plots")
+def list_plots():
+ """Returns a flat list of plot names derived from experiment_base.PlotType."""
+ if not hasattr(eb, "PlotType"):
+ return {"plots": [], "source": "missing PlotType"}
+
+ plot_type_cls = eb.PlotType
+
+ plots = []
+ try:
+ plots = [member.name for member in plot_type_cls]
+ source = "enum"
+ except TypeError:
+ plots = [n for n in dir(plot_type_cls) if n.isupper()]
+ source = "class-attrs"
+
+ return {"plots": plots, "source": source}
+
+
+def extract_params_from_config(config_cls):
+ """Extract parameter info (name, default, description) from a Pydantic BaseModel config."""
+ params = []
+ if config_cls and hasattr(config_cls, "model_fields"):
+ for name, field in config_cls.model_fields.items():
+ default = None
+ if field.default_factory is not None:
+ try:
+ default = field.default_factory()
+ except Exception:
+ default = ""
+ else:
+ default = field.default
+
+ params.append(
+ {
+ "name": name,
+ "default": default,
+ "description": field.description or "",
+ }
+ )
+ return params
+
+
+# ── Solver and problem info endpoints ─
+@app.get("/solvers")
+def get_solvers():
+ """Return all available solvers with display names."""
+ return {"solvers": list(SOLVER_ABBR_TO_FULL.values())}
+
+
+@app.get("/problems")
+def get_problems():
+ """Return all available problems with display names."""
+ return {"problems": list(PROBLEM_ABBR_TO_FULL.values())}
+
+
+@app.get("/solver_params/{solver_name}")
+def get_solver_params(solver_name: str):
+ """Return parameters for a solver (accepts display name)."""
+ # Convert display name to abbreviated name
+ abbr_name = SOLVER_FULL_TO_ABBR.get(solver_name, solver_name)
+ solver_cls = solver_directory.get(abbr_name)
+ if solver_cls is None:
+ return {"parameters": []}
+
+ params = []
+ config_cls = getattr(solver_cls, "config_class", None)
+ params += extract_params_from_config(config_cls)
+
+ return {"parameters": params}
+
+
+@app.get("/problem_params/{problem_name}")
+def get_problem_params(problem_name: str):
+ """Return parameters for both the problem and its model config (accepts display name)."""
+ abbr_name = PROBLEM_FULL_TO_ABBR.get(problem_name, problem_name)
+ problem_cls = problem_directory.get(abbr_name)
+ if problem_cls is None:
+ return {"parameters": []}
+
+ params = []
+ config_cls = getattr(problem_cls, "config_class", None)
+ params += extract_params_from_config(config_cls)
+
+ model_cls = getattr(problem_cls, "model_class", None)
+ if model_cls is not None:
+ model_config_cls = getattr(model_cls, "config_class", None)
+ params += extract_params_from_config(model_config_cls)
+ else:
+ try:
+ sig = inspect.signature(problem_cls)
+ if "model" in sig.parameters:
+ model_default = sig.parameters["model"].default
+ model_config_cls = getattr(model_default.__class__, "config_class", None)
+ params += extract_params_from_config(model_config_cls)
+ except Exception:
+ pass
+
+ return {"parameters": params}
+
+
+@app.get("/plot_params/{plot_name}")
+def get_plot_params(plot_name: str):
+ """Return parameter specs for plots that need them."""
+ name = plot_name.strip().upper()
+ if name in ["ALL", "MEAN", "QUANTILE"]:
+ return {"parameters": extract_params_from_config(PlotProgressCurvesConfig)}
+ if name in ["VIOLIN", "BOX"]:
+ return {"parameters": extract_params_from_config(PlotTerminalProgressCurvesConfig)}
+ if name in [
+ "CDF_SOLVABILITY",
+ "QUANTILE_SOLVABILITY",
+ "DIFFERENCE_OF_CDF_SOLVABILITY",
+ "DIFFERENCE_OF_QUANTILE_SOLVABILITY",
+ ]:
+ return {"parameters": extract_params_from_config(PlotSolvabilityProfilesConfig)}
+ if name in ["AREA", "AREA_MEAN", "AREA_STD_DEV"]:
+ return {"parameters": extract_params_from_config(PlotAreaScatterplotsConfig)}
+ if name == "SOLVE_TIME_CDF":
+ return {"parameters": extract_params_from_config(PlotSolvabilityCDFConfig)}
+ if name == "TERMINAL_SCATTER":
+ return {"parameters": extract_params_from_config(PlotTerminalScatterplotsConfig)}
+ return {"parameters": []}
+
+
+# ── Compatibility checking ──
+@app.post("/check_compatibility")
+def check_compatibility(payload: dict):
+ """Check compatibility between solvers and problems."""
+ solvers = payload.get("solvers", [])
+ problems = payload.get("problems", [])
+
+ compatibility = {}
+
+ for display_name in solvers:
+ abbr_name = SOLVER_FULL_TO_ABBR.get(display_name, display_name)
+ solver_cls = solver_directory.get(abbr_name)
+ if not solver_cls:
+ continue
+ solver = solver_cls()
+ compatibility[display_name] = {}
+
+ for prob_display_name in problems:
+ prob_abbr_name = PROBLEM_FULL_TO_ABBR.get(prob_display_name, prob_display_name)
+ problem_cls = problem_directory.get(prob_abbr_name)
+ if not problem_cls:
+ continue
+ problem = problem_cls()
+
+ try:
+ exp = ProblemsSolvers(solvers=[solver], problems=[problem])
+ err = exp.check_compatibility()
+ if err.strip() == "":
+ compatibility[display_name][prob_display_name] = {
+ "compatible": True,
+ "message": "",
+ }
+ else:
+ compatibility[display_name][prob_display_name] = {
+ "compatible": False,
+ "message": err,
+ }
+ except Exception as e:
+ compatibility[display_name][prob_display_name] = {
+ "compatible": False,
+ "message": str(e),
+ }
+
+ return {"compatibility": compatibility}
+
+
+# ── Rerun detection ──
+def _check_rerun_logic(payload: dict) -> bool:
+ """Returns True if experiment needs to rerun, False if only plots changed."""
+ last_run_id = payload.get("last_run_id")
+ if not last_run_id:
+ print("check_rerun: no last_run_id, needs rerun")
+ return True
+
+ config_path = RESULTS_DIR / last_run_id / "experiment_config.json"
+ experiments_path = RESULTS_DIR / last_run_id / "experiments.pkl"
+
+ if not config_path.exists() or not experiments_path.exists():
+ print(
+ f"check_rerun: missing files - config:{config_path.exists()} pkl:{experiments_path.exists()}"
+ )
+ return True
+
+ import json
+
+ with config_path.open() as f:
+ saved = json.load(f)
+
+ new_problems = [
+ {
+ "name": PROBLEM_FULL_TO_ABBR.get(p["name"], p["name"]),
+ "fixed_factors": p.get("fixed_factors", {}),
+ "model_fixed_factors": p.get("model_fixed_factors", {}),
+ }
+ for p in payload.get("problems", [])
+ ]
+ new_solvers = [
+ {
+ "name": SOLVER_FULL_TO_ABBR.get(s["name"], s["name"]),
+ "fixed_factors": s.get("fixed_factors", {}),
+ }
+ for s in payload.get("solvers", [])
+ ]
+ new_params = payload.get("experiment_params", {})
+
+ problems_match = saved["problems"] == new_problems
+ solvers_match = saved["solvers"] == new_solvers
+ params_match = saved["experiment_params"] == new_params
+
+ print(f"check_rerun: problems={problems_match}, solvers={solvers_match}, params={params_match}")
+ if not problems_match:
+ print(f" saved problems: {saved['problems']}")
+ print(f" new problems: {new_problems}")
+ if not solvers_match:
+ print(f" saved solvers: {saved['solvers']}")
+ print(f" new solvers: {new_solvers}")
+ if not params_match:
+ print(f" saved params: {saved['experiment_params']}")
+ print(f" new params: {new_params}")
+
+ return not (problems_match and solvers_match and params_match)
+
+
+# ── Plot generation ──
+def generate_plots(
+ plots_config,
+ all_experiments,
+ needed_solver_indices,
+ needed_problem_indices,
+ solver_idx_map,
+ problem_idx_map,
+ solvers_config,
+ problems_config,
+ folder,
+):
+ """Shared plot generation logic used by both run_experiment_async and run_plots_only."""
+ from simopt.experiment_base import (
+ plot_area_scatterplots,
+ plot_progress_curves,
+ plot_solvability_cdfs,
+ plot_solvability_profiles,
+ plot_terminal_progress,
+ plot_terminal_scatterplots,
+ )
+ from simopt.plot_type import PlotType
+
+ plot_files = []
+
+ for plot_cfg in plots_config:
+ plot_type_name = plot_cfg.get("plot_type", "MEAN").upper()
+ plot_params = plot_cfg.get("params", {})
+ plot_solvers = plot_cfg.get("solvers")
+ plot_problems = plot_cfg.get("problems")
+
+ # Map selected indices to experiment array positions
+ if plot_solvers:
+ plot_solver_abbrs = [SOLVER_FULL_TO_ABBR.get(s, s) for s in plot_solvers]
+ orig_solver_indices = [
+ i for i, s in enumerate(solvers_config) if s["name"] in plot_solver_abbrs
+ ]
+ solver_exp_indices = [solver_idx_map[i] for i in orig_solver_indices]
+ else:
+ solver_exp_indices = list(range(len(needed_solver_indices)))
+
+ if plot_problems:
+ plot_problem_abbrs = [PROBLEM_FULL_TO_ABBR.get(p, p) for p in plot_problems]
+ orig_problem_indices = [
+ i for i, p in enumerate(problems_config) if p["name"] in plot_problem_abbrs
+ ]
+ problem_exp_indices = [problem_idx_map[i] for i in orig_problem_indices]
+ else:
+ problem_exp_indices = list(range(len(needed_problem_indices)))
+
+ if not solver_exp_indices or not problem_exp_indices:
+ continue
+
+ if plot_type_name in ["ALL", "MEAN", "QUANTILE"]:
+ # Generate progress curves for each problem
+ for exp_prob_idx in problem_exp_indices:
+ try:
+ plt.figure(figsize=(10, 6))
+
+ all_in_one = plot_params.get("all_in_one", True)
+ normalize = plot_params.get("normalize", False)
+
+ plot_type_map = {
+ "ALL": PlotType.ALL,
+ "MEAN": PlotType.MEAN,
+ "QUANTILE": PlotType.QUANTILE,
+ }
+ plot_type_enum = plot_type_map.get(plot_type_name, PlotType.MEAN)
+
+ plot_progress_curves(
+ [
+ all_experiments[exp_prob_idx][exp_solver_idx]
+ for exp_solver_idx in solver_exp_indices
+ ],
+ plot_type=plot_type_enum,
+ all_in_one=all_in_one,
+ normalize=normalize,
+ )
+ actual_prob_idx = needed_problem_indices[exp_prob_idx]
+ filename = f"{plot_type_name.lower()}_progress_curves_problem_{actual_prob_idx + 1}.png"
+ plt.savefig(folder / filename, dpi=150, bbox_inches="tight")
+ plt.close()
+ plot_files.append(filename)
+ print(f" Saved {filename}")
+ except Exception as e:
+ print(
+ f"Error generating {plot_type_name} plot for problem {exp_prob_idx + 1}: {e}"
+ )
+ import traceback
+
+ traceback.print_exc()
+ continue
+
+ elif plot_type_name in ["VIOLIN", "BOX"]:
+ # Generate terminal progress plots (BOX or VIOLIN) for each problem
+ for exp_prob_idx in problem_exp_indices:
+ try:
+ plt.figure(figsize=(10, 6))
+
+ # Extract parameters with defaults
+ normalize = plot_params.get("normalize", True)
+ all_in_one = plot_params.get("all_in_one", True)
+
+ # Determine which PlotType to use
+ plot_type_enum = PlotType.VIOLIN if plot_type_name == "VIOLIN" else PlotType.BOX
+
+ plot_terminal_progress(
+ [
+ all_experiments[exp_prob_idx][exp_solver_idx]
+ for exp_solver_idx in solver_exp_indices
+ ],
+ plot_type=plot_type_enum,
+ normalize=normalize,
+ all_in_one=all_in_one,
+ )
+ actual_prob_idx = needed_problem_indices[exp_prob_idx]
+ filename = f"{plot_type_name.lower()}_progress_curves_problem_{actual_prob_idx + 1}.png"
+ plt.savefig(folder / filename, dpi=150, bbox_inches="tight")
+ plt.close()
+ plot_files.append(filename)
+ print(f" Saved {filename}")
+ except Exception as e:
+ print(
+ f"Error generating {plot_type_name} plot for problem {exp_prob_idx + 1}: {e}"
+ )
+ import traceback
+
+ traceback.print_exc()
+ continue
+
+ elif plot_type_name in ["AREA", "AREA_MEAN", "AREA_STD_DEV"]:
+ # Generate area scatterplots for each problem
+ if len(problem_exp_indices) < 2:
+ print(f"Warning: {plot_type_name} requires multiple problems. Skipping.")
+ continue
+ try:
+ print(f"Generating {plot_type_name} plot...")
+ plt.figure(figsize=(10, 6))
+ # Extract parameters with defaults
+ all_in_one = plot_params.get("all_in_one", True)
+ n_bootstraps = plot_params.get("n_bootstraps", 100)
+ conf_level = plot_params.get("conf_level", 0.95)
+ plot_conf_ints = plot_params.get("plot_conf_ints", True)
+ print_max_hw = plot_params.get("print_max_hw", True)
+ solver_set_name = plot_params.get("solver_set_name", "SOLVER_SET")
+ problem_set_name = plot_params.get("problem_set_name", "PROBLEM_SET")
+
+ plot_type_map = {
+ "AREA": PlotType.AREA,
+ "AREA_MEAN": PlotType.AREA_MEAN,
+ "AREA_STD_DEV": PlotType.AREA_STD_DEV,
+ }
+ plot_type_enum = plot_type_map[plot_type_name]
+
+ filtered_experiments = [
+ [
+ all_experiments[exp_prob_idx][exp_solver_idx]
+ for exp_solver_idx in solver_exp_indices
+ ]
+ for exp_prob_idx in problem_exp_indices
+ ]
+
+ plot_area_scatterplots(
+ filtered_experiments,
+ all_in_one=all_in_one,
+ n_bootstraps=n_bootstraps,
+ conf_level=conf_level,
+ plot_conf_ints=plot_conf_ints,
+ print_max_hw=print_max_hw,
+ solver_set_name=solver_set_name,
+ problem_set_name=problem_set_name,
+ )
+ filename = f"{plot_type_name.lower()}_area_scatterplot.png"
+ plt.savefig(folder / filename, dpi=150, bbox_inches="tight")
+ plt.close()
+ plot_files.append(filename)
+ print(f" Saved {filename}")
+ except Exception as e:
+ print(f"Error generating {plot_type_name} plot: {e}")
+ import traceback
+
+ traceback.print_exc()
+
+ elif plot_type_name in [
+ "CDF_SOLVABILITY",
+ "QUANTILE_SOLVABILITY",
+ "DIFFERENCE_OF_CDF_SOLVABILITY",
+ "DIFFERENCE_OF_QUANTILE_SOLVABILITY",
+ ]:
+ # Solvability profiles require multiple problems
+ if len(problem_exp_indices) < 2:
+ print(f"Warning: {plot_type_name} requires multiple problems. Skipping.")
+ continue
+ try:
+ print(f"Generating {plot_type_name} plot...")
+ plt.figure(figsize=(10, 6))
+ # Extract parameters with defaults
+ all_in_one = plot_params.get("all_in_one", True)
+ n_bootstraps = plot_params.get("n_bootstraps", 100)
+ conf_level = plot_params.get("conf_level", 0.95)
+ plot_conf_ints = plot_params.get("plot_conf_ints", False) # Disabled by default
+ print_max_hw = plot_params.get("print_max_hw", False)
+ solve_tol = plot_params.get("solve_tol", 0.1)
+ beta = plot_params.get("beta", 0.5)
+ ref_solver = plot_params.get("ref_solver", None)
+ solver_set_name = plot_params.get("solver_set_name", "SOLVER_SET")
+ problem_set_name = plot_params.get("problem_set_name", "PROBLEM_SET")
+ # Map plot type name to PlotType enum
+ plot_type_map = {
+ "CDF_SOLVABILITY": PlotType.CDF_SOLVABILITY,
+ "QUANTILE_SOLVABILITY": PlotType.QUANTILE_SOLVABILITY,
+ "DIFFERENCE_OF_CDF_SOLVABILITY": PlotType.DIFFERENCE_OF_CDF_SOLVABILITY,
+ "DIFFERENCE_OF_QUANTILE_SOLVABILITY": PlotType.DIFFERENCE_OF_QUANTILE_SOLVABILITY,
+ }
+ plot_type_enum = plot_type_map[plot_type_name]
+
+ filtered_experiments = [
+ [
+ all_experiments[exp_prob_idx][exp_solver_idx]
+ for exp_solver_idx in solver_exp_indices
+ ]
+ for exp_prob_idx in problem_exp_indices
+ ]
+
+ plot_solvability_profiles(
+ filtered_experiments,
+ plot_type=plot_type_enum,
+ all_in_one=all_in_one,
+ n_bootstraps=n_bootstraps,
+ conf_level=conf_level,
+ plot_conf_ints=plot_conf_ints,
+ print_max_hw=print_max_hw,
+ solve_tol=solve_tol,
+ beta=beta,
+ ref_solver=ref_solver,
+ solver_set_name=solver_set_name,
+ problem_set_name=problem_set_name,
+ )
+ filename = f"{plot_type_name.lower()}_solvability_profile.png"
+ plt.savefig(folder / filename, dpi=150, bbox_inches="tight")
+ plt.close()
+ plot_files.append(filename)
+ print(f" Saved {filename}")
+ except Exception as e:
+ print(f"Error generating {plot_type_name} plot: {e}")
+ import traceback
+
+ traceback.print_exc()
+
+ elif plot_type_name == "SOLVE_TIME_CDF":
+ # Generate solvability CDF plots for each problem
+ for exp_prob_idx in problem_exp_indices:
+ try:
+ plt.figure(figsize=(10, 6))
+
+ # Extract parameters with defaults
+ solve_tol = plot_params.get("solve_tol", 0.1)
+ all_in_one = plot_params.get("all_in_one", True)
+ n_bootstraps = plot_params.get("n_bootstraps", 100)
+ conf_level = plot_params.get("conf_level", 0.95)
+ plot_conf_ints = plot_params.get(
+ "plot_conf_ints", False
+ ) # Disabled by default to avoid bootstrap errors
+ print_max_hw = plot_params.get("print_max_hw", False)
+
+ plot_solvability_cdfs(
+ [
+ all_experiments[exp_prob_idx][exp_solver_idx]
+ for exp_solver_idx in solver_exp_indices
+ ],
+ solve_tol=solve_tol,
+ all_in_one=all_in_one,
+ n_bootstraps=n_bootstraps,
+ conf_level=conf_level,
+ plot_conf_ints=plot_conf_ints,
+ print_max_hw=print_max_hw,
+ )
+ filename = f"solvability_cdf_problem_{exp_prob_idx + 1}.png"
+ plt.savefig(folder / filename, dpi=150, bbox_inches="tight")
+ plt.close()
+ plot_files.append(filename)
+ print(f" Saved {filename}")
+ except Exception as e:
+ print(
+ f"Error generating SOLVE_TIME_CDF plot for problem {exp_prob_idx + 1}: {e}"
+ )
+ import traceback
+
+ traceback.print_exc()
+ continue
+
+ elif plot_type_name == "TERMINAL_SCATTER":
+ # Generate terminal scatterplot (requires multiple problems)
+ if len(problem_exp_indices) < 2:
+ print("Warning: TERMINAL_SCATTER requires multiple problems. Skipping.")
+ continue
+
+ try:
+ print("Generating TERMINAL_SCATTER plot...")
+ plt.figure(figsize=(10, 6))
+
+ # Extract parameters with defaults
+ all_in_one = plot_params.get("all_in_one", True)
+ solver_set_name = plot_params.get("solver_set_name", "SOLVER_SET")
+ problem_set_name = plot_params.get("problem_set_name", "PROBLEM_SET")
+
+ filtered_experiments = [
+ [
+ all_experiments[exp_prob_idx][exp_solver_idx]
+ for exp_solver_idx in solver_exp_indices
+ ]
+ for exp_prob_idx in problem_exp_indices
+ ]
+
+ plot_terminal_scatterplots(
+ filtered_experiments,
+ all_in_one=all_in_one,
+ solver_set_name=solver_set_name,
+ problem_set_name=problem_set_name,
+ )
+ filename = "terminal_scatterplot.png"
+ plt.savefig(folder / filename, dpi=150, bbox_inches="tight")
+ plt.close()
+ plot_files.append(filename)
+ print(f" Saved {filename}")
+ except Exception as e:
+ print(f"Error generating TERMINAL_SCATTER plot: {e}")
+ import traceback
+
+ traceback.print_exc()
+
+ return plot_files
+
+
+# ── Output capture ──
+def setup_print_capture(log_file):
+ """Sets up stdout capture to log file. Returns (original_stdout, capture_instance)."""
+ import json as _json
+ import sys
+ import threading
+
+ write_lock = threading.Lock()
+
+ class PrintCapture:
+ def __init__(self, original) -> None:
+ self.original = original
+ self.buf = ""
+
+ def write(self, text):
+ self.original.write(text)
+ self.buf += text
+ while "\n" in self.buf:
+ line, self.buf = self.buf.split("\n", 1)
+ line = line.strip()
+ if line:
+ entry = {
+ "time": __import__("datetime").datetime.now().strftime("%H:%M:%S"),
+ "level": "INFO",
+ "msg": line,
+ }
+ with write_lock, log_file.open("a") as f:
+ f.write(_json.dumps(entry) + "\n")
+
+ def flush(self):
+ self.original.flush()
+
+ def isatty(self):
+ return False
+
+ original_stdout = sys.stdout
+ sys.stdout = PrintCapture(original_stdout)
+ return original_stdout
+
+
+# ── Experiment runner ──
+def run_experiment_async(run_id: str, payload: dict):
+ """Run the experiment in a background thread."""
+ folder = RESULTS_DIR / run_id
+ print(f"Results folder: {folder}")
+ print(f"Folder exists: {folder.exists()}")
+
+ import json as _json
+ import sys
+
+ log_file = folder / "experiment.log"
+
+ original_stdout = setup_print_capture(log_file)
+ import logging as _logging
+
+ class PrintForwardHandler(_logging.Handler):
+ def emit(self, record):
+ if record.name.startswith(("matplotlib", "PIL", "urllib", "findfont")):
+ return
+ msg = self.format(record)
+ if msg.startswith("findfont:"):
+ return
+ print(msg)
+
+ log_handler = PrintForwardHandler()
+ log_handler.setFormatter(_logging.Formatter("%(message)s"))
+ log_handler.setLevel(_logging.DEBUG)
+
+ root_logger = _logging.getLogger()
+ original_level = root_logger.level
+ root_logger.setLevel(_logging.DEBUG)
+ root_logger.addHandler(log_handler)
+
+ try:
+ update_status(folder, "Running experiments...")
+
+ exp_params = payload.get("experiment_params", {})
+ num_macroreps = exp_params.get("num_macroreps", 10)
+ num_postreps = exp_params.get("num_postreps", 100)
+ num_postnorms = exp_params.get("num_postnorms", 200)
+
+ problems_config = payload.get("problems", [])
+ solvers_config = payload.get("solvers", [])
+ plots_config = payload.get("plots", [])
+
+ # Convert display names to abbreviated names
+ for solver_cfg in solvers_config:
+ display_name = solver_cfg["name"]
+ abbr_name = SOLVER_FULL_TO_ABBR.get(display_name, display_name)
+ solver_cfg["name"] = abbr_name
+ print(f"Converted solver: '{display_name}' -> '{abbr_name}'")
+
+ for prob_cfg in problems_config:
+ display_name = prob_cfg["name"]
+ abbr_name = PROBLEM_FULL_TO_ABBR.get(display_name, display_name)
+ prob_cfg["name"] = abbr_name
+ print(f"Converted problem: '{display_name}' -> '{abbr_name}'")
+
+ from simopt.experiment_base import ProblemSolver, post_normalize
+
+ # Validate solver and problem names
+ for solver_cfg in solvers_config:
+ if solver_cfg["name"] not in solver_directory:
+ raise ValueError(
+ f"Solver '{solver_cfg['name']}' not found in solver directory. Available solvers: {list(solver_directory.keys())[:10]}"
+ )
+
+ for prob_cfg in problems_config:
+ if prob_cfg["name"] not in problem_directory:
+ raise ValueError(
+ f"Problem '{prob_cfg['name']}' not found in problem directory. Available problems: {list(problem_directory.keys())[:10]}"
+ )
+
+ needed_solver_indices = set()
+ needed_problem_indices = set()
+
+ for plot_cfg in plots_config:
+ plot_solvers = plot_cfg.get("solvers")
+ plot_problems = plot_cfg.get("problems")
+
+ if plot_solvers:
+ plot_solver_abbrs = [SOLVER_FULL_TO_ABBR.get(s, s) for s in plot_solvers]
+ for i, s in enumerate(solvers_config):
+ if s["name"] in plot_solver_abbrs:
+ needed_solver_indices.add(i)
+ else:
+ needed_solver_indices.update(range(len(solvers_config)))
+
+ if plot_problems:
+ plot_problem_abbrs = [PROBLEM_FULL_TO_ABBR.get(p, p) for p in plot_problems]
+ for i, p in enumerate(problems_config):
+ if p["name"] in plot_problem_abbrs:
+ needed_problem_indices.add(i)
+ else:
+ needed_problem_indices.update(range(len(problems_config)))
+
+ # Convert to sorted lists
+ needed_solver_indices = sorted(needed_solver_indices)
+ needed_problem_indices = sorted(needed_problem_indices)
+
+ solver_idx_map = {
+ orig_idx: new_idx for new_idx, orig_idx in enumerate(needed_solver_indices)
+ }
+ problem_idx_map = {
+ orig_idx: new_idx for new_idx, orig_idx in enumerate(needed_problem_indices)
+ }
+
+ print(
+ f"Running experiments for {len(needed_solver_indices)} solvers and {len(needed_problem_indices)} problems"
+ )
+
+ # Run experiments for each problem
+ all_experiments = []
+ for prob_idx in needed_problem_indices:
+ prob_cfg = problems_config[prob_idx]
+ print(f"Running problem {prob_idx + 1}: {prob_cfg['name']}...")
+
+ experiments_same_problem = []
+
+ for solver_idx in needed_solver_indices:
+ solver_cfg = solvers_config[solver_idx]
+ print(
+ f"Creating ProblemSolver with solver={solver_cfg['name']}, problem={prob_cfg['name']}"
+ )
+ print(f" Solver factors: {solver_cfg.get('fixed_factors', {})}")
+ print(f" Problem factors: {prob_cfg.get('fixed_factors', {})}")
+
+ experiment = ProblemSolver(
+ solver_name=solver_cfg["name"],
+ solver_rename=solver_cfg.get("rename", solver_cfg["name"]),
+ solver_fixed_factors=solver_cfg.get("fixed_factors", {}),
+ problem_name=prob_cfg["name"],
+ problem_rename=prob_cfg.get("rename", prob_cfg["name"]),
+ problem_fixed_factors=prob_cfg.get("fixed_factors", {}),
+ model_fixed_factors=prob_cfg.get("model_fixed_factors", {}),
+ )
+
+ print(f"Running experiment with {num_macroreps} macroreps...")
+ experiment.run(n_macroreps=num_macroreps)
+ print(f"Post-replicating with {num_postreps} postreps...")
+ experiment.post_replicate(n_postreps=num_postreps)
+ experiments_same_problem.append(experiment)
+
+ # Post-normalize
+ print(f"Post-normalizing with {num_postnorms} postnorms...")
+ post_normalize(
+ experiments=experiments_same_problem,
+ n_postreps_init_opt=num_postnorms,
+ )
+
+ all_experiments.append(experiments_same_problem)
+ import pickle
+
+ with (folder / "experiments.pkl").open("wb") as f:
+ pickle.dump(all_experiments, f)
+ with (folder / "index_maps.pkl").open("wb") as f:
+ pickle.dump(
+ {
+ "needed_solver_indices": needed_solver_indices,
+ "needed_problem_indices": needed_problem_indices,
+ "solver_idx_map": solver_idx_map,
+ "problem_idx_map": problem_idx_map,
+ },
+ f,
+ )
+
+ print("Generating plots...")
+ plot_files = generate_plots(
+ plots_config=plots_config,
+ all_experiments=all_experiments,
+ needed_solver_indices=needed_solver_indices,
+ needed_problem_indices=needed_problem_indices,
+ solver_idx_map=solver_idx_map,
+ problem_idx_map=problem_idx_map,
+ solvers_config=solvers_config,
+ problems_config=problems_config,
+ folder=folder,
+ )
+
+ config_to_save = {
+ "problems": [
+ {
+ "name": p["name"],
+ "fixed_factors": p.get("fixed_factors", {}),
+ "model_fixed_factors": p.get("model_fixed_factors", {}),
+ }
+ for p in problems_config
+ ],
+ "solvers": [
+ {"name": s["name"], "fixed_factors": s.get("fixed_factors", {})}
+ for s in solvers_config
+ ],
+ "experiment_params": exp_params,
+ }
+ with (folder / "experiment_config.json").open("w") as f:
+ _json.dump(config_to_save, f)
+
+ # Create final results page with plots
+ update_status(folder, "Complete!", plot_files)
+ print(f"Experiment {run_id} completed successfully!")
+
+ except Exception as e:
+ error_msg = f"Error: {e!s}"
+ update_status(folder, error_msg)
+ print(f"Experiment {run_id} failed: {error_msg}")
+ import traceback
+
+ traceback.print_exc()
+
+ finally:
+ sys.stdout = original_stdout
+ root_logger.removeHandler(log_handler)
+ root_logger.setLevel(original_level)
+
+
+# ── Results page generation ──
+def update_status(
+ folder: Path,
+ status: str,
+ plot_files: list[str] | None = None,
+):
+ """Update the results page with current status and plots."""
+ run_id = folder.name
+ plots_html = ""
+ if plot_files:
+ plot_cards = ""
+ minimized_icons = ""
+ preview_data = ""
+ for i, plot_file in enumerate(plot_files):
+ plot_id = f"plot_{i}"
+ label = plot_file.replace("_", " ").replace(".png", "")
+ plot_cards += f"""
+