diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 545e44e..50d4c44 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -88,6 +88,42 @@ jobs:
- name: API contract is committed (docs/contracts/openapi.json)
run: python scripts/export_openapi.py --check
+ desktop-install:
+ name: Default install (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ env:
+ OMP_NUM_THREADS: "2"
+ OPENBLAS_NUM_THREADS: "2"
+ MKL_NUM_THREADS: "2"
+ MPLBACKEND: Agg
+ steps:
+ - uses: actions/checkout@v4
+ - uses: astral-sh/setup-uv@v6
+ with:
+ python-version: "3.12"
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+ - name: Build the bundled Studio page
+ run: |
+ npm --prefix frontend ci
+ npm --prefix frontend run build
+ - name: Install defaults using automatic PyTorch backend selection
+ run: |
+ uv venv --python 3.12
+ uv pip install --python .venv -e ".[dev]" --torch-backend=auto
+ - name: Check default dependencies and available compute
+ run: uv run --no-project --python .venv python scripts/check_install.py
+ - name: Real browser fallback remains reachable
+ run: uv run --no-project --python .venv python -m pytest tests/integration/test_launcher_browser_fallback.py -q
+
frontend:
name: Frontend (types, lint, unit, build, browser journeys)
runs-on: ubuntu-latest
diff --git a/README.md b/README.md
index 217e592..0345dd0 100644
--- a/README.md
+++ b/README.md
@@ -37,54 +37,63 @@ OpenDPD is a PyTorch framework for power amplifier (PA) modeling and digital pre
## What's new
-**OpenDPD 2.2.4** separates PSD plots by signal-chain position: **DPD Input**, **DPD Output / PA Input**, and **PA Output**. Output references and with/without-DPD comparisons share only the PA Output plot, with compact legends and independent chart controls.
+**OpenDPD 2.2.5** adds LaTeX equations with interactive PA parameter highlighting, shared or per-channel OFDMA settings, and **ILC + ILA DPD** with a separate waveform-specific Ideal benchmark. Next-step controls sit above settings, and metric calculation methods have clearer names.
-**Signal Generator** creates a PA Input Dataset with separate CSV and metadata downloads. The new **PA Library** offers nine mathematical Virtual PAs with editable formulas and linked parameter controls. Explicitly simulate the output, create a paired synthetic dataset, and continue to PA/DPD training and testing. An expandable workflow diagram follows your progress.
+**Signal Generator → PA Library → PA training → DPD training/testing.** Generate a PA input waveform, simulate its output with one of nine Virtual PAs, or upload existing input/output CSV data. Results label synthetic, surrogate and measured evidence and show separate PSD charts at each signal-chain position.
-See the [2.2.4 release notes](https://github.com/lab-emi/OpenDPD/blob/main/docs/releases/release-notes-2.2.4.md) and [Signal Generator guide](https://lab-emi.github.io/OpenDPD/guides/signal-generator/). Standard presets are uncoded engineering stimuli; Wi-Fi 8 is experimental. Local Studio also adds research comparisons, publication figures and reproduction, measurement sessions, Sweep Board, hardware cost evidence and optional dataset contribution PRs for human review.
-
-- CUDA replay for supported native models reduces dispatch overhead while retaining the existing optimizer, precision, batches and scheduler.
-- Quick/full training defaults are 10/150 epochs; plots update once per epoch; DPD previews capture the intermediate signal in a bounded shadow-model forward pass.
-- See the [2.2.1 performance measurements](https://github.com/lab-emi/OpenDPD/blob/main/docs/performance/studio-2.2.1.md) and [release notes](https://github.com/lab-emi/OpenDPD/blob/main/docs/releases/release-notes-2.2.1.md).
-- **Guided experiments:** explore built-in I/Q data, train and test PA/DPD models, and choose from the original backbone registry.
-- **Live feedback:** separate epoch and batch progress bars, NMSE and other task metrics, live signal plots, reconnectable experiments and a Stop control.
-- **Download models while training:** save the best checkpoint so far; after training, download the selected final model. Compare compatible runs and export reports.
-- **Browser and local workbench:** nine interface languages, English by default, CUDA when available, touch-friendly plots and system light/dark themes.
-
-**Bring your own CSV:** upload UTF-8 CSV with two complex columns or four real I/Q columns, up to 25 MiB and 1,000,000 paired samples. Every row is validated in quarantine before preview; rejected uploads are deleted. Code, package and checkpoint uploads are unavailable in the public app.
-
-For a hosted installation, the [public Studio deployment guide](https://lab-emi.github.io/OpenDPD/architecture/public-studio/) covers GitHub Pages, a Cloudflare Tunnel and isolated local VM compute, with temporary sessions and automatic file deletion within 24 hours.
+[2.2.5 release notes](https://lab-emi.github.io/OpenDPD/releases/release-notes-2.2.5/) · [Signal Generator](https://lab-emi.github.io/OpenDPD/guides/signal-generator/) · [ILC guide](https://lab-emi.github.io/OpenDPD/guides/ilc-dpd/). Standard presets are engineering stimuli, not conformance waveforms; Wi-Fi 8 is experimental.
[Feature history](docs/whats-new.md) · [Verified platform status](docs/releases/support-matrix.md)
## Get started with Studio
-**[Open the hosted Studio now](https://opendpd.com/studio/)**, or install the packaged local app with **Python 3.10–3.13**:
+Use **[Studio on the web](https://opendpd.com/studio/)**, or install locally:
+
+**1. Install [uv](https://docs.astral.sh/uv/getting-started/installation/)** (then open a new terminal).
+
+macOS / Linux:
+
+```sh
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+Windows PowerShell:
+
+```powershell
+powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
+```
+
+**2. Create an environment, install, and launch** (same commands on all three platforms):
-```bash
-python -m pip install "opendpd[gui]==2.2.4"
-opendpd gui
+```sh
+mkdir opendpd-lab
+cd opendpd-lab
+uv venv --python 3.12
+uv pip install --python .venv "opendpd==2.2.5" --torch-backend=auto
+uv run --no-project --python .venv opendpd gui
```
-The wheel includes the frontend; Node.js is not needed. For development from source, also install Git and Node.js 22.22+:
+PyTorch, Studio and pywebview install together; no Node.js is needed. uv selects a PyTorch backend for the detected platform/drivers. Studio prefers available CUDA or Apple MPS, then CPU. Keep the terminal running. **A `127.0.0.1` link opens on the computer running OpenDPD**; for SSH, use the [port-forwarding instructions](docs/install.md#ssh-or-another-computer).
+
+Click **Get Started → Signal Generator**, or **Use an existing dataset**. See [Installation](docs/install.md) for drivers, Linux system libraries, native-window troubleshooting and pip; [Studio walkthrough](docs/tutorials/gui-quickstart.md) for your first experiment.
+
+
+Develop from source (Git and Node.js 22.22+ required)
-```bash
+```sh
git clone https://github.com/lab-emi/OpenDPD.git
cd OpenDPD
-python -m venv .venv
-source .venv/bin/activate
-python -m pip install -e ".[gui]"
+uv venv --python 3.12
+uv pip install --python .venv -e ".[dev]" --torch-backend=auto
npm --prefix frontend ci
npm --prefix frontend run build
-opendpd gui
+uv run --no-project --python .venv opendpd gui
```
-This opens Studio locally in your browser. For Windows, a native desktop window, GPU setup, or a core-only installation, see [Installation](docs/install.md).
-
-Click **Get Started → Signal Generator** to create a waveform, then **Choose Virtual PA** to simulate a paired dataset. Or choose **Use an existing dataset → DPA_200MHz** to go directly to PA Training. Use **Starting settings → Quick trial** to check the pipeline, then choose **Full training** for a longer experiment. Quick trial defaults to **10 epochs**; full training defaults to **150 epochs**. Plots update **once per epoch**; DPD previews show x, u and PA(u) from the same bounded validation probe. Advanced settings offer an optional batch preview interval with a red warning because extra previews can severely slow training.
+
## The PA → DPD workflow
@@ -99,7 +108,7 @@ Click **Get Started → Signal Generator** to create a waveform, then **Choose V
[PA Library guide](docs/guides/virtual-pa-library.md) · [Reading signal-chain PSD plots](docs/guides/signal-chain-spectra.md)
-
+
## Choose your next step
diff --git a/docs/about.md b/docs/about.md
index 76867e2..b675400 100644
--- a/docs/about.md
+++ b/docs/about.md
@@ -13,7 +13,7 @@ OpenDPD is developed by the [Lab of Efficient Machine Intelligence](https://www.
| Ruishen Yang | Contributor |
| Qian Wu | Contributor |
-See the [GitHub contributors](https://github.com/lab-emi/OpenDPD/graphs/contributors) for the evolving contribution history. Studio's About page also links to the team and repository activity.
+See the [GitHub contributors](https://github.com/lab-emi/OpenDPD/graphs/contributors) for the evolving contribution history. Studio's About page presents the team, lab, repository link and installed version; it no longer embeds GitHub activity.
## Research and citation
diff --git a/docs/architecture/adr/0002-native-window-shell.md b/docs/architecture/adr/0002-native-window-shell.md
index 5b29e23..2238164 100644
--- a/docs/architecture/adr/0002-native-window-shell.md
+++ b/docs/architecture/adr/0002-native-window-shell.md
@@ -40,3 +40,7 @@ security, no silent scientific change.
## Verification
`tests/unit/test_window.py`, `tests/unit/test_launcher.py` (window mode, fallback, refusal, reuse, CLI flags, doctor); the macOS evidence in `docs/releases/support-matrix.md`.
+
+## 2.2.5 installation update
+
+The default package now includes pywebview and the Studio server. Platform markers select Qt on Linux and native bindings on macOS/Windows. The old extras are compatibility aliases. OS libraries and GPU drivers remain host requirements; the recommended uv workflow selects a compatible PyTorch wheel with `--torch-backend=auto`. SSH sessions receive loopback/port-forward guidance. See [Installation](../../install.md).
diff --git a/docs/architecture/threat-model.md b/docs/architecture/threat-model.md
index 36bcec2..1d1e65a 100644
--- a/docs/architecture/threat-model.md
+++ b/docs/architecture/threat-model.md
@@ -19,7 +19,7 @@ ASGI application (`tests/integration/test_hardening.py` unless stated).
|---|---|---|---|
| Malicious web page drives the local API | `fetch("http://127.0.0.1:port/api/v1/runs", {method:"POST"})` | session cookie required (SameSite=Strict, HttpOnly); writes need `X-OpenDPD-CSRF` that only same-origin scripts can read; no CORS headers, preflight refused; Origin/Referer must match Host | `tests/integration/test_studio_api.py::test_cross_origin_and_csrf` |
| DNS rebinding | attacker host resolves to 127.0.0.1 | Host header must be a loopback name | `test_studio_api.py::test_host_header_enforced` |
-| Script injection into the page (XSS) | run names, notes, dataset ids, log lines, package contents rendered in the UI | React escapes every string; no `dangerouslySetInnerHTML`/`innerHTML`/`eval` in the frontend sources; server-side diagnostic pages HTML-escape their text; a Content-Security-Policy on every response allows scripts only from this origin (no inline scripts, no `eval`, no CDN), plus `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `frame-ancestors 'none'`, `Referrer-Policy: same-origin` | `test_every_response_carries_the_security_headers`, `test_diagnostic_pages_escape_their_text`, `test_frontend_sources_have_no_html_sinks`, `test_user_text_is_stored_and_returned_as_data`; the real-server journey (`frontend/e2e/live.spec.ts`) fails on any console error, which is where CSP violations surface |
+| Script injection into the page (XSS) | run names, notes, dataset ids, log lines, package contents rendered in the UI | React escapes user strings; the sole reviewed HTML sink is the bundled KaTeX renderer, which escapes source text and trusts only catalog coefficient classes; arbitrary URLs, resources and HTML/style commands are disabled; server-side diagnostic pages HTML-escape their text; a Content-Security-Policy on every response allows scripts only from this origin (no inline scripts, no `eval`, no CDN), plus `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `frame-ancestors 'none'`, `Referrer-Policy: same-origin` | `test_every_response_carries_the_security_headers`, `test_diagnostic_pages_escape_their_text`, `test_frontend_html_sinks_are_confined_to_the_audited_math_renderer`, `MathFormula.test.tsx`, `test_user_text_is_stored_and_returned_as_data`; the real-server journey (`frontend/e2e/live.spec.ts`) fails on any console error, which is where CSP violations surface |
| Guessing the session | brute force | 256-bit random ids; bootstrap token printed only to the local console/URL; sessions die with the process | `tests/unit/test_security.py` |
| Path traversal / symlink escape on download | `GET /artifacts/{run}/{id}` | downloads by registered artifact id; the resolved path (symlinks followed) must stay inside the run directory | `test_studio_api.py::test_artifact_download_by_id_only`, `test_artifact_symlink_outside_the_run_is_refused` |
| Reading files outside the import roots | `../` or a symlink inside a root pointing elsewhere | every source path is resolved and must stay inside its root; a symlink that leaves the root is neither listed nor readable | `tests/unit/test_datasets_service.py::test_import_roots_refuse_traversal_and_unknown_roots`, `test_import_root_symlink_escapes_are_invisible_and_unreadable` |
diff --git a/docs/contracts/openapi.json b/docs/contracts/openapi.json
index 42df263..1a19787 100644
--- a/docs/contracts/openapi.json
+++ b/docs/contracts/openapi.json
@@ -717,7 +717,8 @@
"kind": {
"enum": [
"surrogate_without_dpd",
- "measured_without_dpd"
+ "measured_without_dpd",
+ "ilc_ideal"
],
"title": "Kind",
"type": "string"
@@ -3502,6 +3503,18 @@
}
]
},
+ "ilc": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ilc"
+ },
"is_mock": {
"default": false,
"title": "Is Mock",
@@ -5246,6 +5259,17 @@
"title": "Seed",
"type": "integer"
},
+ "shared_channel_settings": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Shared Channel Settings"
+ },
"snr_db": {
"anyOf": [
{
@@ -7797,7 +7821,8 @@
"gradient",
"gradient_dla",
"least_squares",
- "ila_least_squares"
+ "ila_least_squares",
+ "ilc_ila"
],
"type": "string"
},
@@ -8284,6 +8309,11 @@
"title": "Symbol",
"type": "string"
},
+ "symbol_latex": {
+ "default": "",
+ "title": "Symbol Latex",
+ "type": "string"
+ },
"unit": {
"default": "",
"title": "Unit",
@@ -10382,7 +10412,7 @@
"title": "Csrf Token"
},
"version": {
- "default": "2.2.4",
+ "default": "2.2.5",
"title": "Version",
"type": "string"
}
@@ -12214,6 +12244,13 @@
"title": "Equations",
"type": "array"
},
+ "equations_latex": {
+ "items": {
+ "type": "string"
+ },
+ "title": "Equations Latex",
+ "type": "array"
+ },
"limitations": {
"$ref": "#/components/schemas/PALocalizedText"
},
@@ -12565,7 +12602,7 @@
},
"info": {
"title": "OpenDPD Studio API",
- "version": "2.2.4"
+ "version": "2.2.5"
},
"openapi": "3.1.0",
"paths": {
@@ -16464,6 +16501,46 @@
]
}
},
+ "/api/v1/signal-generator/signals/{signal_id}/archive": {
+ "post": {
+ "operationId": "archive_api_v1_signal_generator_signals__signal_id__archive_post",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "signal_id",
+ "required": true,
+ "schema": {
+ "title": "Signal Id",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Archive",
+ "tags": [
+ "signal generator"
+ ]
+ }
+ },
"/api/v1/signal-generator/signals/{signal_id}/dataset": {
"post": {
"deprecated": true,
@@ -16637,6 +16714,48 @@
]
}
},
+ "/api/v1/signal-generator/signals/{signal_id}/restore": {
+ "post": {
+ "operationId": "restore_api_v1_signal_generator_signals__signal_id__restore_post",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "signal_id",
+ "required": true,
+ "schema": {
+ "title": "Signal Id",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PAInputDataset"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Restore",
+ "tags": [
+ "signal generator"
+ ]
+ }
+ },
"/api/v1/signal-generator/validate": {
"post": {
"operationId": "validate_api_v1_signal_generator_validate_post",
@@ -17013,6 +17132,18 @@
"/api/v1/system/about": {
"get": {
"operationId": "system_about_api_v1_system_about_get",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "activity",
+ "required": false,
+ "schema": {
+ "default": true,
+ "title": "Activity",
+ "type": "boolean"
+ }
+ }
+ ],
"responses": {
"200": {
"content": {
@@ -17025,6 +17156,16 @@
}
},
"description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
}
},
"summary": "System About",
diff --git a/docs/documentation.md b/docs/documentation.md
index 782dc16..3542b27 100644
--- a/docs/documentation.md
+++ b/docs/documentation.md
@@ -7,7 +7,7 @@ The GitHub README is the short entry point; the documentation site is the place
| Content | Source of truth | Reuse |
| --- | --- | --- |
| Product introduction, current highlights, homepage screenshot | Root `README.md` sections `intro`, `studio-features`, `hero` | Included by the site home page. |
-| Minimal source-install command block | Root `README.md`, `source-install` section | Included by `docs/install.md`; edit once. |
+| Minimal source-install command block | Root `README.md`, `source-install` section | Shown in README; the installation guide adds platform and troubleshooting details. |
| Setup options and troubleshooting | `docs/install.md` | Linked from README and tutorials. |
| Task walkthroughs and explanations | `docs/tutorials/`, `training.md`, `visualization.md`, `advanced.md`, `faq.md` | Read directly on GitHub or through the site. |
| Dataset format and Python examples | `datasets/README.md`, `examples/README.md` | Included by `docs/datasets.md` and `docs/examples.md`. |
@@ -19,7 +19,7 @@ The GitHub README is the short entry point; the documentation site is the place
| Shared images | `pics/` | Published by `docs/hooks/assets.py`; keep one copy. |
| Studio and EMI logos | `frontend/src/assets/`; root `README.md`, `brand` section | The site includes the same brand section and publishes the original SVGs through the asset hook. |
-`docs/index.md`, `docs/datasets.md`, `docs/examples.md` and `docs/benchmark/index.md` are site entry pages. They compose canonical content with `pymdownx.snippets`; they are not a second place to edit that content. The installation page reuses only the short command block. When reading its Markdown on GitHub, that block is available in the root README.
+`docs/index.md`, `docs/datasets.md`, `docs/examples.md` and `docs/benchmark/index.md` are site entry pages. They compose canonical content with `pymdownx.snippets`; they are not a second place to edit that content. Keep the short uv commands in README and the installation guide consistent.
## Writing and linking
@@ -60,6 +60,6 @@ Reuse the transparent SVGs from `frontend/src/assets/`, including their light an
The README's `brand` snippet uses GitHub-compatible `` elements, each with a dark `source` and a light fallback `img`. Keep these child tags self-closing. During the site build, `docs/hooks/assets.py` converts them to Material's `#only-light` / `#only-dark` images, so the site's own theme toggle works independently of the system theme. Check both themes and a narrow viewport when changing this section.
-## Release 2.2.4 screenshot set
+## Release 2.2.5 screenshot set
-Refresh `pics/studio-home.png`, `studio-signal-generator.png`, `studio-pa-library.png`, and `studio-psd-chain.png` from the actual current GUI. Use a disposable synthetic workspace, record exact sample counts and real worker outcomes, and inspect chart legends at desktop and mobile sizes. Current guides link to these shared assets. Older release and performance directories remain historical evidence and are not overwritten as if they represented the new version.
+Refresh `pics/studio-home.png`, `studio-signal-generator.png`, `studio-pa-library.png`, `studio-psd-chain.png`, and `studio-ilc.png` from the actual current GUI. Use a disposable synthetic workspace, record exact sample counts and real worker outcomes, and inspect chart legends at desktop and mobile sizes. Current guides link to these shared assets. Older release and performance directories remain historical evidence and are not overwritten as if they represented the new version.
diff --git a/docs/faq.md b/docs/faq.md
index 425c21e..b4fc88d 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -2,7 +2,7 @@
## Why did pip install not give me Studio?
-The PyPI 2.1.0 release provides the original core API and `opendpd-cli`. Studio is in the 2.2 development preview and currently requires a [source installation](install.md), including a frontend build. Installing an extra named `gui` or `desktop` from the older release does not add that code.
+Install the current package in a fresh Python environment using the [uv quick start](install.md). Version 2.2.5 includes the built Studio frontend, PyTorch and pywebview by default; no Node.js build or extra is needed. Older 2.1 packages do not contain Studio.
## Which command should I use?
@@ -15,7 +15,7 @@ Use the [workspace CLI guide](tutorials/headless-cli.md), [training guide](train
## Can I upload my own dataset?
-The current Studio preview disables dataset upload and displays **Coming soon**. You can use the local [CLI import workflow](tutorials/headless-cli.md#use-your-own-data) or [Python dataset API](api.md). Start the GUI walkthrough with a built-in dataset.
+Yes. Choose **Upload CSV** in Get Started or Datasets and supply paired PA input/output with signal metadata. Generated signals are input-only until PA Library creates a synthetic output. Contributions to the shared collection require explicit submission and human PR review; private datasets stay in the current workspace.
## Why is a DPD result labeled simulated?
@@ -38,3 +38,11 @@ For the current signal and demodulator details, read [Datasets](datasets.md). Fo
## Where does the generated PA output come from?
Signal Generator produces x only. In [PA Library](guides/virtual-pa-library.md), choose a mathematical Virtual PA and explicitly simulate y, then create the paired dataset. Both signals are synthetic. To assess a physical device, upload measured pairs or import actual captures through the measured-DPD workflow. PSD charts distinguish DPD output / PA input from PA output; see [signal positions](guides/signal-chain-spectra.md).
+
+## Why does a localhost Studio link refuse the connection?
+
+The launcher must still be running, and `127.0.0.1` refers to the browser's own computer. When a Mac browser connects to Studio launched on a Linux server, use an SSH port forward. A failed automatic browser opener does not stop the service. See [installation troubleshooting](install.md#troubleshooting-connection-refused).
+
+## What did Metric profile mean?
+
+It is the versioned method used to calculate a score: averaging, spectral bands, normalization and reference conventions. The GUI now calls it **Metric calculation** and uses descriptive labels. It remains in Metric definitions because comparing results with different calculation methods is misleading; selecting it does not retrain the model or change the waveform.
diff --git a/docs/guides/ilc-dpd.md b/docs/guides/ilc-dpd.md
new file mode 100644
index 0000000..4c98c25
--- /dev/null
+++ b/docs/guides/ilc-dpd.md
@@ -0,0 +1,44 @@
+# ILC and ILA DPD
+
+Studio 2.2.5 exposes **ILC linearization** beside PA Training/Testing and **ILC-DPD / Ideal benchmark** beside DPD Training/Testing. First train a usable forward PA surrogate on the paired dataset. ILC is an input-waveform controller, not an alternative forward PA identifier.
+
+## The two outputs
+
+1. **ILC-DPD (ILA)** is a causal memory polynomial you can apply to a new input. On a bounded prefix of the training split, ILC learns an input `u` that makes the bound surrogate approach `G*x`. ILA fits `Phi(y_ILC/G) w ≈ u_ILC`, then copies the postdistorter coefficients into the predistorter. The test split never supplies fitting samples.
+2. **ILC Ideal DPD** is a separate, explicitly labelled test-waveform reference. It uses repeated feedback on that same test waveform through the selected surrogate. It is not a transferable model, proof of a global optimum, hardware evidence or a fair substitute for held-out model generalization. Its score may stop short of the target when the plant saturates or the update cannot improve.
+
+The constant target gain `G` retains the existing OpenDPD gain rule. The complex inverse learning gain is estimated from training-only input/output of the bound surrogate. Each plant replay resets model state at the same dataset segment boundaries; final-segment padding is excluded from the controller's pooled error. The update is:
+
+```text
+e[k] = G*x - PA(u[k])
+u[k+1] = clip(u[k] + alpha[k] * inverse_gain * e[k], peak_limit)
+```
+
+The surrogate replay uses the selected accelerator; polynomial fitting and coefficient arithmetic use CPU complex128 for numerical stability. MPS hardware validation remains pending. The implementation uses a constant gain inverse, not a frequency-dependent BLA inverse. Each rejected update halves the learning step; only lower-error candidates are accepted. The algorithm reports target reached, iteration limit, no improving step, or improvement below tolerance. Cancellation is checked between plant evaluations.
+
+## Starting hyperparameters
+
+| Setting | Default | Effect |
+| --- | --- | --- |
+| Iterations | 30 | Upper bound on waveform updates |
+| Learning gain | 0.5 | Initial correction size; backtracking can reduce it |
+| Target pooled NMSE | −45 dB | Stop threshold; no guaranteed attainability |
+| Peak factor | 1.5 | Peak cap relative to the PA model's training-input peak; values above 1 permit extrapolation |
+| Backtracking steps | 6 | Maximum halvings per update |
+| Minimum improvement | 0.001 dB | Stop when progress becomes negligible |
+| Fit samples | 32,768 | Leading training samples used by ILC and ILA |
+| MP envelope order count K | 7 | Powers 0 through K−1 |
+| MP memory depth Q | 5 samples | Past input context |
+| SVD relative cutoff | 10⁻⁶ | Truncate poorly conditioned fitting directions |
+
+These are conservative starting settings, not universally optimal values. Reduce the peak factor and input level when extrapolation or saturation dominates; reduce learning gain when many steps backtrack. Increase model order/depth only when independent validation supports it. The public app caps K/Q at 9/16, iterations at 60, fit samples at 32,768 and backtracking at 6.
+
+
+
+## Inspect and reproduce
+
+Results show the two ILC convergence curves, stopping reasons, sample counts, peak cap and PA checkpoint identity. Metric definitions render in LaTeX. Main result metrics score the fitted DPD; the Ideal reference is a separate baseline under the same calculation method. PSDs put Ideal and fitted signals into their respective PA Input and PA Output panels. No score definitions or frozen legacy protocols change.
+
+Artifacts include `ilc.json`, training waveforms in `ilc-training.npz`, `ilc-benchmark.json`, `ilc-ideal-test.csv`, the transferable checkpoint and ordinary result/plot files. Re-evaluating a run does not rewrite its training data. Testing with another PA surrogate creates a new result and re-estimates the learning gain from training data for that surrogate.
+
+The framework follows [Schoukens, Hammenecker & Cooman, *Obtaining the Preinverse of a Power Amplifier Using Iterative Learning Control*](https://doi.org/10.1109/TMTT.2017.2694822) ([author preprint](https://arxiv.org/abs/1606.08663)); this release uses an ILA postinverse fit after waveform learning. It does not reproduce the paper's frequency-dependent learning filter or claim its experimental performance.
diff --git a/docs/guides/signal-chain-spectra.md b/docs/guides/signal-chain-spectra.md
index ab0749a..7ac4ba0 100644
--- a/docs/guides/signal-chain-spectra.md
+++ b/docs/guides/signal-chain-spectra.md
@@ -59,3 +59,5 @@ Band shading follows the selected result's metric profile. Legacy and general
spectral ACLR/ACPR retain their distinct definitions. Zooming does not recalculate
metrics. See [research review](research-review.md) for protocol compatibility,
saved provenance and frequency-cursor interpretation.
+
+ILC runs add a waveform-specific Ideal trace to the PA Input and PA Output panels. The legend preserves that identity alongside the fitted DPD. Ideal feedback uses the current test waveform; see [ILC and ILA](ilc-dpd.md) before comparing it with a transferable model.
diff --git a/docs/guides/signal-generator.md b/docs/guides/signal-generator.md
index b29b413..70725c0 100644
--- a/docs/guides/signal-generator.md
+++ b/docs/guides/signal-generator.md
@@ -135,8 +135,16 @@ custom two-channel OFDMA with explicit pilots and noise, ZIP export, dataset cre
and real CPU PA/DPD training and testing. These checks validate the software workflow;
they do not constitute independent standards conformance or physical RF validation.
-## Studio 2.2.4 preview
+## Studio 2.2.5 preview

The PSD here is labelled **PA Input**. It contains only the generated x signal; PA output appears after explicit simulation in PA Library. See [signal-chain spectra](signal-chain-spectra.md).
+
+## Explicit generation and shared channels (2.2.5)
+
+Opening the generator no longer creates a saved default waveform. Choose a preset and click **Generate & preview**. Generated sources remain PA Input Datasets until explicitly paired with an output.
+
+**Use the same settings for all channels** is checked by default for equal allocations. One set of subcarrier-count, modulation and power fields then controls every channel; a newly added channel inherits it. Uncheck to edit those parameters independently. Importing unequal channel settings keeps them independent. Re-enabling sharing applies channel 1's settings to every channel. The number of channels remains visible. FFT timing, cyclic prefix and the global pilot-bin allocation share the OFDM grid.
+
+Configuration JSON records the sharing choice together with every channel's resolved settings. Generated CSV/metadata downloads and the next-step PA Library button are above advanced parameters.
diff --git a/docs/guides/virtual-pa-library.md b/docs/guides/virtual-pa-library.md
index 7632974..9c0dbe6 100644
--- a/docs/guides/virtual-pa-library.md
+++ b/docs/guides/virtual-pa-library.md
@@ -50,7 +50,7 @@ calibrated transistor-physics simulation.
The complete equations, parameter bounds, defaults, explanations and symbols
come from `opendpd/core/virtual_pa.py` and are included in each frozen simulation.
-The UI renders parameter tokens as controls; it does not evaluate arbitrary code.
+Studio 2.2.5 renders the catalog equations as LaTeX using bundled KaTeX and fonts. Parameter coefficients remain keyboard/click controls with dynamic highlighting. Rendering allows only the fixed parameter classes, with external links/resources and arbitrary styles disabled; no formula code is evaluated.
For example, the Rapp helper is
```
@@ -128,8 +128,10 @@ feature gating and public-session isolation. Frontend tests cover linked control
invalidated previews, explicit pairing and existing-dataset bypass. The browser
script `scripts/verify_signal_generator.mjs` exercises real downloads and workers.
-## Studio 2.2.4 preview
+## Studio 2.2.5 preview

The output preview draws **PA Input** and **PA Output** PSDs separately on matching initial dB scales. Independent controls enlarge or zoom each location. The paired dataset remains synthetic when used to learn a PA surrogate or DPD model. See [signal-chain spectra](signal-chain-spectra.md).
+
+The input selector and **Simulate PA output** control sit above the mathematical parameters. After simulation, dataset creation appears above the output charts. **Remove PA Input Dataset** hides only the selected input from this workspace's picker; Undo restores it. Existing simulation sources and paired datasets remain intact. For linearization experiments after forward-model training, continue to [ILC and ILA DPD](ilc-dpd.md).
diff --git a/docs/install.md b/docs/install.md
index f9ce76f..8be092a 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -1,85 +1,87 @@
# Installation
-Try **[OpenDPD Studio on the web](https://opendpd.com/studio/)** without installing anything, or install **2.2.0** locally.
+[Use the hosted Studio](https://opendpd.com/studio/) without installation, or run OpenDPD **2.2.5** locally. Python 3.12 is the recommended starting point; the compute suite also covers 3.10–3.13.
-| Installation | Includes | Requirements |
-| --- | --- | --- |
-| PyPI, `opendpd[gui]` | Packaged Studio, workspace CLI and Python API | Python 3.10–3.13; no Node.js |
-| PyPI, `opendpd[desktop]` | Studio in a native window | Above, plus a working system web view |
-| Source, `.[gui]` | Editable Studio and research scripts | Python 3.10–3.13, Git, Node.js 22.22+ |
+## Install uv
-```bash
-python -m pip install "opendpd[gui]==2.2.0"
-opendpd gui
-```
+macOS / Linux:
-Local workspaces stay on your computer. The hosted app uses temporary server workspaces and deletes their files within 24 hours.
+```sh
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
-## Install Studio from source
+Windows PowerShell:
-Install Python, Git and Node.js first. The following creates an isolated Python environment, installs the browser dependencies and builds the frontend. Run it in a terminal on macOS or Linux; use `python3` if that is your Python command.
+```powershell
+powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
+```
-[Source quick-start commands on GitHub](https://github.com/lab-emi/OpenDPD#get-started-with-studio) (also expanded below on the documentation site).
+Open a new terminal so uv is on PATH. See the [official uv installer](https://docs.astral.sh/uv/getting-started/installation/) for package-manager alternatives.
---8<-- "README.md:source-install"
+## Create your project environment
-On **Windows PowerShell**, replace the activation line with:
+These commands work on macOS, Linux and Windows; activation is unnecessary because the environment is named explicitly.
-```powershell
-.venv\Scripts\Activate.ps1
+```sh
+mkdir opendpd-lab
+cd opendpd-lab
+uv venv --python 3.12
+uv pip install --python .venv "opendpd==2.2.5" --torch-backend=auto
+uv run --no-project --python .venv opendpd doctor
+uv run --no-project --python .venv opendpd gui
```
-The remaining commands are the same. Platform verification is recorded separately in the [support matrix](releases/support-matrix.md).
-
-Studio starts a service on `127.0.0.1` and opens your browser. The default workspace is `~/opendpd-workspace`; choose another with `opendpd gui --workspace PATH`. Datasets, runs and results stay in that workspace. Follow the [Studio walkthrough](tutorials/gui-quickstart.md) once the Home page appears.
+The package installs PyTorch, the API server, the prebuilt Studio frontend, and pywebview. The old `[gui]` and `[desktop]` extras remain compatible aliases. A released wheel does not require Node.js. Local datasets and runs persist in `~/opendpd-workspace`; change this with `opendpd gui --workspace PATH`.
-### Native desktop window
+## Platform and accelerator selection
-From the same checkout and activated environment:
+| Platform | Native window | Compute |
+| --- | --- | --- |
+| Apple Silicon macOS | pywebview installs PyObjC and uses WKWebView | PyTorch's macOS wheel includes MPS; Studio selects it when available |
+| Intel macOS | WKWebView on a supported macOS/Python combination | CPU; availability of current PyTorch wheels depends on architecture/version |
+| Linux | pywebview installs Qt, PyQt6 and Qt WebEngine | uv selects a compatible detected GPU backend; Studio supports CUDA/ROCm through `cuda`, otherwise CPU |
+| Windows | pywebview installs pythonnet and uses WebView2 | uv selects CUDA with a compatible NVIDIA driver, otherwise CPU |
-```bash
-python -m pip install -e ".[desktop]"
-opendpd gui --window
-```
+`--torch-backend=auto` is uv's hardware-aware installation option; it is not a pip feature. See [uv's PyTorch guide](https://docs.astral.sh/uv/guides/integration/pytorch/) and the [PyTorch installation selector](https://pytorch.org/get-started/locally/). A Python package cannot install an operating-system GPU driver, manufacture accelerator support on incompatible hardware, or guarantee every model supports every device. Intel XPU is not an OpenDPD execution target. `opendpd doctor` and Studio's device selector report what is actually available.
-The desktop window uses the same local UI and workspace. `--window` reports an error if the native backend is unavailable; `--browser` always uses the system browser. Run `opendpd doctor` to inspect the installed backend. Linux may also need WebKit2GTK system packages or a Qt backend; see the [launcher guide](tutorials/gui-quickstart.md#launch-options).
+If PyTorch was already installed with an unsuitable backend, repair only that environment:
-### CPU and GPU
+```sh
+uv pip install --python .venv --reinstall torch --torch-backend=auto
+```
-CPU is enough for a first smoke experiment. For NVIDIA GPUs, install the PyTorch build matching your system using the [official PyTorch selector](https://pytorch.org/get-started/locally/) **before** installing OpenDPD. Select `cuda`, `mps` or `cpu` only when available on your machine. See the [support matrix](releases/support-matrix.md) for what has been verified.
+Linux still needs a graphical session and the system libraries Qt uses (typically `libgl1`, `libegl1`, `libxcb-cursor0` and X11/XCB libraries on Debian/Ubuntu). Minimal distributions and containers may lack them. Windows needs the Microsoft WebView2 Runtime. See [pywebview platform prerequisites](https://pywebview.flowrl.com/guide/installation). OpenDPD does not silently install system packages or disable the browser sandbox. On a headless machine, use `--no-browser`; `--browser` bypasses the native window and `--window` reports a missing native backend directly.
-### Update a checkout
+## SSH or another computer
-After pulling new source changes, refresh the Python installation and rebuild the frontend:
+`127.0.0.1` always refers to the computer running the browser. If OpenDPD runs on a Linux server and you browse from a Mac, establish a tunnel **in a terminal on the Mac**:
-```bash
-git pull --ff-only
-python -m pip install -e ".[gui]"
-npm --prefix frontend ci
-npm --prefix frontend run build
-opendpd gui
+```sh
+ssh -N -L 8765:127.0.0.1:8765 USER@SERVER
```
-Node.js is needed to build the frontend from source. Running a wheel that already contains the built frontend does not require Node.js.
+Replace `USER@SERVER` with your server login. Keep both this tunnel and the terminal running `opendpd gui --no-browser --port 8765` open. Open the **bootstrap URL printed by that running server** in the Mac browser. If Studio chose a different port, use that port on both sides of `-L`. You may choose another local port if 8765 is occupied; then change only the browser URL's port to match it. Do not publish the bootstrap token.
-## Install the released core from PyPI
+A browser-open failure is nonfatal: Studio continues serving its URL. The launcher now explains local addresses and prints forwarding guidance when SSH is detected. Its loopback health checks bypass system HTTP proxies. The service stays bound to loopback; opening it on a LAN is unnecessary.
-In an activated Python environment:
+## Troubleshooting connection refused
-```bash
-python -m pip install opendpd
-opendpd-cli --help
-```
+1. Keep the launcher running. Closing its native window, pressing Ctrl+C, closing the terminal, or disconnecting SSH stops it.
+2. Use the current printed port and URL. Another machine requires the tunnel above.
+3. On the server, check `curl http://127.0.0.1:8765/healthz` (PowerShell: `Invoke-RestMethod http://127.0.0.1:8765/healthz`). If this fails, inspect the launcher's terminal for a startup error and run `opendpd doctor` in the same environment.
+4. If the health check works locally but the browser cannot connect, check your SSH forward or browser proxy's localhost bypass. A session-expired response is different from connection refused; reopen the current bootstrap URL to renew the session.
-This provides `opendpd.train_pa`, `train_dpd`, `run_dpd`, `plot_dpd`, `load_dataset` and `create_dataset`. Start with [Python examples](examples.md) or the [API reference](api.md). `opendpd-cli` mirrors the repository's `python main.py`; it is distinct from the newer workspace command `opendpd run`.
+## pip and source installations
-For the current core from source without the GUI, use `python -m pip install -e .` in the checkout. Research scripts run from the repository root; see [training](training.md) and [reproduction](reproducing.md).
+Standard pip installs all Python dependencies too:
-## Troubleshooting
+```sh
+python -m pip install "opendpd==2.2.5"
+opendpd gui
+```
-- **`opendpd` is missing:** check that the virtual environment is active and that you installed the source preview, not only the PyPI 2.1.0 release.
-- **Frontend assets are missing:** run both frontend build commands above from the repository root, then restart Studio.
-- **An old UI appears:** stop Studio, rebuild the frontend and relaunch. `opendpd doctor` checks asset availability and version.
-- **The session expired:** reopen the URL printed by the current launcher. A link from an earlier server instance will not work.
+pip cannot detect your GPU driver to choose a custom PyTorch index. For acceleration, prefer the uv command above, or follow the official PyTorch selector before installing OpenDPD. Running from a source checkout also needs Git and Node.js 22.22+:
+
+--8<-- "README.md:source-install"
-Developer setup and the repository layout are in [Contributing](https://github.com/lab-emi/OpenDPD/blob/main/CONTRIBUTING.md) and [Testing](testing.md).
+After source changes, rerun the editable install and frontend build. See [Contributing](https://github.com/lab-emi/OpenDPD/blob/main/CONTRIBUTING.md) and the [platform support matrix](releases/support-matrix.md).
diff --git a/docs/performance/studio-2.2.5.md b/docs/performance/studio-2.2.5.md
new file mode 100644
index 0000000..aa84ecb
--- /dev/null
+++ b/docs/performance/studio-2.2.5.md
@@ -0,0 +1,21 @@
+# Studio 2.2.5 validation
+
+Validation date: 2026-09-14. Synthetic runs establish software behavior, not physical RF performance.
+
+## Exercised workflows
+
+- Actual Chromium UI at 1366×768 and 1920×1080: explicit generation, custom independent OFDMA channels, input CSV/JSON, Virtual PA simulation, exact paired exports, dataset creation, PA/DPD training and testing. Eight CPU jobs succeeded; each dataset contains 32,768 pairs and 6,452 test samples.
+- All nine Virtual PA catalogs render without LaTeX errors. Selecting parameters highlights matching coefficients. PA Input Dataset removal and Undo pass through the actual API.
+- Default ILC/ILA settings execute through the GUI on CPU and CUDA, with 19,353 available training samples and 6,452 separate test samples. Both stop when improvement falls below tolerance. On this one-epoch smoke surrogate, pooled Ideal tracking NMSE improves from approximately −3.52 dB to −19.85 dB; it does **not** reach the −45 dB target. This is a workflow check, not a model-quality benchmark.
+- ILC PSDs contain one DPD Input trace, two PA Input traces and five PA Output traces. The Ideal waveform is explicitly labelled. Ordinary PSD review at 1366×768, 1920×1080 and 390×844 retains exact saved bins and independent zoom/legend controls.
+- An isolated Python 3.12 installation resolves all default dependencies and runs CPU tensor arithmetic with PyTorch 2.14.0+cpu, pywebview 6.2.1 and Qt/WebEngine 6.11.0. Separate `uv --torch-backend=auto` resolution detects this host's CUDA driver and selects the CUDA 13.2 wheel. The installed GUI serves from outside the source repository.
+- Chromium mock journeys and accessibility checks: 24 passed, six optional live cases skipped; the actual-worker workflows above exercise the service separately. Packaging, golden metrics and ILC integration: 10 passed before the final full regression.
+- LaTeX adversarial tests reject URLs, resource loads, injected markup and arbitrary HTML classes/styles. The remaining frontend stays under the HTML-sink scanner.
+
+Records: [local workflow](studio-2.2.5/local-workflow.json), [ILC and formula checks](studio-2.2.5/ilc.json), [spectral checks](studio-2.2.5/signal-chain.json), [screenshots](studio-2.2.5/screenshots.json).
+
+## Release verification
+
+The PR checks the final Python/frontend regression, distribution metadata, API contract, documentation build and default installs on Linux/macOS/Windows. Release/deployment evidence is attached to the GitHub release after merged source, public API, GPU image, website and PyPI package are checked together.
+
+Native windows on all three platforms and MPS hardware are not claimed by a Linux smoke check. See the [support matrix](../releases/support-matrix.md).
diff --git a/docs/performance/studio-2.2.5/ilc.json b/docs/performance/studio-2.2.5/ilc.json
new file mode 100644
index 0000000..af4129a
--- /dev/null
+++ b/docs/performance/studio-2.2.5/ilc.json
@@ -0,0 +1,683 @@
+{
+ "runs": [
+ {
+ "id": "run-20260913-233145-b0dbaf",
+ "device": "cpu",
+ "nmse": -7.155760288238525,
+ "ilc": {
+ "fit_samples": 19353,
+ "inverse_gain": [
+ 0.8494279459322799,
+ -0.03201008698744541
+ ],
+ "method": "ILC + ILA: optimize only the training waveform through the bound PA surrogate; fit Phi(y_ILC / G) w ~= u_ILC and copy that postdistorter to the predistorter. The test waveform is never used to fit coefficients. The separate Ideal baseline uses test-waveform feedback; it is not transferable, globally optimal, or measured hardware evidence.",
+ "peak_limit": 0.9769193530082703,
+ "plant_run_id": "run-20260913-231319-6a8293",
+ "plant_sha256": "ab8cdbc20c4fc541e4241c403be3df1ff5e67b7d5afda55622ffa65a1d35a79a",
+ "segment_length": 1024,
+ "settings": {
+ "K": 7,
+ "Q": 5,
+ "backtracking_steps": 6,
+ "fit_samples": 32768,
+ "iterations": 30,
+ "learning_gain": 0.5,
+ "min_improvement_db": 0.001,
+ "peak_factor": 1.5,
+ "rcond": 0.000001,
+ "target_nmse_db": -45
+ },
+ "target_gain": 1.0600225851920677,
+ "test_feedback_used_for_fit": false,
+ "training_history": [
+ {
+ "iteration": 0,
+ "learning_gain": 0,
+ "limited_fraction": 0,
+ "nmse_db": -3.4942437023942166,
+ "peak_abs": 0.6512795457805705
+ },
+ {
+ "iteration": 1,
+ "learning_gain": 0.5,
+ "limited_fraction": 0,
+ "nmse_db": -8.27927443409887,
+ "peak_abs": 0.646770236828989
+ },
+ {
+ "iteration": 2,
+ "learning_gain": 0.5,
+ "limited_fraction": 0,
+ "nmse_db": -11.654759437883556,
+ "peak_abs": 0.8964763173277374
+ },
+ {
+ "iteration": 3,
+ "learning_gain": 0.5,
+ "limited_fraction": 0.00010334315093267194,
+ "nmse_db": -14.184108245643468,
+ "peak_abs": 0.9769193530082703
+ },
+ {
+ "iteration": 4,
+ "learning_gain": 0.5,
+ "limited_fraction": 0.00010334315093267194,
+ "nmse_db": -16.145322004377736,
+ "peak_abs": 0.9769193530082703
+ },
+ {
+ "iteration": 5,
+ "learning_gain": 0.5,
+ "limited_fraction": 0.0001550147263990079,
+ "nmse_db": -17.62766050534396,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 6,
+ "learning_gain": 0.5,
+ "limited_fraction": 0.0001550147263990079,
+ "nmse_db": -18.676172320843037,
+ "peak_abs": 0.9769193530082703
+ },
+ {
+ "iteration": 7,
+ "learning_gain": 0.5,
+ "limited_fraction": 0.0001550147263990079,
+ "nmse_db": -19.34211716547877,
+ "peak_abs": 0.9769193530082703
+ },
+ {
+ "iteration": 8,
+ "learning_gain": 0.5,
+ "limited_fraction": 0.0001550147263990079,
+ "nmse_db": -19.69519348619471,
+ "peak_abs": 0.9769193530082705
+ },
+ {
+ "iteration": 9,
+ "learning_gain": 0.5,
+ "limited_fraction": 0.00020668630186534387,
+ "nmse_db": -19.813205580903286,
+ "peak_abs": 0.9769193530082703
+ },
+ {
+ "iteration": 10,
+ "learning_gain": 0.25,
+ "limited_fraction": 0.00020668630186534387,
+ "nmse_db": -19.886016120112696,
+ "peak_abs": 0.9769193530082703
+ },
+ {
+ "iteration": 11,
+ "learning_gain": 0.25,
+ "limited_fraction": 0.00020668630186534387,
+ "nmse_db": -19.924738586366583,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 12,
+ "learning_gain": 0.25,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.935392534436428,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 13,
+ "learning_gain": 0.125,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.951641027060305,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 14,
+ "learning_gain": 0.125,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.9623569769544,
+ "peak_abs": 0.9769193530082703
+ },
+ {
+ "iteration": 15,
+ "learning_gain": 0.125,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.968043878487126,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 16,
+ "learning_gain": 0.125,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.969163096479235,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 17,
+ "learning_gain": 0.0625,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.972931389547323,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 18,
+ "learning_gain": 0.0625,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.975679745210385,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 19,
+ "learning_gain": 0.0625,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.977455466970923,
+ "peak_abs": 0.9769193530082703
+ },
+ {
+ "iteration": 20,
+ "learning_gain": 0.0625,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.97830421196644,
+ "peak_abs": 0.9769193530082703
+ }
+ ],
+ "training_plant_calls": 44,
+ "training_stop_reason": "improvement_below_tolerance",
+ "version": "ilc-ila-v1",
+ "test_history": [
+ {
+ "iteration": 0,
+ "nmse_db": -3.5227215299624133,
+ "learning_gain": 0,
+ "peak_abs": 0.5484617996283938,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 1,
+ "nmse_db": -8.320090233966598,
+ "learning_gain": 0.5,
+ "peak_abs": 0.6093995543679916,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 2,
+ "nmse_db": -11.644818240952205,
+ "learning_gain": 0.5,
+ "peak_abs": 0.666493304409383,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 3,
+ "nmse_db": -14.12674249095637,
+ "learning_gain": 0.5,
+ "peak_abs": 0.7018526034061539,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 4,
+ "nmse_db": -16.064878673206678,
+ "learning_gain": 0.5,
+ "peak_abs": 0.7250900146155623,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 5,
+ "nmse_db": -17.546025027179546,
+ "learning_gain": 0.5,
+ "peak_abs": 0.7395864648395971,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 6,
+ "nmse_db": -18.599203952634422,
+ "learning_gain": 0.5,
+ "peak_abs": 0.7544075937071119,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 7,
+ "nmse_db": -19.26574664368347,
+ "learning_gain": 0.5,
+ "peak_abs": 0.8154475833223841,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 8,
+ "nmse_db": -19.61351610911512,
+ "learning_gain": 0.5,
+ "peak_abs": 0.8708055502281201,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 9,
+ "nmse_db": -19.7208448894478,
+ "learning_gain": 0.5,
+ "peak_abs": 0.9209490802219078,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 10,
+ "nmse_db": -19.787095077806466,
+ "learning_gain": 0.25,
+ "peak_abs": 0.9436267020948125,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 11,
+ "nmse_db": -19.82030484493842,
+ "learning_gain": 0.25,
+ "peak_abs": 0.9652116076049032,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 12,
+ "nmse_db": -19.82633891344902,
+ "learning_gain": 0.25,
+ "peak_abs": 0.9769193530082703,
+ "limited_fraction": 0.00015499070055796651
+ },
+ {
+ "iteration": 13,
+ "nmse_db": -19.840663648618907,
+ "learning_gain": 0.125,
+ "peak_abs": 0.9769193530082703,
+ "limited_fraction": 0.00015499070055796651
+ },
+ {
+ "iteration": 14,
+ "nmse_db": -19.849756279491228,
+ "learning_gain": 0.125,
+ "peak_abs": 0.9769193530082702,
+ "limited_fraction": 0.00015499070055796651
+ },
+ {
+ "iteration": 15,
+ "nmse_db": -19.854128679160226,
+ "learning_gain": 0.125,
+ "peak_abs": 0.9769193530082704,
+ "limited_fraction": 0.00030998140111593303
+ },
+ {
+ "iteration": 16,
+ "nmse_db": -19.85424795486487,
+ "learning_gain": 0.125,
+ "peak_abs": 0.9769193530082704,
+ "limited_fraction": 0.00030998140111593303
+ }
+ ],
+ "test_stop_reason": "improvement_below_tolerance",
+ "test_plant_calls": 28,
+ "test_samples": 6452,
+ "test_inverse_gain": [
+ 0.8494279459322799,
+ -0.03201008698744541
+ ],
+ "test_plant_run_id": "run-20260913-231319-6a8293",
+ "test_plant_sha256": "ab8cdbc20c4fc541e4241c403be3df1ff5e67b7d5afda55622ffa65a1d35a79a"
+ },
+ "positions": [
+ {
+ "node": "dpd_input",
+ "names": [
+ "Input x"
+ ]
+ },
+ {
+ "node": "pa_input",
+ "names": [
+ "Predistorted u",
+ "ILC Ideal DPD · waveform-specific"
+ ]
+ },
+ {
+ "node": "pa_output",
+ "names": [
+ "Linear target g·x",
+ "With DPD · surrogate",
+ "Without DPD · surrogate",
+ "Without DPD · synthetic data",
+ "ILC Ideal DPD · waveform-specific"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "run-20260913-233152-aaedf3",
+ "device": "cuda",
+ "nmse": -7.155779838562012,
+ "ilc": {
+ "fit_samples": 19353,
+ "inverse_gain": [
+ 0.8494276800855682,
+ -0.03201018641669078
+ ],
+ "method": "ILC + ILA: optimize only the training waveform through the bound PA surrogate; fit Phi(y_ILC / G) w ~= u_ILC and copy that postdistorter to the predistorter. The test waveform is never used to fit coefficients. The separate Ideal baseline uses test-waveform feedback; it is not transferable, globally optimal, or measured hardware evidence.",
+ "peak_limit": 0.9769193530082703,
+ "plant_run_id": "run-20260913-231319-6a8293",
+ "plant_sha256": "ab8cdbc20c4fc541e4241c403be3df1ff5e67b7d5afda55622ffa65a1d35a79a",
+ "segment_length": 1024,
+ "settings": {
+ "K": 7,
+ "Q": 5,
+ "backtracking_steps": 6,
+ "fit_samples": 32768,
+ "iterations": 30,
+ "learning_gain": 0.5,
+ "min_improvement_db": 0.001,
+ "peak_factor": 1.5,
+ "rcond": 0.000001,
+ "target_nmse_db": -45
+ },
+ "target_gain": 1.0600225851920677,
+ "test_feedback_used_for_fit": false,
+ "training_history": [
+ {
+ "iteration": 0,
+ "learning_gain": 0,
+ "limited_fraction": 0,
+ "nmse_db": -3.494240263155196,
+ "peak_abs": 0.6512795457805705
+ },
+ {
+ "iteration": 1,
+ "learning_gain": 0.5,
+ "limited_fraction": 0,
+ "nmse_db": -8.279273611683356,
+ "peak_abs": 0.6467704043875584
+ },
+ {
+ "iteration": 2,
+ "learning_gain": 0.5,
+ "limited_fraction": 0,
+ "nmse_db": -11.65475761353437,
+ "peak_abs": 0.896476152845324
+ },
+ {
+ "iteration": 3,
+ "learning_gain": 0.5,
+ "limited_fraction": 0.00010334315093267194,
+ "nmse_db": -14.184106907924082,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 4,
+ "learning_gain": 0.5,
+ "limited_fraction": 0.00010334315093267194,
+ "nmse_db": -16.14532140981289,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 5,
+ "learning_gain": 0.5,
+ "limited_fraction": 0.0001550147263990079,
+ "nmse_db": -17.627660001528117,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 6,
+ "learning_gain": 0.5,
+ "limited_fraction": 0.0001550147263990079,
+ "nmse_db": -18.67617222100335,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 7,
+ "learning_gain": 0.5,
+ "limited_fraction": 0.0001550147263990079,
+ "nmse_db": -19.342116435076335,
+ "peak_abs": 0.9769193530082703
+ },
+ {
+ "iteration": 8,
+ "learning_gain": 0.5,
+ "limited_fraction": 0.0001550147263990079,
+ "nmse_db": -19.695192745563595,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 9,
+ "learning_gain": 0.5,
+ "limited_fraction": 0.00020668630186534387,
+ "nmse_db": -19.813204732371485,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 10,
+ "learning_gain": 0.25,
+ "limited_fraction": 0.00020668630186534387,
+ "nmse_db": -19.886015536734796,
+ "peak_abs": 0.9769193530082703
+ },
+ {
+ "iteration": 11,
+ "learning_gain": 0.25,
+ "limited_fraction": 0.00020668630186534387,
+ "nmse_db": -19.924736831097594,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 12,
+ "learning_gain": 0.25,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.93539083331513,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 13,
+ "learning_gain": 0.125,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.951639812516518,
+ "peak_abs": 0.9769193530082703
+ },
+ {
+ "iteration": 14,
+ "learning_gain": 0.125,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.96235519909753,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 15,
+ "learning_gain": 0.125,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.968041331115515,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 16,
+ "learning_gain": 0.125,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.969160029993656,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 17,
+ "learning_gain": 0.0625,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.972927920036877,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 18,
+ "learning_gain": 0.0625,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.97567658004469,
+ "peak_abs": 0.9769193530082704
+ },
+ {
+ "iteration": 19,
+ "learning_gain": 0.0625,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.97745249192264,
+ "peak_abs": 0.9769193530082703
+ },
+ {
+ "iteration": 20,
+ "learning_gain": 0.0625,
+ "limited_fraction": 0.0002583578773316798,
+ "nmse_db": -19.978300585731592,
+ "peak_abs": 0.9769193530082704
+ }
+ ],
+ "training_plant_calls": 44,
+ "training_stop_reason": "improvement_below_tolerance",
+ "version": "ilc-ila-v1",
+ "test_history": [
+ {
+ "iteration": 0,
+ "nmse_db": -3.5223755401969905,
+ "learning_gain": 0,
+ "peak_abs": 0.5484617996283938,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 1,
+ "nmse_db": -8.32027414537148,
+ "learning_gain": 0.5,
+ "peak_abs": 0.6093891486625578,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 2,
+ "nmse_db": -11.645328043887273,
+ "learning_gain": 0.5,
+ "peak_abs": 0.6665071422123694,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 3,
+ "nmse_db": -14.127733409483284,
+ "learning_gain": 0.5,
+ "peak_abs": 0.7018175123150482,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 4,
+ "nmse_db": -16.066391194342607,
+ "learning_gain": 0.5,
+ "peak_abs": 0.7250019325257651,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 5,
+ "nmse_db": -17.54747452838066,
+ "learning_gain": 0.5,
+ "peak_abs": 0.739513146027734,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 6,
+ "nmse_db": -18.600921558967208,
+ "learning_gain": 0.5,
+ "peak_abs": 0.7543795257888295,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 7,
+ "nmse_db": -19.267516521487323,
+ "learning_gain": 0.5,
+ "peak_abs": 0.8154157747326167,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 8,
+ "nmse_db": -19.61547823828799,
+ "learning_gain": 0.5,
+ "peak_abs": 0.8707671064835301,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 9,
+ "nmse_db": -19.72278872044499,
+ "learning_gain": 0.5,
+ "peak_abs": 0.9208942987149199,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 10,
+ "nmse_db": -19.789196189172046,
+ "learning_gain": 0.25,
+ "peak_abs": 0.9435797937805084,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 11,
+ "nmse_db": -19.822347747158652,
+ "learning_gain": 0.25,
+ "peak_abs": 0.9651553616764454,
+ "limited_fraction": 0
+ },
+ {
+ "iteration": 12,
+ "nmse_db": -19.828617066271267,
+ "learning_gain": 0.25,
+ "peak_abs": 0.9769193530082702,
+ "limited_fraction": 0.00015499070055796651
+ },
+ {
+ "iteration": 13,
+ "nmse_db": -19.842569432386096,
+ "learning_gain": 0.125,
+ "peak_abs": 0.9769193530082703,
+ "limited_fraction": 0.00015499070055796651
+ },
+ {
+ "iteration": 14,
+ "nmse_db": -19.85156466211895,
+ "learning_gain": 0.125,
+ "peak_abs": 0.9769193530082703,
+ "limited_fraction": 0.00015499070055796651
+ },
+ {
+ "iteration": 15,
+ "nmse_db": -19.856353512860096,
+ "learning_gain": 0.125,
+ "peak_abs": 0.9769193530082703,
+ "limited_fraction": 0.00030998140111593303
+ },
+ {
+ "iteration": 16,
+ "nmse_db": -19.856769158297126,
+ "learning_gain": 0.125,
+ "peak_abs": 0.9769193530082704,
+ "limited_fraction": 0.00030998140111593303
+ }
+ ],
+ "test_stop_reason": "improvement_below_tolerance",
+ "test_plant_calls": 28,
+ "test_samples": 6452,
+ "test_inverse_gain": [
+ 0.8494276800855682,
+ -0.03201018641669078
+ ],
+ "test_plant_run_id": "run-20260913-231319-6a8293",
+ "test_plant_sha256": "ab8cdbc20c4fc541e4241c403be3df1ff5e67b7d5afda55622ffa65a1d35a79a"
+ },
+ "positions": [
+ {
+ "node": "dpd_input",
+ "names": [
+ "Input x"
+ ]
+ },
+ {
+ "node": "pa_input",
+ "names": [
+ "Predistorted u",
+ "ILC Ideal DPD · waveform-specific"
+ ]
+ },
+ {
+ "node": "pa_output",
+ "names": [
+ "Linear target g·x",
+ "With DPD · surrogate",
+ "Without DPD · surrogate",
+ "Without DPD · synthetic data",
+ "ILC Ideal DPD · waveform-specific"
+ ]
+ }
+ ]
+ }
+ ],
+ "errors": [],
+ "removalAndUndo": true,
+ "latexModels": 9
+}
\ No newline at end of file
diff --git a/docs/performance/studio-2.2.5/local-workflow.json b/docs/performance/studio-2.2.5/local-workflow.json
new file mode 100644
index 0000000..1a63059
--- /dev/null
+++ b/docs/performance/studio-2.2.5/local-workflow.json
@@ -0,0 +1,50 @@
+[
+ {
+ "width": 1366,
+ "height": 768,
+ "initialSignal": "sg-96b4e2955d5633421ea49500932bd4891f162a7cf105b2a5f83952b550d015d0",
+ "customSignal": "sg-007ca778ad10ba7572a21596409c312d2efc7bdced6d5c5747e9bc615c569b50",
+ "simulation": "vpa-6fc76e3fbbb973986b10df25286a288fd15f53dd9334db13663edad25b38c5b7",
+ "inputOnly": true,
+ "exportsExactlyPaired": true,
+ "wifi8Coverage": "experimental",
+ "samples": 32768,
+ "activeCarriers": 158,
+ "pilotCarriers": 2,
+ "referenceEvmPercent": 2.214019926290765,
+ "dataset": "qa-generator-1366-mu0fiuww",
+ "testSamples": 6452,
+ "runs": [
+ "run-20260913-231246-b18587",
+ "run-20260913-231252-63aac1",
+ "run-20260913-231257-eca19e",
+ "run-20260913-231302-3a39d7"
+ ],
+ "errors": [],
+ "publications": []
+ },
+ {
+ "width": 1920,
+ "height": 1080,
+ "initialSignal": "sg-96b4e2955d5633421ea49500932bd4891f162a7cf105b2a5f83952b550d015d0",
+ "customSignal": "sg-007ca778ad10ba7572a21596409c312d2efc7bdced6d5c5747e9bc615c569b50",
+ "simulation": "vpa-6fc76e3fbbb973986b10df25286a288fd15f53dd9334db13663edad25b38c5b7",
+ "inputOnly": true,
+ "exportsExactlyPaired": true,
+ "wifi8Coverage": "experimental",
+ "samples": 32768,
+ "activeCarriers": 158,
+ "pilotCarriers": 2,
+ "referenceEvmPercent": 2.214019926290765,
+ "dataset": "qa-generator-1920-mu0fiuww",
+ "testSamples": 6452,
+ "runs": [
+ "run-20260913-231319-6a8293",
+ "run-20260913-231326-c46cc2",
+ "run-20260913-231331-4e16f4",
+ "run-20260913-231336-201496"
+ ],
+ "errors": [],
+ "publications": []
+ }
+]
diff --git a/docs/performance/studio-2.2.5/screenshots.json b/docs/performance/studio-2.2.5/screenshots.json
new file mode 100644
index 0000000..28c35e2
--- /dev/null
+++ b/docs/performance/studio-2.2.5/screenshots.json
@@ -0,0 +1,34 @@
+{
+ "version": "2.2.5",
+ "source": "installed wheel in isolated Python 3.12 environment; clean synthetic workspace",
+ "records": [
+ {
+ "file": "pics/studio-home.png",
+ "width": 1920,
+ "height": 1080,
+ "sha256": "34f8998d9aa04d36121332e7021d3c1d25ea9535c03baa9d2c62226fb26f4abe"
+ },
+ {
+ "file": "pics/studio-signal-generator.png",
+ "width": 1920,
+ "height": 1080,
+ "sha256": "9a03eee6d6e546009b0e3175636cbaaae321a8ad33533e5f2df890a5218f8b4c"
+ },
+ {
+ "file": "pics/studio-pa-library.png",
+ "width": 1920,
+ "height": 1080,
+ "sha256": "99fb7b8020f8241cc0d64802404c68c8ce54aa0aff637570f46ae757cde7b851"
+ },
+ {
+ "file": "pics/studio-ilc.png",
+ "source": "real CUDA ILC run in disposable synthetic workspace",
+ "sha256": "d2281673305458291d20398d8f4fb3a57d4c2c472af8e0519e94529fc4987152"
+ },
+ {
+ "file": "pics/studio-psd-chain.png",
+ "source": "real CUDA ILC run in disposable synthetic workspace",
+ "sha256": "215855b907301c10dfe10d17448646614a4aa2273d7f090354cc56ec118b5709"
+ }
+ ]
+}
diff --git a/docs/performance/studio-2.2.5/signal-chain.json b/docs/performance/studio-2.2.5/signal-chain.json
new file mode 100644
index 0000000..6440a38
--- /dev/null
+++ b/docs/performance/studio-2.2.5/signal-chain.json
@@ -0,0 +1,65 @@
+[
+ {
+ "width": 1366,
+ "height": 768,
+ "positions": [
+ [
+ "Input x"
+ ],
+ [
+ "Predistorted u"
+ ],
+ [
+ "Linear target g·x",
+ "With DPD · surrogate",
+ "Without DPD · surrogate",
+ "Without DPD · synthetic data"
+ ]
+ ],
+ "exactStoredBins": true,
+ "independentZoomAndLegend": true,
+ "errors": []
+ },
+ {
+ "width": 1920,
+ "height": 1080,
+ "positions": [
+ [
+ "Input x"
+ ],
+ [
+ "Predistorted u"
+ ],
+ [
+ "Linear target g·x",
+ "With DPD · surrogate",
+ "Without DPD · surrogate",
+ "Without DPD · synthetic data"
+ ]
+ ],
+ "exactStoredBins": true,
+ "independentZoomAndLegend": true,
+ "errors": []
+ },
+ {
+ "width": 390,
+ "height": 844,
+ "positions": [
+ [
+ "Input x"
+ ],
+ [
+ "Predistorted u"
+ ],
+ [
+ "Linear target g·x",
+ "With DPD · surrogate",
+ "Without DPD · surrogate",
+ "Without DPD · synthetic data"
+ ]
+ ],
+ "exactStoredBins": true,
+ "independentZoomAndLegend": true,
+ "errors": []
+ }
+]
diff --git a/docs/releases/hardening-report.md b/docs/releases/hardening-report.md
index 67b29ff..f8000a7 100644
--- a/docs/releases/hardening-report.md
+++ b/docs/releases/hardening-report.md
@@ -132,3 +132,7 @@ candidate label: tagging a release is a maintainer decision (S14).
browser launch.
- External trial and onboarding measurements (S14).
- GPU-tier performance and the full benchmark matrix (L4, human-approved).
+
+## 2.2.5 math-rendering boundary
+
+LaTeX uses bundled KaTeX and local fonts. `MathFormula.tsx` is the sole reviewed HTML sink: KaTeX escapes text, limits expansion and size, and trusts only known coefficient classes for interaction. URLs, resource inclusion and arbitrary HTML/style commands remain disabled. `MathFormula.test.tsx` covers malicious TeX/HTML and catalog interaction; the repository scanner continues to reject HTML sinks elsewhere. Existing CSP and tenant boundaries remain in force.
diff --git a/docs/releases/release-notes-2.2.5.md b/docs/releases/release-notes-2.2.5.md
new file mode 100644
index 0000000..9e0d9af
--- /dev/null
+++ b/docs/releases/release-notes-2.2.5.md
@@ -0,0 +1,36 @@
+# OpenDPD 2.2.5
+
+Studio adds interactive LaTeX formulas, configurable ILC/ILA experiments, clearer dataset controls and a simpler default installation.
+
+## Changes
+
+- Virtual PA equations render locally with KaTeX. Clicking a coefficient, slider or numeric input highlights the matching parameter and every occurrence in the equation.
+- Signal Generator creates an input only after **Generate & preview**. PA Input Datasets can be removed from the picker and restored with Undo; existing simulations retain their source bytes.
+- Custom OFDMA uses shared channel settings by default. Uncheck the option for independent subcarrier counts, modulation and power; checking it again copies channel 1 to all channels.
+- PA Training/Testing now includes **ILC linearization** after forward PA identification. DPD includes **ILC-DPD / Ideal benchmark**, with bounded feedback, backtracking, peak limits and configurable hyperparameters.
+- ILC learns training waveforms and ILA fits a transferable memory-polynomial DPD. The separately labelled Ideal baseline optimizes the test waveform with feedback. Results retain convergence, stop reasons, sample counts, plant identity and downloadable waveforms. Ideal input/output PSDs appear at their respective signal-chain positions.
+- Metric definitions render formulas in LaTeX. **Metric calculation** replaces the unexplained profile label; the result selector lives inside Metric definitions. Existing calculation protocols are unchanged.
+- Next-step controls sit above configuration and carry an arrow. About no longer requests or shows GitHub activity.
+- Default installation includes PyTorch, pywebview and the Studio server. Platform markers install Qt bindings on Linux and native bindings on macOS/Windows. The recommended uv commands select an appropriate PyTorch backend automatically.
+- Launcher messages explain localhost and SSH port forwarding. Failed browser opening leaves the server running; local health probes bypass HTTP proxies.
+- Updated README, installation and workflow guides, support records and actual GUI screenshots. CI checks default installation on Linux, macOS and Windows.
+
+## Install
+
+Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then:
+
+```sh
+mkdir opendpd-lab
+cd opendpd-lab
+uv venv --python 3.12
+uv pip install --python .venv "opendpd==2.2.5" --torch-backend=auto
+uv run --no-project --python .venv opendpd gui
+```
+
+[Installation and SSH access](../install.md) · [Hosted Studio](https://opendpd.com/studio/) · [ILC guide](../guides/ilc-dpd.md)
+
+## Evidence and scope
+
+[Validation record](../performance/studio-2.2.5.md). ILC is experimental waveform control through a trained surrogate. The Ideal reference is specific to the tested waveform, not proof of a global optimum or physical PA performance. The reported defaults are starting values; target convergence is not guaranteed. Polynomial fitting remains CPU complex128 while PA replay uses the selected device.
+
+Virtual PAs and generated pairs remain synthetic. Public sessions retain isolation, quotas and expiry. OS GUI libraries and GPU drivers remain platform prerequisites; installation checks do not establish native-window or accelerator support on every device. Human dataset review and independent RF/EVM acceptance requirements remain unchanged.
diff --git a/docs/releases/studio-progress.md b/docs/releases/studio-progress.md
index 1b2c95b..6899fdd 100644
--- a/docs/releases/studio-progress.md
+++ b/docs/releases/studio-progress.md
@@ -58,7 +58,7 @@ repository; "pending human" means a maintainer decision is required;
| Model list from the registry; CLI choices and GUI options not hand-written twice; unknown models cannot be submitted | done for the new path (`opendpd models`, `test_registry_covers_every_working_legacy_backbone`); the legacy parser keeps its own list for compatibility |
| MP/GMP-style non-neural methods can express their own fitting logic | done (S12): `mp_ls` / `gmp_ls` are registry models with `training_method: least_squares`; `opendpd/core/polynomial.py` fits them (column-normalised truncated SVD, diagnostics recorded) and `opendpd/services/polynomial.py` runs them as ordinary runs (PA: direct least squares; DPD: indirect learning on the measured train split, scored through a gradient-trained surrogate); the result states the training path |
| Legacy commands and public API compatibility tests pass; deprecations announced | done (existing suite passes; nothing deprecated yet) |
-| Plain `pip install opendpd` needs no GUI dependency; runs work from a read-only install into an external workspace | done (`tests/packaging/test_wheel_install.py`, `tests/unit/test_lazy_imports.py`) |
+| Default installation includes Studio/native dependencies since 2.2.5; core imports remain lazy and runs use an external workspace | done (`tests/packaging/test_wheel_install.py`, `tests/unit/test_lazy_imports.py`) |
## S03 acceptance items
diff --git a/docs/releases/support-matrix.md b/docs/releases/support-matrix.md
index aa1e6fb..fb3a481 100644
--- a/docs/releases/support-matrix.md
+++ b/docs/releases/support-matrix.md
@@ -5,7 +5,7 @@ designed for but no test evidence yet; **unsupported** = out of scope.
A platform being able to open the page does not imply every model or
accelerator path is supported, so three tables are kept separately.
-Latest feature validation: [Studio 2.2.4](../performance/studio-2.2.4.md), 2026-09-13. Linux local generation, Virtual PA simulation, PA/DPD training/testing, separated PSD views and figure replay are verified; this does not broaden native-platform support.
+Latest feature validation: [Studio 2.2.5](../performance/studio-2.2.5.md), 2026-09-14. Includes explicit generation, LaTeX interaction, ILC/ILA and installation checks. Default-install CI now covers Linux, macOS and Windows dependencies and the headless browser fallback; native-window and hardware-specific verification remain separate below.
Native baseline update: 2026-09-11 (native macOS fix verified in [the follow-up report](https://github.com/lab-emi/OpenDPD/blob/64d3e5e76b25d2d9dd59c91bf5c70c1d3ab2c698/docs/baseline/native-window-macos-fix-2026-09-11.md); S13 baseline in `docs/baseline/baseline-report.md`, hardening in `docs/releases/hardening-report.md`).
@@ -27,7 +27,7 @@ Native baseline update: 2026-09-11 (native macOS fix verified in [the follow-up
| macOS Apple Silicon | 3.11–3.13 | native surface verified for the exercised paths on 3.13.12; **browser launch unverified** | native launch, real CPU training and idle cleanup were exercised on a desktop (see Native window below, including the repaired active-close flow); real default-browser opening still needs verification; weekly CI covers the headless service |
| Windows x86-64 | 3.11–3.13 | **unverified** | needs a person: browser opening, process-group cleanup, paths with spaces/Unicode |
-## Native window (`opendpd[desktop]`)
+## Native window (included by default since 2.2.5)
| OS | Backend | Status | Evidence |
|---|---|---|---|
diff --git a/docs/testing.md b/docs/testing.md
index 86450ea..c3c4e92 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -7,7 +7,8 @@ The [CI workflow](https://github.com/lab-emi/OpenDPD/actions/workflows/ci.yml) c
From an activated development environment in the source checkout:
```bash
-python -m pip install -e ".[dev,gui,desktop]"
+uv venv --python 3.12
+uv pip install --python .venv -e ".[dev]" --torch-backend=auto
npm --prefix frontend ci
npm --prefix frontend run build
python -m pytest tests/ -m "not extended"
@@ -64,3 +65,7 @@ The documentation integration test validates CLI flags and runs real CPU experim
├── main.py # Legacy CLI entry mirrored by opendpd-cli
└── project.py # Core configuration & training orchestration
```
+
+## Default installation checks
+
+CI installs the default package on Linux, macOS and Windows with uv automatic PyTorch backend selection. `scripts/check_install.py` verifies platform bindings and tensor arithmetic on each available CPU/CUDA/MPS backend. A subprocess regression checks that a failed browser opener leaves the service reachable. This does not claim a native window was manually exercised on all three platforms.
diff --git a/docs/training.md b/docs/training.md
index bedc2ad..1f0166b 100644
--- a/docs/training.md
+++ b/docs/training.md
@@ -75,3 +75,7 @@ Python users can call `train_pa`, `train_dpd`, `run_dpd` and `plot_dpd`; see the
## Starting in Studio 2.2.4
A [generated signal](guides/signal-generator.md) is a **PA Input Dataset**, not a training pair. Use the [Virtual PA Library](guides/virtual-pa-library.md) to explicitly synthesize y, or supply measured input/output pairs. The Virtual PA is the mathematical simulation device; PA Model Training subsequently learns a separate surrogate from its dataset. PA and DPD each combine Training and Testing tabs. Live DPD previews include x, u and PA(u) from the same bounded validation forward pass, with [separate PSD panels](guides/signal-chain-spectra.md).
+
+## ILC linearization reference
+
+Studio 2.2.5 adds `dpd-ilc-ila-v1` (`ilc_dpd`): bounded gain-inverse waveform learning through a trained PA surrogate, followed by a training-only ILA memory-polynomial fit. PA and DPD pages expose this workflow, including conservative default hyperparameters and a separate Ideal test-waveform reference. See [ILC and ILA DPD](guides/ilc-dpd.md) for equations, convergence, artifacts and evidence limits.
diff --git a/docs/tutorials/gui-quickstart.md b/docs/tutorials/gui-quickstart.md
index 8021267..5b84169 100644
--- a/docs/tutorials/gui-quickstart.md
+++ b/docs/tutorials/gui-quickstart.md
@@ -1,6 +1,6 @@
# Your first experiment in Studio
-Open [Studio on the web](https://opendpd.com/studio/), or install the packaged local app with `pip install "opendpd[gui]==2.2.4"`.
+Open [Studio on the web](https://opendpd.com/studio/), or follow the [uv installation steps](../install.md) for the packaged local app. Studio, PyTorch and pywebview install together in 2.2.5.
Get Started now offers **Signal Generator** first, then existing datasets and CSV upload. The [Signal Generator guide](../guides/signal-generator.md) walks through creating and inspecting a waveform and creating a PA Input Dataset, then explicitly simulating a Virtual PA output in the [PA Library](../guides/virtual-pa-library.md) to make paired synthetic data. Each PA Model and DPD Model workspace contains Training and Testing tabs; Testing displays the selected split's exact complex I/Q count.
@@ -73,6 +73,12 @@ Select a light, dark or system theme in **Settings**. The layout adapts to the w
A second launch for the same active workspace opens the existing instance. Close Studio or press Ctrl+C to stop the launcher; active workers trigger a confirmation before shutdown.
-The native window uses the operating system's web view. Run `opendpd doctor` for backend diagnostics; on Linux a WebKit2GTK installation may need `python3-gi gir1.2-webkit2-4.1`, or install a Qt backend with `python -m pip install "pywebview[qt]"`. Consult the [support matrix](../releases/support-matrix.md) for verified platforms.
+The native window uses the operating system's web view. Run `opendpd doctor` for backend diagnostics; on Linux a WebKit2GTK installation may need `python3-gi gir1.2-webkit2-4.1`, or use the Qt backend installed by default on Linux. Consult the [support matrix](../releases/support-matrix.md) for verified platforms.
-For remote use, keep the server on loopback and use an SSH tunnel, for example `ssh -L 8765:127.0.0.1:8765 host`, with `--no-browser` on the host. If the session expires, reopen the current launcher's URL. Other setup problems are covered in [Installation](../install.md#troubleshooting).
+For remote use, keep the server on loopback and use an SSH tunnel, for example `ssh -L 8765:127.0.0.1:8765 host`, with `--no-browser` on the host. If the session expires, reopen the current launcher's URL. Other setup problems are covered in [Installation](../install.md#troubleshooting-connection-refused).
+
+## ILC and formulas (2.2.5)
+
+PA Training/Testing includes an ILC linearization tab; DPD Training/Testing includes an ILC-DPD / Ideal benchmark tab. Both use the same bounded, recorded controller and ILA fitting workflow described in the [ILC guide](../guides/ilc-dpd.md). Select a trained PA reference first. The main score is the fitted DPD on held-out input; the Ideal test-waveform feedback result is labelled separately.
+
+Virtual PA equations and result metric formulas use local LaTeX rendering. Click a formula coefficient or its parameter field to highlight the pair. Next-step buttons appear above configuration fields. The result's calculation-method chip uses a descriptive name; expand Metric definitions to change the method or inspect its recorded version.
diff --git a/docs/whats-new.md b/docs/whats-new.md
index 6efce4c..daee043 100644
--- a/docs/whats-new.md
+++ b/docs/whats-new.md
@@ -1,5 +1,15 @@
# What's new
+## 2.2.5: ILC, interactive LaTeX and simpler installation
+
+- ILC + ILA DPD with a separate waveform-specific Ideal test reference, configurable bounds and convergence evidence.
+- LaTeX Virtual PA equations with linked controls, and LaTeX metric definitions.
+- Shared/per-channel OFDMA controls, explicit-only input generation and reversible input removal.
+- Next-step controls above settings; clearer metric calculation labels; About without GitHub activity.
+- Default PyTorch/Studio/pywebview dependencies, uv environment instructions, MPS selection and clearer localhost/SSH diagnostics.
+
+[Release notes](releases/release-notes-2.2.5.md) · [Installation](install.md) · [ILC guide](guides/ilc-dpd.md)
+
## 2.2.4: Virtual PA Library and signal-chain PSD views
Signal Generator exports input-only I/Q. PA Library provides nine mathematical Virtual PAs, parameter-linked formulas, explicit output simulation and paired dataset creation. The expandable workflow diagram separates dataset making from PA/DPD model training and recognizes existing paired datasets.
diff --git a/frontend/e2e/journey.spec.ts b/frontend/e2e/journey.spec.ts
index 6c3038f..dff0aed 100644
--- a/frontend/e2e/journey.spec.ts
+++ b/frontend/e2e/journey.spec.ts
@@ -17,11 +17,11 @@ test.describe('J1 — reproduce the built-in example (mock API)', () => {
await page.getByLabel('Name (optional)').fill('e2e smoke')
// the metric profile is an explicit, registry-backed choice (S08); the default stays the frozen legacy one
await page.getByRole('button', { name: 'Advanced settings' }).click()
- const profile = page.getByRole('combobox', { name: 'Metric profile' })
- await expect(profile).toContainText('legacy-opendpd-v1')
+ const profile = page.getByRole('combobox', { name: 'Metric calculation' })
+ await expect(profile).toContainText('OpenDPD legacy')
await profile.focus()
await page.keyboard.press('ArrowDown')
- await page.getByRole('option', { name: /general-spectral-v1/ }).click()
+ await page.getByRole('option', { name: /General baseband/ }).click()
await expect(page.getByText('Configuration is valid')).toBeVisible()
await page.getByRole('button', { name: 'Continue' }).click()
await page.getByRole('button', { name: 'Start run' }).click()
@@ -48,11 +48,11 @@ test.describe('J1 — reproduce the built-in example (mock API)', () => {
await expect(page.locator('[data-metric="NMSE"]')).toContainText('dB')
await expect(page.locator('[data-metric="NMSE"]')).toContainText('lower is better')
await expect(page.locator('[data-profile="legacy-opendpd-v1"]')).toBeVisible()
- await page.getByLabel('Metric profile').click()
- await page.getByRole('option', { name: 'general-spectral-v1' }).click()
+ await page.getByRole('button', { name: 'Metric definitions', exact: true }).click()
+ await page.getByLabel('Metric calculation').click()
+ await page.getByRole('option', { name: 'General baseband · pooled error & leakage' }).click()
await expect(page.locator('[data-profile="general-spectral-v1"]')).toBeVisible()
await expect(page.locator('[data-metric="IBE"]')).toContainText('-23.10 dB')
- await page.getByRole('button', { name: 'Metric definitions' }).click()
await expect(page.getByText(/pooled over all valid samples/)).toBeVisible()
})
diff --git a/frontend/mocks/comparison_report_mock.json b/frontend/mocks/comparison_report_mock.json
index 07e08e7..61469c2 100644
--- a/frontend/mocks/comparison_report_mock.json
+++ b/frontend/mocks/comparison_report_mock.json
@@ -42,6 +42,7 @@
"sha256": null,
"size_bytes": null
},
+ "ilc": null,
"is_mock": true,
"limitations": [
"MOCK DATA for UI development; not a computed result",
@@ -253,6 +254,7 @@
"extra": {},
"generated_at": "2026-09-06T08:00:00Z",
"history": null,
+ "ilc": null,
"is_mock": true,
"limitations": [
"MOCK DATA for UI development",
diff --git a/frontend/mocks/generator_presets.json b/frontend/mocks/generator_presets.json
index 37fbb79..73bad8e 100644
--- a/frontend/mocks/generator_presets.json
+++ b/frontend/mocks/generator_presets.json
@@ -42,6 +42,7 @@
"sample_rate_hz": 122880000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -93,6 +94,7 @@
"sample_rate_hz": 491520000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -144,6 +146,7 @@
"sample_rate_hz": 491520000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -195,6 +198,7 @@
"sample_rate_hz": 80000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -246,6 +250,7 @@
"sample_rate_hz": 160000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -297,6 +302,7 @@
"sample_rate_hz": 320000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -351,6 +357,7 @@
"sample_rate_hz": 640000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -402,6 +409,7 @@
"sample_rate_hz": 80000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -453,6 +461,7 @@
"sample_rate_hz": 160000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -504,6 +513,7 @@
"sample_rate_hz": 320000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -558,6 +568,7 @@
"sample_rate_hz": 640000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -618,6 +629,7 @@
"sample_rate_hz": 1280000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -669,6 +681,7 @@
"sample_rate_hz": 320000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -723,6 +736,7 @@
"sample_rate_hz": 640000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -783,6 +797,7 @@
"sample_rate_hz": 1280000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -834,6 +849,7 @@
"sample_rate_hz": 80000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -885,6 +901,7 @@
"sample_rate_hz": 80000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -936,6 +953,7 @@
"sample_rate_hz": 80000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -987,6 +1005,7 @@
"sample_rate_hz": 80000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
@@ -1038,6 +1057,7 @@
"sample_rate_hz": 80000000.0,
"samples_per_symbol": 8,
"seed": 42,
+ "shared_channel_settings": true,
"snr_db": null,
"tone_count": 8,
"tone_frequency_hz": 1000000.0,
diff --git a/frontend/mocks/result_dpd_measured_mock.json b/frontend/mocks/result_dpd_measured_mock.json
index be4a773..efa0800 100644
--- a/frontend/mocks/result_dpd_measured_mock.json
+++ b/frontend/mocks/result_dpd_measured_mock.json
@@ -66,6 +66,7 @@
"extra": {},
"generated_at": "2026-09-06T08:00:00Z",
"history": null,
+ "ilc": null,
"is_mock": true,
"limitations": [
"MOCK DATA for UI development",
diff --git a/frontend/mocks/result_dpd_surrogate_mock.json b/frontend/mocks/result_dpd_surrogate_mock.json
index 6ead9e3..e2a4bfe 100644
--- a/frontend/mocks/result_dpd_surrogate_mock.json
+++ b/frontend/mocks/result_dpd_surrogate_mock.json
@@ -112,6 +112,7 @@
"extra": {},
"generated_at": "2026-09-06T08:00:00Z",
"history": null,
+ "ilc": null,
"is_mock": true,
"limitations": [
"MOCK DATA for UI development",
diff --git a/frontend/mocks/result_legacy_import.json b/frontend/mocks/result_legacy_import.json
index d1f3112..c9aad03 100644
--- a/frontend/mocks/result_legacy_import.json
+++ b/frontend/mocks/result_legacy_import.json
@@ -19,6 +19,7 @@
"extra": {},
"generated_at": "2026-09-06T08:00:00Z",
"history": null,
+ "ilc": null,
"is_mock": false,
"limitations": [
"imported from a legacy CSV log: software version, git commit, device and data hashes unknown",
diff --git a/frontend/mocks/result_metric_not_applicable_mock.json b/frontend/mocks/result_metric_not_applicable_mock.json
index 9e3eb62..74037f7 100644
--- a/frontend/mocks/result_metric_not_applicable_mock.json
+++ b/frontend/mocks/result_metric_not_applicable_mock.json
@@ -19,6 +19,7 @@
"extra": {},
"generated_at": "2026-09-06T08:00:00Z",
"history": null,
+ "ilc": null,
"is_mock": true,
"limitations": [
"MOCK DATA for UI development",
diff --git a/frontend/mocks/result_pa_modeling_mock.json b/frontend/mocks/result_pa_modeling_mock.json
index a57ba28..63da241 100644
--- a/frontend/mocks/result_pa_modeling_mock.json
+++ b/frontend/mocks/result_pa_modeling_mock.json
@@ -23,6 +23,7 @@
"sha256": null,
"size_bytes": null
},
+ "ilc": null,
"is_mock": true,
"limitations": [
"MOCK DATA for UI development; not a computed result",
diff --git a/frontend/mocks/result_pa_streaming_mock.json b/frontend/mocks/result_pa_streaming_mock.json
index e54222b..754afe7 100644
--- a/frontend/mocks/result_pa_streaming_mock.json
+++ b/frontend/mocks/result_pa_streaming_mock.json
@@ -37,6 +37,7 @@
"sha256": null,
"size_bytes": null
},
+ "ilc": null,
"is_mock": true,
"limitations": [
"MOCK DATA for UI development; not a computed result",
diff --git a/frontend/mocks/virtual_pa_models.json b/frontend/mocks/virtual_pa_models.json
index e73c167..67ffd1e 100644
--- a/frontend/mocks/virtual_pa_models.json
+++ b/frontend/mocks/virtual_pa_models.json
@@ -11,6 +11,9 @@
"equations": [
"y[n] = {{gain}}\u00b7x[n]\u00b7exp(j\u00b7{{phase_deg}}\u00b7\u03c0/180)"
],
+ "equations_latex": [
+ "y[n]={{gain}}x[n]e^{j{{phase_deg}}\\pi/180}"
+ ],
"limitations": {
"en": "Illustrative complex-baseband behavior, with normalized amplitudes. Defaults are not extracted from a specific device; no calibrated RF power, efficiency or transistor-level prediction.",
"zh": "\u5f52\u4e00\u5316\u5e45\u5ea6\u7684\u590d\u57fa\u5e26\u793a\u610f\u6a21\u578b\u3002\u9ed8\u8ba4\u53c2\u6570\u672a\u4ece\u5177\u4f53\u5668\u4ef6\u63d0\u53d6\uff0c\u4e0d\u4ee3\u8868\u6821\u51c6\u5c04\u9891\u529f\u7387\u3001\u6548\u7387\u6216\u6676\u4f53\u7ba1\u7ea7\u9884\u6d4b\u3002"
@@ -39,6 +42,7 @@
"minimum": 0.1,
"step": 0.05,
"symbol": "G",
+ "symbol_latex": "G",
"unit": "V/V"
},
{
@@ -59,6 +63,7 @@
"minimum": -180.0,
"step": 1.0,
"symbol": "\u03b8",
+ "symbol_latex": "\\theta",
"unit": "\u00b0"
}
],
@@ -75,6 +80,10 @@
"R(x) = {{gain}}\u00b7x / [1 + ({{gain}}\u00b7|x|/{{saturation}})^(2\u00b7{{smoothness}})]^(1/(2\u00b7{{smoothness}}))",
"y[n] = R(x[n])"
],
+ "equations_latex": [
+ "R(x)=\\frac{{{gain}}x}{[1+({{gain}}|x|/{{saturation}})^{2{{smoothness}}}]^{1/(2{{smoothness}})}}",
+ "y[n]=R(x[n])"
+ ],
"limitations": {
"en": "Illustrative complex-baseband behavior, with normalized amplitudes. Defaults are not extracted from a specific device; no calibrated RF power, efficiency or transistor-level prediction.",
"zh": "\u5f52\u4e00\u5316\u5e45\u5ea6\u7684\u590d\u57fa\u5e26\u793a\u610f\u6a21\u578b\u3002\u9ed8\u8ba4\u53c2\u6570\u672a\u4ece\u5177\u4f53\u5668\u4ef6\u63d0\u53d6\uff0c\u4e0d\u4ee3\u8868\u6821\u51c6\u5c04\u9891\u529f\u7387\u3001\u6548\u7387\u6216\u6676\u4f53\u7ba1\u7ea7\u9884\u6d4b\u3002"
@@ -103,6 +112,7 @@
"minimum": 0.1,
"step": 0.05,
"symbol": "G",
+ "symbol_latex": "G",
"unit": "V/V"
},
{
@@ -123,6 +133,7 @@
"minimum": 0.05,
"step": 0.01,
"symbol": "s",
+ "symbol_latex": "s",
"unit": "normalized"
},
{
@@ -143,6 +154,7 @@
"minimum": 0.5,
"step": 0.1,
"symbol": "p",
+ "symbol_latex": "p",
"unit": ""
}
],
@@ -162,6 +174,11 @@
"\u03a6(r) = {{phase}}\u00b7r\u00b2 / (r\u00b2 + {{phase_scale}}\u00b2)",
"y[n] = R(x[n])\u00b7exp(j\u00b7\u03a6(|x[n]|))"
],
+ "equations_latex": [
+ "R(x)=\\frac{{{gain}}x}{[1+({{gain}}|x|/{{saturation}})^{2{{smoothness}}}]^{1/(2{{smoothness}})}}",
+ "\\Phi(r)=\\frac{{{phase}}r^2}{r^2+{{phase_scale}}^2}",
+ "y[n]=R(x[n])e^{j\\Phi(|x[n]|)}"
+ ],
"limitations": {
"en": "Illustrative complex-baseband behavior, with normalized amplitudes. Defaults are not extracted from a specific device; no calibrated RF power, efficiency or transistor-level prediction.",
"zh": "\u5f52\u4e00\u5316\u5e45\u5ea6\u7684\u590d\u57fa\u5e26\u793a\u610f\u6a21\u578b\u3002\u9ed8\u8ba4\u53c2\u6570\u672a\u4ece\u5177\u4f53\u5668\u4ef6\u63d0\u53d6\uff0c\u4e0d\u4ee3\u8868\u6821\u51c6\u5c04\u9891\u529f\u7387\u3001\u6548\u7387\u6216\u6676\u4f53\u7ba1\u7ea7\u9884\u6d4b\u3002"
@@ -190,6 +207,7 @@
"minimum": 0.1,
"step": 0.05,
"symbol": "G",
+ "symbol_latex": "G",
"unit": "V/V"
},
{
@@ -210,6 +228,7 @@
"minimum": 0.05,
"step": 0.01,
"symbol": "s",
+ "symbol_latex": "s",
"unit": "normalized"
},
{
@@ -230,6 +249,7 @@
"minimum": 0.5,
"step": 0.1,
"symbol": "p",
+ "symbol_latex": "p",
"unit": ""
},
{
@@ -250,6 +270,7 @@
"minimum": -1.5,
"step": 0.01,
"symbol": "\u03c6\u221e",
+ "symbol_latex": "\\phi_\\infty",
"unit": "rad"
},
{
@@ -270,6 +291,7 @@
"minimum": 0.03,
"step": 0.01,
"symbol": "b",
+ "symbol_latex": "b",
"unit": "normalized"
}
],
@@ -289,6 +311,11 @@
"A(r) = {{gain}}\u00b7r / (1 + {{compression}}\u00b7r\u00b2)",
"\u03a6(r) = {{phase}}\u00b7r\u00b2 / (1 + {{phase_scale}}\u00b7r\u00b2)"
],
+ "equations_latex": [
+ "y[n]=A(r)e^{j(\\arg x[n]+\\Phi(r))},\\quad r=|x[n]|",
+ "A(r)=\\frac{{{gain}}r}{1+{{compression}}r^2}",
+ "\\Phi(r)=\\frac{{{phase}}r^2}{1+{{phase_scale}}r^2}"
+ ],
"limitations": {
"en": "Illustrative complex-baseband behavior, with normalized amplitudes. Defaults are not extracted from a specific device; no calibrated RF power, efficiency or transistor-level prediction.",
"zh": "\u5f52\u4e00\u5316\u5e45\u5ea6\u7684\u590d\u57fa\u5e26\u793a\u610f\u6a21\u578b\u3002\u9ed8\u8ba4\u53c2\u6570\u672a\u4ece\u5177\u4f53\u5668\u4ef6\u63d0\u53d6\uff0c\u4e0d\u4ee3\u8868\u6821\u51c6\u5c04\u9891\u529f\u7387\u3001\u6548\u7387\u6216\u6676\u4f53\u7ba1\u7ea7\u9884\u6d4b\u3002"
@@ -317,6 +344,7 @@
"minimum": 0.1,
"step": 0.05,
"symbol": "G",
+ "symbol_latex": "G",
"unit": "V/V"
},
{
@@ -337,6 +365,7 @@
"minimum": 0.05,
"step": 0.05,
"symbol": "\u03b2\u2090",
+ "symbol_latex": "\\beta_a",
"unit": ""
},
{
@@ -357,6 +386,7 @@
"minimum": -3.0,
"step": 0.05,
"symbol": "\u03b1\u03c6",
+ "symbol_latex": "\\alpha_\\phi",
"unit": "rad"
},
{
@@ -377,6 +407,7 @@
"minimum": 0.1,
"step": 0.1,
"symbol": "\u03b2\u03c6",
+ "symbol_latex": "\\beta_\\phi",
"unit": ""
}
],
@@ -397,6 +428,12 @@
"z[n] = u[n] + {{memory}}\u00b7\u03a3\u2098\u208c\u2081^{{depth}} w\u2098\u00b7u[n\u2212m]",
"y[n] = z[n]"
],
+ "equations_latex": [
+ "u[n]={{gain}}x[n]+{{cubic}}x[n]|x[n]|^2+{{quintic}}x[n]|x[n]|^4",
+ "w_m=\\frac{{{decay}}^{m-1}e^{jm{{memory_phase}}\\pi/180}}{\\sum_{k=1}^{{{depth}}}{{decay}}^{k-1}}",
+ "z[n]=u[n]+{{memory}}\\sum_{m=1}^{{{depth}}} w_m u[n-m]",
+ "y[n]=z[n]"
+ ],
"limitations": {
"en": "Illustrative complex-baseband behavior, with normalized amplitudes. Defaults are not extracted from a specific device; no calibrated RF power, efficiency or transistor-level prediction.",
"zh": "\u5f52\u4e00\u5316\u5e45\u5ea6\u7684\u590d\u57fa\u5e26\u793a\u610f\u6a21\u578b\u3002\u9ed8\u8ba4\u53c2\u6570\u672a\u4ece\u5177\u4f53\u5668\u4ef6\u63d0\u53d6\uff0c\u4e0d\u4ee3\u8868\u6821\u51c6\u5c04\u9891\u529f\u7387\u3001\u6548\u7387\u6216\u6676\u4f53\u7ba1\u7ea7\u9884\u6d4b\u3002"
@@ -425,6 +462,7 @@
"minimum": 0.1,
"step": 0.05,
"symbol": "G",
+ "symbol_latex": "G",
"unit": "V/V"
},
{
@@ -445,6 +483,7 @@
"minimum": -5.0,
"step": 0.05,
"symbol": "c\u2083",
+ "symbol_latex": "c_3",
"unit": ""
},
{
@@ -465,6 +504,7 @@
"minimum": -2.0,
"step": 0.05,
"symbol": "c\u2085",
+ "symbol_latex": "c_5",
"unit": ""
},
{
@@ -485,6 +525,7 @@
"minimum": 0.0,
"step": 0.01,
"symbol": "\u03bc",
+ "symbol_latex": "\\mu",
"unit": ""
},
{
@@ -505,6 +546,7 @@
"minimum": 1.0,
"step": 1.0,
"symbol": "M",
+ "symbol_latex": "M",
"unit": "samples"
},
{
@@ -525,6 +567,7 @@
"minimum": 0.05,
"step": 0.01,
"symbol": "d",
+ "symbol_latex": "d",
"unit": ""
},
{
@@ -545,6 +588,7 @@
"minimum": -90.0,
"step": 1.0,
"symbol": "\u03c8",
+ "symbol_latex": "\\psi",
"unit": "\u00b0"
}
],
@@ -567,6 +611,13 @@
"q[n] = \u03a3\u2098\u208c\u2081^{{depth}} ({{decay}}^(m\u22121)/\u03a3\u2096\u208c\u2081^{{depth}} {{decay}}^(k\u22121))\u00b7|x[n\u2212m]|\u00b2",
"y[n] = z[n] + {{cross_memory}}\u00b7x[n]\u00b7q[n]"
],
+ "equations_latex": [
+ "u[n]={{gain}}x[n]+{{cubic}}x[n]|x[n]|^2+{{quintic}}x[n]|x[n]|^4",
+ "w_m=\\frac{{{decay}}^{m-1}e^{jm{{memory_phase}}\\pi/180}}{\\sum_{k=1}^{{{depth}}}{{decay}}^{k-1}}",
+ "z[n]=u[n]+{{memory}}\\sum_{m=1}^{{{depth}}} w_m u[n-m]",
+ "q[n]=\\sum_{m=1}^{{{depth}}}\\frac{{{decay}}^{m-1}}{\\sum_{k=1}^{{{depth}}}{{decay}}^{k-1}}|x[n-m]|^2",
+ "y[n]=z[n]+{{cross_memory}}x[n]q[n]"
+ ],
"limitations": {
"en": "Illustrative complex-baseband behavior, with normalized amplitudes. Defaults are not extracted from a specific device; no calibrated RF power, efficiency or transistor-level prediction.",
"zh": "\u5f52\u4e00\u5316\u5e45\u5ea6\u7684\u590d\u57fa\u5e26\u793a\u610f\u6a21\u578b\u3002\u9ed8\u8ba4\u53c2\u6570\u672a\u4ece\u5177\u4f53\u5668\u4ef6\u63d0\u53d6\uff0c\u4e0d\u4ee3\u8868\u6821\u51c6\u5c04\u9891\u529f\u7387\u3001\u6548\u7387\u6216\u6676\u4f53\u7ba1\u7ea7\u9884\u6d4b\u3002"
@@ -595,6 +646,7 @@
"minimum": 0.1,
"step": 0.05,
"symbol": "G",
+ "symbol_latex": "G",
"unit": "V/V"
},
{
@@ -615,6 +667,7 @@
"minimum": -5.0,
"step": 0.05,
"symbol": "c\u2083",
+ "symbol_latex": "c_3",
"unit": ""
},
{
@@ -635,6 +688,7 @@
"minimum": -2.0,
"step": 0.05,
"symbol": "c\u2085",
+ "symbol_latex": "c_5",
"unit": ""
},
{
@@ -655,6 +709,7 @@
"minimum": 0.0,
"step": 0.01,
"symbol": "\u03bc",
+ "symbol_latex": "\\mu",
"unit": ""
},
{
@@ -675,6 +730,7 @@
"minimum": 1.0,
"step": 1.0,
"symbol": "M",
+ "symbol_latex": "M",
"unit": "samples"
},
{
@@ -695,6 +751,7 @@
"minimum": 0.05,
"step": 0.01,
"symbol": "d",
+ "symbol_latex": "d",
"unit": ""
},
{
@@ -715,6 +772,7 @@
"minimum": -90.0,
"step": 1.0,
"symbol": "\u03c8",
+ "symbol_latex": "\\psi",
"unit": "\u00b0"
},
{
@@ -735,6 +793,7 @@
"minimum": -3.0,
"step": 0.05,
"symbol": "c\u00d7",
+ "symbol_latex": "c_\\times",
"unit": ""
}
],
@@ -760,6 +819,18 @@
"y[n] = R(x[n])\u00b7(1\u2212{{trap_strength}}\u00b7q\u209c[n])\u00b7v[n]\u00b7exp(\u2212{{thermal_gain}}\u00b7(T[n]\u221225) + j\u00b7{{trap_phase}}\u00b7q\u209c[n])",
"LPF(P,\u03c4)[n] = a\u00b7LPF[n\u22121] + (1\u2212a)\u00b7P[n]; a = exp(\u22121/(Fs\u00b7\u03c4\u00b710\u207b\u2076)); \u03c4 in \u00b5s; kB = 8.617333262\u00d710\u207b\u2075 eV/K"
],
+ "equations_latex": [
+ "R(x)=\\frac{{{gain}}x}{[1+({{gain}}|x|/{{saturation}})^{2{{smoothness}}}]^{1/(2{{smoothness}})}}",
+ "P[n]=\\frac{|x[n]|^2}{|x[n]|^2+({{saturation}}/{{gain}})^2}",
+ "T[n]={{ambient_c}}+{{heating_c}}\\operatorname{LPF}(P,{{thermal_us}})",
+ "\\tau_e[n]={{release_us}}\\exp\\!\\left[\\frac{{{activation_ev}}}{k_B}\\left(\\frac{1}{T[n]+273.15}-\\frac{1}{298.15}\\right)\\right]",
+ "\\tau[n]=\\begin{cases}{{capture_us}}&P[n]\\ge q_t[n-1]\\\\\\tau_e[n]&\\text{otherwise}\\end{cases}",
+ "q_t[n]=a[n]q_t[n-1]+(1-a[n])P[n],\\quad a[n]=e^{-1/(F_s\\tau[n]10^{-6})}",
+ "v[n]=1-{{ir_drop}}\\operatorname{LPF}(P,{{bias_us}})",
+ "y[n]=R(x[n])(1-{{trap_strength}}q_t[n])v[n]e^{-{{thermal_gain}}(T[n]-25)+j{{trap_phase}}q_t[n]}",
+ "\\operatorname{LPF}(P,\\tau)[n]=a\\operatorname{LPF}[n-1]+(1-a)P[n],\\quad a=e^{-1/(F_s\\tau\\,10^{-6})},\\quad\\tau\\text{ in }\\mu\\mathrm{s}",
+ "k_B=8.617333262\\times 10^{-5}\\,\\mathrm{eV/K}"
+ ],
"limitations": {
"en": "Illustrative complex-baseband behavior, with normalized amplitudes. Defaults are not extracted from a specific device; no calibrated RF power, efficiency or transistor-level prediction.",
"zh": "\u5f52\u4e00\u5316\u5e45\u5ea6\u7684\u590d\u57fa\u5e26\u793a\u610f\u6a21\u578b\u3002\u9ed8\u8ba4\u53c2\u6570\u672a\u4ece\u5177\u4f53\u5668\u4ef6\u63d0\u53d6\uff0c\u4e0d\u4ee3\u8868\u6821\u51c6\u5c04\u9891\u529f\u7387\u3001\u6548\u7387\u6216\u6676\u4f53\u7ba1\u7ea7\u9884\u6d4b\u3002"
@@ -788,6 +859,7 @@
"minimum": 0.1,
"step": 0.05,
"symbol": "G",
+ "symbol_latex": "G",
"unit": "V/V"
},
{
@@ -808,6 +880,7 @@
"minimum": 0.05,
"step": 0.01,
"symbol": "s",
+ "symbol_latex": "s",
"unit": "normalized"
},
{
@@ -828,6 +901,7 @@
"minimum": 0.5,
"step": 0.1,
"symbol": "p",
+ "symbol_latex": "p",
"unit": ""
},
{
@@ -848,6 +922,7 @@
"minimum": 0.0,
"step": 0.01,
"symbol": "\u03bat",
+ "symbol_latex": "\\kappa_t",
"unit": ""
},
{
@@ -868,6 +943,7 @@
"minimum": 0.01,
"step": 0.01,
"symbol": "\u03c4c",
+ "symbol_latex": "\\tau_c",
"unit": "\u00b5s"
},
{
@@ -888,6 +964,7 @@
"minimum": 0.01,
"step": 0.01,
"symbol": "\u03c4e,25",
+ "symbol_latex": "\\tau_{e,25}",
"unit": "\u00b5s"
},
{
@@ -908,6 +985,7 @@
"minimum": 0.0,
"step": 0.01,
"symbol": "Ea",
+ "symbol_latex": "E_a",
"unit": "eV"
},
{
@@ -928,6 +1006,7 @@
"minimum": -1.0,
"step": 0.01,
"symbol": "\u03c6t",
+ "symbol_latex": "\\phi_t",
"unit": "rad"
},
{
@@ -948,6 +1027,7 @@
"minimum": 0.1,
"step": 0.1,
"symbol": "\u03c4T",
+ "symbol_latex": "\\tau_T",
"unit": "\u00b5s"
},
{
@@ -968,6 +1048,7 @@
"minimum": -20.0,
"step": 1.0,
"symbol": "Ta",
+ "symbol_latex": "T_a",
"unit": "\u00b0C"
},
{
@@ -988,6 +1069,7 @@
"minimum": 0.0,
"step": 1.0,
"symbol": "\u0394T",
+ "symbol_latex": "\\Delta T",
"unit": "K"
},
{
@@ -1008,6 +1090,7 @@
"minimum": 0.0,
"step": 0.0005,
"symbol": "\u03baT",
+ "symbol_latex": "\\kappa_T",
"unit": "1/K"
},
{
@@ -1028,6 +1111,7 @@
"minimum": 0.0,
"step": 0.01,
"symbol": "\u03c1",
+ "symbol_latex": "\\rho",
"unit": ""
},
{
@@ -1048,6 +1132,7 @@
"minimum": 0.01,
"step": 0.01,
"symbol": "\u03c4b",
+ "symbol_latex": "\\tau_b",
"unit": "\u00b5s"
}
],
@@ -1069,6 +1154,11 @@
"x\u209a[n] = max(|x[n]|\u2212{{knee}}, 0)\u00b7exp(j\u00b7arg(x[n]))",
"y[n] = R(x[n]) + {{peaker}}\u00b7R(x\u209a[n])\u00b7exp(j\u00b7{{phase_deg}}\u00b7\u03c0/180)"
],
+ "equations_latex": [
+ "R(x)=\\frac{{{gain}}x}{[1+({{gain}}|x|/{{saturation}})^{2{{smoothness}}}]^{1/(2{{smoothness}})}}",
+ "x_p[n]=\\max(|x[n]|-{{knee}},0)e^{j\\arg x[n]}",
+ "y[n]=R(x[n])+{{peaker}}R(x_p[n])e^{j{{phase_deg}}\\pi/180}"
+ ],
"limitations": {
"en": "Illustrative complex-baseband behavior, with normalized amplitudes. Defaults are not extracted from a specific device; no calibrated RF power, efficiency or transistor-level prediction. This envelope construction does not solve an impedance inverter or predict load modulation/efficiency.",
"zh": "\u5f52\u4e00\u5316\u5e45\u5ea6\u7684\u590d\u57fa\u5e26\u793a\u610f\u6a21\u578b\u3002\u9ed8\u8ba4\u53c2\u6570\u672a\u4ece\u5177\u4f53\u5668\u4ef6\u63d0\u53d6\uff0c\u4e0d\u4ee3\u8868\u6821\u51c6\u5c04\u9891\u529f\u7387\u3001\u6548\u7387\u6216\u6676\u4f53\u7ba1\u7ea7\u9884\u6d4b\u3002 \u8be5\u5305\u7edc\u6784\u9020\u4e0d\u6c42\u89e3\u963b\u6297\u53d8\u6362\u7f51\u7edc\uff0c\u4e5f\u4e0d\u9884\u6d4b\u771f\u5b9e\u8d1f\u8f7d\u8c03\u5236\u6216\u6548\u7387\u3002"
@@ -1097,6 +1187,7 @@
"minimum": 0.1,
"step": 0.05,
"symbol": "G",
+ "symbol_latex": "G",
"unit": "V/V"
},
{
@@ -1117,6 +1208,7 @@
"minimum": 0.05,
"step": 0.01,
"symbol": "s",
+ "symbol_latex": "s",
"unit": "normalized"
},
{
@@ -1137,6 +1229,7 @@
"minimum": 0.5,
"step": 0.1,
"symbol": "p",
+ "symbol_latex": "p",
"unit": ""
},
{
@@ -1157,6 +1250,7 @@
"minimum": 0.0,
"step": 0.01,
"symbol": "r\u2096",
+ "symbol_latex": "r_k",
"unit": "normalized"
},
{
@@ -1177,6 +1271,7 @@
"minimum": 0.0,
"step": 0.05,
"symbol": "\u03b2p",
+ "symbol_latex": "\\beta_p",
"unit": ""
},
{
@@ -1197,6 +1292,7 @@
"minimum": -180.0,
"step": 1.0,
"symbol": "\u03b8",
+ "symbol_latex": "\\theta",
"unit": "\u00b0"
}
],
@@ -1219,6 +1315,14 @@
"y[n] = R(x[n]; {{gain}}, {{saturation}}\u00b7v[n], {{smoothness}})\u00b7exp(j\u00b7{{phase}}\u00b7(v[n]\u22121))",
"LPF(P,\u03c4)[n] = a\u00b7LPF[n\u22121] + (1\u2212a)\u00b7P[n]; a = exp(\u22121/(Fs\u00b7\u03c4\u00b710\u207b\u2076)); \u03c4 in \u00b5s"
],
+ "equations_latex": [
+ "R(x;G,s,p)=\\frac{Gx}{[1+(G|x|/s)^{2p}]^{1/(2p)}}",
+ "D[n]=\\min({{gain}}|x[n]|/{{saturation}},1)",
+ "P[n]=\\frac{|x[n]|^2}{|x[n]|^2+({{saturation}}/{{gain}})^2}",
+ "v[n]=\\operatorname{clip}({{supply_floor}}+{{supply_span}}\\operatorname{LPF}(D,{{tracking_us}})-{{ir_drop}}\\operatorname{LPF}(P,{{bias_us}}),{{supply_floor}},1.5)",
+ "y[n]=R(x[n];{{gain}},{{saturation}}v[n],{{smoothness}})e^{j{{phase}}(v[n]-1)}",
+ "\\operatorname{LPF}(P,\\tau)[n]=a\\operatorname{LPF}[n-1]+(1-a)P[n],\\quad a=e^{-1/(F_s\\tau\\,10^{-6})},\\quad\\tau\\text{ in }\\mu\\mathrm{s}"
+ ],
"limitations": {
"en": "Illustrative complex-baseband behavior, with normalized amplitudes. Defaults are not extracted from a specific device; no calibrated RF power, efficiency or transistor-level prediction.",
"zh": "\u5f52\u4e00\u5316\u5e45\u5ea6\u7684\u590d\u57fa\u5e26\u793a\u610f\u6a21\u578b\u3002\u9ed8\u8ba4\u53c2\u6570\u672a\u4ece\u5177\u4f53\u5668\u4ef6\u63d0\u53d6\uff0c\u4e0d\u4ee3\u8868\u6821\u51c6\u5c04\u9891\u529f\u7387\u3001\u6548\u7387\u6216\u6676\u4f53\u7ba1\u7ea7\u9884\u6d4b\u3002"
@@ -1247,6 +1351,7 @@
"minimum": 0.1,
"step": 0.05,
"symbol": "G",
+ "symbol_latex": "G",
"unit": "V/V"
},
{
@@ -1267,6 +1372,7 @@
"minimum": 0.05,
"step": 0.01,
"symbol": "s",
+ "symbol_latex": "s",
"unit": "normalized"
},
{
@@ -1287,6 +1393,7 @@
"minimum": 0.5,
"step": 0.1,
"symbol": "p",
+ "symbol_latex": "p",
"unit": ""
},
{
@@ -1307,6 +1414,7 @@
"minimum": 0.1,
"step": 0.01,
"symbol": "vmin",
+ "symbol_latex": "v_{\\min}",
"unit": ""
},
{
@@ -1327,6 +1435,7 @@
"minimum": 0.1,
"step": 0.01,
"symbol": "hv",
+ "symbol_latex": "h_v",
"unit": ""
},
{
@@ -1347,6 +1456,7 @@
"minimum": 0.001,
"step": 0.001,
"symbol": "\u03c4v",
+ "symbol_latex": "\\tau_v",
"unit": "\u00b5s"
},
{
@@ -1367,6 +1477,7 @@
"minimum": 0.0,
"step": 0.01,
"symbol": "\u03c1",
+ "symbol_latex": "\\rho",
"unit": ""
},
{
@@ -1387,6 +1498,7 @@
"minimum": 0.01,
"step": 0.01,
"symbol": "\u03c4b",
+ "symbol_latex": "\\tau_b",
"unit": "\u00b5s"
},
{
@@ -1407,6 +1519,7 @@
"minimum": -1.5,
"step": 0.01,
"symbol": "\u03c6\u221e",
+ "symbol_latex": "\\phi_\\infty",
"unit": "rad"
}
],
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 780f497..0d87ad8 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,18 +1,19 @@
{
"name": "opendpd-studio",
- "version": "2.2.4",
+ "version": "2.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opendpd-studio",
- "version": "2.2.4",
+ "version": "2.2.5",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@tanstack/react-query": "^5.102.8",
+ "katex": "^0.18.7",
"plotly.js-basic-dist-min": "4.0.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -1952,6 +1953,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/commander": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
+ "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
@@ -2457,6 +2467,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/katex": {
+ "version": "0.18.7",
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.7.tgz",
+ "integrity": "sha512-h+UCwkZ+4Jz8WQ7MLGfj7UVFrRCizGb912fwF4luGdYsC5paYG1vx+jy+KRcC/XkpjGva/P7nAWuxNnPzRvzHw==",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^15.0.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 5aa1a23..792c393 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "opendpd-studio",
"private": true,
- "version": "2.2.4",
+ "version": "2.2.5",
"type": "module",
"scripts": {
"dev": "vite",
@@ -22,6 +22,7 @@
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@tanstack/react-query": "^5.102.8",
+ "katex": "^0.18.7",
"plotly.js-basic-dist-min": "4.0.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
diff --git a/frontend/src/api/profiles.ts b/frontend/src/api/profiles.ts
index 9c4cfb9..faec1e2 100644
--- a/frontend/src/api/profiles.ts
+++ b/frontend/src/api/profiles.ts
@@ -1,3 +1,4 @@
+import { t } from '@/i18n'
import type { MetricProfile } from './types'
/**
@@ -8,3 +9,6 @@ import type { MetricProfile } from './types'
const isOffered = (profile: MetricProfile): boolean => profile.validation !== 'pending_cross_validation'
export const offeredProfiles = (profiles: readonly MetricProfile[] | undefined): MetricProfile[] => (profiles ?? []).filter(isOffered)
+
+export const profileLabel = (id: string) => id === 'general-spectral-v1' ? t('profile.general')
+ : id === 'legacy-opendpd-v1' ? t('profile.legacy') : id === 'ofdm-lte20-evm-v1' ? t('profile.ofdm') : id
diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts
index 073c99d..0cec097 100644
--- a/frontend/src/api/schema.ts
+++ b/frontend/src/api/schema.ts
@@ -1433,6 +1433,23 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/signal-generator/signals/{signal_id}/archive": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Archive */
+ post: operations["archive_api_v1_signal_generator_signals__signal_id__archive_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/signal-generator/signals/{signal_id}/dataset": {
parameters: {
query?: never;
@@ -1504,6 +1521,23 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/signal-generator/signals/{signal_id}/restore": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Restore */
+ post: operations["restore_api_v1_signal_generator_signals__signal_id__restore_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/signal-generator/validate": {
parameters: {
query?: never;
@@ -1952,7 +1986,7 @@ export interface components {
* Kind
* @enum {string}
*/
- kind: "surrogate_without_dpd" | "measured_without_dpd";
+ kind: "surrogate_without_dpd" | "measured_without_dpd" | "ilc_ideal";
/** Metrics */
metrics: components["schemas"]["MetricValue"][];
};
@@ -2907,6 +2941,10 @@ export interface components {
*/
generated_at?: string;
history?: components["schemas"]["FileRef"] | null;
+ /** Ilc */
+ ilc?: {
+ [key: string]: unknown;
+ } | null;
/**
* Is Mock
* @default false
@@ -3614,6 +3652,8 @@ export interface components {
* @default 42
*/
seed: number;
+ /** Shared Channel Settings */
+ shared_channel_settings?: boolean | null;
/** Snr Db */
snr_db?: number | null;
/**
@@ -4498,7 +4538,7 @@ export interface components {
/** Run Id */
run_id?: string | null;
/** Training Path */
- training_path?: ("gradient" | "gradient_dla" | "least_squares" | "ila_least_squares") | null;
+ training_path?: ("gradient" | "gradient_dla" | "least_squares" | "ila_least_squares" | "ilc_ila") | null;
/** Weights Sha256 */
weights_sha256?: string | null;
};
@@ -4656,6 +4696,11 @@ export interface components {
step: number;
/** Symbol */
symbol: string;
+ /**
+ * Symbol Latex
+ * @default
+ */
+ symbol_latex: string;
/**
* Unit
* @default
@@ -5389,7 +5434,7 @@ export interface components {
csrf_token?: string | null;
/**
* Version
- * @default 2.2.4
+ * @default 2.2.5
*/
version: string;
};
@@ -6086,6 +6131,8 @@ export interface components {
description: components["schemas"]["PALocalizedText"];
/** Equations */
equations: string[];
+ /** Equations Latex */
+ equations_latex?: string[];
limitations: components["schemas"]["PALocalizedText"];
/** Model Id */
model_id: string;
@@ -8913,6 +8960,37 @@ export interface operations {
};
};
};
+ archive_api_v1_signal_generator_signals__signal_id__archive_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ signal_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
dataset_api_v1_signal_generator_signals__signal_id__dataset_post: {
parameters: {
query?: never;
@@ -9041,6 +9119,37 @@ export interface operations {
};
};
};
+ restore_api_v1_signal_generator_signals__signal_id__restore_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ signal_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PAInputDataset"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
validate_api_v1_signal_generator_validate_post: {
parameters: {
query?: never;
@@ -9323,7 +9432,9 @@ export interface operations {
};
system_about_api_v1_system_about_get: {
parameters: {
- query?: never;
+ query?: {
+ activity?: boolean;
+ };
header?: never;
path?: never;
cookie?: never;
@@ -9341,6 +9452,15 @@ export interface operations {
};
};
};
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
};
};
capabilities_api_v1_system_capabilities_get: {
diff --git a/frontend/src/components/ILCResults.tsx b/frontend/src/components/ILCResults.tsx
new file mode 100644
index 0000000..df4364c
--- /dev/null
+++ b/frontend/src/components/ILCResults.tsx
@@ -0,0 +1,34 @@
+import { useMemo } from 'react'
+import Alert from '@mui/material/Alert'
+import Paper from '@mui/material/Paper'
+import Typography from '@mui/material/Typography'
+import { t, formatNumber, useLanguage } from '@/i18n'
+import { MathFormula } from './MathFormula'
+import { PlotlyChart, type PlotTrace } from './PlotlyChart'
+
+const STOP_LABELS = {
+ target_reached: 'ilc.stop.target_reached', iteration_limit: 'ilc.stop.iteration_limit',
+ no_improving_step: 'ilc.stop.no_improving_step', improvement_below_tolerance: 'ilc.stop.improvement_below_tolerance',
+} as const
+function stopLabel(reason: unknown) {
+ return typeof reason === 'string' && reason in STOP_LABELS ? t(STOP_LABELS[reason as keyof typeof STOP_LABELS]) : String(reason ?? '—')
+}
+
+export function ILCResults({ evidence }: { evidence: Record }) {
+ useLanguage()
+ const trainingLabel = t('ilc.trainingCurve'), testLabel = t('ilc.testCurve')
+ const traces = useMemo(() => ['training', 'test'].map((split, i) => {
+ const rows = (evidence[`${split}_history`] ?? []) as Array<{ iteration: number; nmse_db: number }>
+ return { type: 'scatter', mode: 'lines+markers', name: split === 'test' ? testLabel : trainingLabel,
+ x: rows.map(r => r.iteration), y: rows.map(r => r.nmse_db), line: { dash: i ? 'dash' : 'solid' } }
+ }), [evidence, trainingLabel, testLabel])
+ return
+ {t('ilc.benchmark')}
+ {t('ilc.idealHelp')}
+
+
+
+ {t('ilc.summary', { trainReason: stopLabel(evidence.training_stop_reason), trainCount: formatNumber(Number(evidence.fit_samples)), testReason: stopLabel(evidence.test_stop_reason), testCount: formatNumber(Number(evidence.test_samples)) })}
+ {t('ilc.plant', { pa: String(evidence.test_plant_run_id), peak: Number(evidence.peak_limit).toPrecision(4), calls: formatNumber(Number(evidence.test_plant_calls)) })}
+
+}
diff --git a/frontend/src/components/MathFormula.test.tsx b/frontend/src/components/MathFormula.test.tsx
new file mode 100644
index 0000000..0ae9e8e
--- /dev/null
+++ b/frontend/src/components/MathFormula.test.tsx
@@ -0,0 +1,37 @@
+import { fireEvent, screen } from '@testing-library/react'
+import { vi } from 'vitest'
+import fixture from '@mocks/virtual_pa_models.json'
+import { renderWithProviders } from '@/test/utils'
+import { MathFormula } from './MathFormula'
+
+test('every catalog equation renders and its coefficients remain interactive after a parent update', () => {
+ const select = vi.fn()
+ for (const model of fixture.data) {
+ const variables = model.parameters.map(p => ({ key: p.key, symbol: p.symbol_latex, label: p.label.en }))
+ const view = renderWithProviders(<>{model.equations_latex.map((latex, i) => )}>)
+ expect(view.container.querySelector('.katex-error')).toBeNull()
+ const first = view.container.querySelector('[data-variable]')!
+ fireEvent.keyDown(first, { key: 'Enter' })
+ expect(select).toHaveBeenLastCalledWith(first.dataset.variable)
+ view.unmount()
+ }
+ const variables = [{ key: 'gain', symbol: 'G', label: 'Gain' }]
+ const view = renderWithProviders()
+ view.rerender()
+ expect(screen.getByRole('button', { name: 'Gain' })).toHaveAttribute('aria-pressed', 'true')
+ fireEvent.click(screen.getByRole('button', { name: 'Gain' }))
+ expect(select).toHaveBeenLastCalledWith('gain')
+})
+
+test.each([
+ String.raw`\href{javascript:alert(1)}{click}`,
+ String.raw`\url{https://example.com}`,
+ String.raw`\includegraphics{https://example.com/pixel.png}`,
+ String.raw`\htmlStyle{background:url(https://example.com/pixel.png)}{x}`,
+ String.raw`\htmlClass{untrusted}{x}`,
+ '
',
+])('untrusted math cannot load resources, inject markup or create links: %s', latex => {
+ const { container } = renderWithProviders()
+ expect(container.querySelector('a, img, script, iframe, object, .untrusted, [onerror], [onclick]')).toBeNull()
+ expect(container.querySelector('[style*="url("]')).toBeNull()
+})
diff --git a/frontend/src/components/MathFormula.tsx b/frontend/src/components/MathFormula.tsx
new file mode 100644
index 0000000..30ed2db
--- /dev/null
+++ b/frontend/src/components/MathFormula.tsx
@@ -0,0 +1,50 @@
+import { useLayoutEffect, useMemo, useRef } from 'react'
+import Box from '@mui/material/Box'
+import { renderToString } from 'katex'
+import 'katex/dist/katex.min.css'
+import { useStudioColors } from '@/theme'
+
+interface Variable { key: string; symbol: string; label: string }
+/** Only catalog parameter classes are trusted; links, HTML styles and resource loading stay disabled. */
+export function MathFormula({ latex, variables = [], active, onSelect, display = false }: {
+ latex: string; variables?: Variable[]; active?: string; onSelect?: (key: string) => void; display?: boolean
+}) {
+ const ref = useRef(null)
+ const colors = useStudioColors()
+ const signature = JSON.stringify(variables)
+ const html = useMemo(() => {
+ const known: Variable[] = JSON.parse(signature)
+ const source = latex.replace(/\{\{(\w+)\}\}/g, (_, key: string) => {
+ const v = known.find(item => item.key === key)
+ return v ? `\\htmlClass{pa-var-${key}}{${v.symbol}}` : '\\text{?}'
+ })
+ return renderToString(source, { displayMode: display, throwOnError: false, maxExpand: 300, maxSize: 10,
+ strict: 'ignore', trust: ctx => ctx.command === '\\htmlClass' && known.some(v => ctx.class === `pa-var-${v.key}`) })
+ }, [latex, signature, display])
+ useLayoutEffect(() => {
+ if (!html || !ref.current) return
+ const known: Variable[] = JSON.parse(signature)
+ for (const v of known) ref.current?.querySelectorAll(`.pa-var-${v.key}`).forEach((el, index) => {
+ el.dataset.variable = v.key; el.dataset.active = String(active === v.key)
+ // KaTeX's visual tree is aria-hidden. Expose each interactive coefficient separately.
+ el.setAttribute('role', 'button'); el.tabIndex = 0; el.setAttribute('aria-label', v.label)
+ el.setAttribute('aria-pressed', String(active === v.key))
+ if (index === 0) el.dataset.testid = 'equation-' + v.key
+ })
+ const visual = ref.current?.querySelector('.katex-html')
+ if (known.length) visual?.removeAttribute('aria-hidden')
+ }) // React may replace innerHTML on a parent render; reattach interaction metadata each commit.
+ const select = (target: EventTarget) => {
+ if (!(target instanceof Element)) return
+ const key = target.closest('[data-variable]')?.dataset.variable
+ if (key && variables.some(v => v.key === key)) onSelect?.(key)
+ }
+ return select(e.target)} onKeyDown={e => {
+ if ((e.key === 'Enter' || e.key === ' ') && (e.target as HTMLElement).dataset.variable) { e.preventDefault(); select(e.target) }
+ }} sx={{ display: display ? 'block' : 'inline-block', maxWidth: '100%', overflowX: 'auto', verticalAlign: 'middle',
+ '& .katex-display': { textAlign: 'left', my: 1.5 }, '& .katex-display > .katex': { textAlign: 'left' },
+ '& [data-variable]': { cursor: 'pointer', borderRadius: .5, outlineOffset: 2 },
+ '& [data-active="true"]': { color: colors.primary, bgcolor: colors.selected, outline: `1px solid ${colors.primary}` },
+ '& [data-variable]:focus-visible': { outline: `2px solid ${colors.primary}` },
+ }} dangerouslySetInnerHTML={{ __html: html }} />
+}
diff --git a/frontend/src/components/MetricCard.tsx b/frontend/src/components/MetricCard.tsx
index b44f997..73b80a6 100644
--- a/frontend/src/components/MetricCard.tsx
+++ b/frontend/src/components/MetricCard.tsx
@@ -1,3 +1,4 @@
+import { MetricFormula } from './MetricFormula'
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'
@@ -39,7 +40,7 @@ export function MetricCard({ metric, definition }: { metric: MetricValue; defini
- {t('metric.formula')}: {message(definition.formula)}
+ {t('metric.formula')}:
{t('metric.aggregation')}: {message(definition.aggregation)}
{definition.notes ? (
diff --git a/frontend/src/components/MetricFormula.tsx b/frontend/src/components/MetricFormula.tsx
new file mode 100644
index 0000000..d2dc762
--- /dev/null
+++ b/frontend/src/components/MetricFormula.tsx
@@ -0,0 +1,23 @@
+import type { MetricDefinition } from '@/api/types'
+import { MathFormula } from './MathFormula'
+
+// Exact definition matches: an unknown/new protocol remains readable as its source text.
+const FORMULAS: Record = {
+ '20 * log10( EVM_RMS / 100 )': String.raw`20\log_{10}\frac{\mathrm{EVM}_{\mathrm{RMS}}}{100}`,
+ '10 * log10( P[-29, -11 MHz] / P[-9, +9 MHz] ) from the Welch PSD at the capture rate': String.raw`10\log_{10}\frac{P_{[-29,-11]\,\mathrm{MHz}}}{P_{[-9,+9]\,\mathrm{MHz}}}`,
+ '10 * log10( P[+11, +29 MHz] / P[-9, +9 MHz] ) from the Welch PSD at the capture rate': String.raw`10\log_{10}\frac{P_{[+11,+29]\,\mathrm{MHz}}}{P_{[-9,+9]\,\mathrm{MHz}}}`,
+ 'mean over segments of 10*log10(sum|e|^2 / sum|y|^2)': String.raw`\frac1S\sum_{s=1}^{S}10\log_{10}\frac{\sum_n|e_s[n]|^2}{\sum_n|y_s[n]|^2}`,
+ '20*log10(mean over segments of mean over sub-channels of mean|X_pred - X_ref| / mean|X_ref|), FFT of nperseg samples': String.raw`20\log_{10}\!\left[\frac1{SC}\sum_{s,c}\frac{\operatorname{mean}_{k\in c}|X_{\mathrm{pred},s}[k]-X_{\mathrm{ref},s}[k]|}{\operatorname{mean}_{k\in c}|X_{\mathrm{ref},s}[k]|}\right]`,
+ '10*log10(P_adjacent_left / max sub-channel power)': String.raw`10\log_{10}\frac{P_{\mathrm{adj},L}}{\max_c P_c}`,
+ '10*log10(P_adjacent_right / max sub-channel power)': String.raw`10\log_{10}\frac{P_{\mathrm{adj},R}}{\max_c P_c}`,
+ '(ACLR_L + ACLR_R) / 2': String.raw`\frac{\mathrm{ACLR}_L+\mathrm{ACLR}_R}{2}`,
+ '10*log10( sum|y - r|^2 / sum|r|^2 ) over the valid range': String.raw`10\log_{10}\frac{\sum_{n\in\mathcal V}|y[n]-r[n]|^2}{\sum_{n\in\mathcal V}|r[n]|^2}`,
+ '10*log10( P_main(y - r) / P_main(r) ) with band powers from the Welch PSD': String.raw`10\log_{10}\frac{P_{\mathrm{main}}(y-r)}{P_{\mathrm{main}}(r)}`,
+ '10*log10( P_adjacent_left(y) / P_main(y) )': String.raw`10\log_{10}\frac{P_{\mathrm{adj},L}(y)}{P_{\mathrm{main}}(y)}`,
+ '10*log10( P_adjacent_right(y) / P_main(y) )': String.raw`10\log_{10}\frac{P_{\mathrm{adj},R}(y)}{P_{\mathrm{main}}(y)}`,
+ '100 * sqrt( sum|S_eq - S_ref|^2 / sum|S_ref|^2 ) over occupied subcarriers and complete symbols': String.raw`100\sqrt{\frac{\sum|S_{\mathrm{eq}}-S_{\mathrm{ref}}|^2}{\sum|S_{\mathrm{ref}}|^2}}`,
+}
+export function MetricFormula({ definition }: { definition: MetricDefinition }) {
+ const latex = FORMULAS[definition.formula]
+ return latex ? : {definition.formula}
+}
diff --git a/frontend/src/components/spectrumNodes.ts b/frontend/src/components/spectrumNodes.ts
index 2b6fa92..7d589bc 100644
--- a/frontend/src/components/spectrumNodes.ts
+++ b/frontend/src/components/spectrumNodes.ts
@@ -32,6 +32,7 @@ export function spectrumGroups(traces: T[], dpd = hasD
* the review controls and exported data. Synthetic evidence remains explicit. */
export function spectrumLegend(trace: SignalIdentity): string {
const name = trace.name.toLowerCase(), synthetic = trace.source?.includes('synthetic')
+ if (name.startsWith('ilc ideal')) return t('ilc.ideal')
if (trace.role === 'input') return t('spectrum.trace.input')
if (trace.role === 'predistorted') return t('spectrum.trace.predistorted')
if (name.includes('linear target')) return t('spectrum.trace.target')
diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json
index 65c2d74..b94b81a 100644
--- a/frontend/src/i18n/de.json
+++ b/frontend/src/i18n/de.json
@@ -248,8 +248,8 @@
"signal.sub_channel_bandwidth_hz": "Unterkanalbandbreite (Hz)",
"signal.n_sub_ch": "Unterkanäle",
"signal.nperseg": "PSD-Segmentlänge (nperseg)",
- "results.detail.profile": "Metrikprofil",
- "results.detail.profile.help": "Jedes registrierte Profil wird vom selben besten Checkpoint aus bewertet; das konfigurierte ist das primäre Ergebnis.",
+ "results.detail.profile": "Metric calculation",
+ "results.detail.profile.help": "Same output, different score definitions. Select the same calculation for fair comparisons.",
"results.detail.definitions": "Metrikdefinitionen",
"results.detail.definitions.help": "Was jeder Wert unter {profile} v{version} bedeutet. Namen, Formeln und Vorzugsrichtungen stammen aus der Registry, nicht von der Seite.",
"results.detail.parameters": "Profilparameter",
@@ -257,8 +257,8 @@
"metric.definition": "Definition von {name}",
"metric.formula": "Formel",
"metric.aggregation": "Aggregation",
- "form.profile": "Metrikprofil",
- "form.profile.help": "Die Definition hinter jedem Wert des Ergebnisses. Jedes registrierte Profil wird ebenfalls gespeichert; dieses wird zuerst angezeigt.",
+ "form.profile": "Metric calculation",
+ "form.profile.help": "Changes score definitions, not the model. Every supported calculation is saved; use the same method when comparing runs.",
"results.detail.metric": "Metrik",
"results.detail.unit": "Einheit · Richtung",
"form.import": "Konfiguration importieren…",
@@ -1101,7 +1101,7 @@
"generator.generate": "Generate & preview",
"generator.generating": "Generating signal…",
"generator.applyGenerate": "Apply & regenerate",
- "generator.firstPreview": "Preparing a reproducible 5G NR numerology stimulus and its measurements.",
+ "generator.firstPreview": "Choose a waveform, then click Generate to create your PA Input Dataset.",
"generator.advanced": "Advanced parameters",
"generator.advancedHelp": "Customize the waveform geometry and impairments. Modified numerology is labeled custom. Invalid allocations are rejected before generation.",
"generator.fft": "FFT size before oversampling",
@@ -1289,5 +1289,30 @@
"spectrum.trace.model": "PA model prediction",
"spectrum.trace.synthetic": "Dataset output · synthetic",
"spectrum.trace.measured": "Measured output",
- "spectrum.runKey": "Run key"
+ "spectrum.runKey": "Run key",
+ "generator.sharedChannels": "Use the same settings for all channels",
+ "generator.allChannels": "All channels",
+ "profile.general": "General baseband · pooled error & leakage",
+ "profile.legacy": "OpenDPD legacy · segment-averaged scores",
+ "profile.ofdm": "OFDM symbol EVM · reference required",
+ "profile.help": "The calculation method defines averaging, spectral bands and references. It does not change training or the waveform.",
+ "ilc.title": "ILC linearization",
+ "ilc.help": "Learn a PA input waveform by repeated feedback through a trained PA surrogate, then fit an ILA memory-polynomial DPD. ILC is waveform control; train a forward PA model first.",
+ "ilc.defaults": "Starting settings: 30 iterations, learning gain 0.5, target NMSE −45 dB, peak limit 1.5× training-input peak, 6 backtracking steps. These are conservative starting values, not universally optimal.",
+ "ilc.ideal": "ILC Ideal DPD · waveform-specific",
+ "ilc.idealHelp": "Feedback-optimized on this test waveform through the selected surrogate. This is an achievable reference for this configuration, not a transferable model or measured hardware result.",
+ "ilc.benchmark": "ILC-DPD / Ideal benchmark",
+ "paInput.remove": "Remove PA Input Dataset",
+ "paInput.removed": "PA Input Dataset removed from this workspace.",
+ "common.undo": "Undo",
+ "ilc.trainingCurve": "Training waveform · ILC",
+ "ilc.testCurve": "Test waveform · Ideal feedback",
+ "ilc.iteration": "ILC iteration",
+ "ilc.trackingNmse": "Pooled tracking NMSE (dB)",
+ "ilc.stop.target_reached": "Target reached",
+ "ilc.stop.iteration_limit": "Iteration limit reached",
+ "ilc.stop.no_improving_step": "No improving step found",
+ "ilc.stop.improvement_below_tolerance": "Improvement below tolerance",
+ "ilc.summary": "Training: {trainReason} · {trainCount} I/Q samples · Testing: {testReason} · {testCount} I/Q samples",
+ "ilc.plant": "PA: {pa} · Peak limit: {peak} · {calls} test plant calls"
}
diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json
index 585faeb..fae7b50 100644
--- a/frontend/src/i18n/en.json
+++ b/frontend/src/i18n/en.json
@@ -248,8 +248,8 @@
"signal.sub_channel_bandwidth_hz": "Sub-channel bandwidth (Hz)",
"signal.n_sub_ch": "Sub-channels",
"signal.nperseg": "PSD segment length (nperseg)",
- "results.detail.profile": "Metric profile",
- "results.detail.profile.help": "Every registered profile is scored from the same best checkpoint; the configured one is the primary result.",
+ "results.detail.profile": "Metric calculation",
+ "results.detail.profile.help": "Same output, different score definitions. Select the same calculation for fair comparisons.",
"results.detail.definitions": "Metric definitions",
"results.detail.definitions.help": "What each score means under {profile} v{version}. Names, formulas and better-directions come from the registry, not from the page.",
"results.detail.parameters": "Profile parameters",
@@ -257,8 +257,8 @@
"metric.definition": "Definition of {name}",
"metric.formula": "Formula",
"metric.aggregation": "Aggregation",
- "form.profile": "Metric profile",
- "form.profile.help": "The definition behind every score of the result. Every registered profile is also stored; this one is shown first.",
+ "form.profile": "Metric calculation",
+ "form.profile.help": "Changes score definitions, not the model. Every supported calculation is saved; use the same method when comparing runs.",
"results.detail.metric": "Metric",
"results.detail.unit": "Unit · direction",
"form.import": "Import configuration…",
@@ -1101,7 +1101,7 @@
"generator.generate": "Generate & preview",
"generator.generating": "Generating signal…",
"generator.applyGenerate": "Apply & regenerate",
- "generator.firstPreview": "Preparing a reproducible 5G NR numerology stimulus and its measurements.",
+ "generator.firstPreview": "Choose a waveform, then click Generate to create your PA Input Dataset.",
"generator.advanced": "Advanced parameters",
"generator.advancedHelp": "Customize the waveform geometry and impairments. Modified numerology is labeled custom. Invalid allocations are rejected before generation.",
"generator.fft": "FFT size before oversampling",
@@ -1289,5 +1289,30 @@
"spectrum.trace.model": "PA model prediction",
"spectrum.trace.synthetic": "Dataset output · synthetic",
"spectrum.trace.measured": "Measured output",
- "spectrum.runKey": "Run key"
+ "spectrum.runKey": "Run key",
+ "generator.sharedChannels": "Use the same settings for all channels",
+ "generator.allChannels": "All channels",
+ "profile.general": "General baseband · pooled error & leakage",
+ "profile.legacy": "OpenDPD legacy · segment-averaged scores",
+ "profile.ofdm": "OFDM symbol EVM · reference required",
+ "profile.help": "The calculation method defines averaging, spectral bands and references. It does not change training or the waveform.",
+ "ilc.title": "ILC linearization",
+ "ilc.help": "Learn a PA input waveform by repeated feedback through a trained PA surrogate, then fit an ILA memory-polynomial DPD. ILC is waveform control; train a forward PA model first.",
+ "ilc.defaults": "Starting settings: 30 iterations, learning gain 0.5, target NMSE −45 dB, peak limit 1.5× training-input peak, 6 backtracking steps. These are conservative starting values, not universally optimal.",
+ "ilc.ideal": "ILC Ideal DPD · waveform-specific",
+ "ilc.idealHelp": "Feedback-optimized on this test waveform through the selected surrogate. This is an achievable reference for this configuration, not a transferable model or measured hardware result.",
+ "ilc.benchmark": "ILC-DPD / Ideal benchmark",
+ "paInput.remove": "Remove PA Input Dataset",
+ "paInput.removed": "PA Input Dataset removed from this workspace.",
+ "common.undo": "Undo",
+ "ilc.trainingCurve": "Training waveform · ILC",
+ "ilc.testCurve": "Test waveform · Ideal feedback",
+ "ilc.iteration": "ILC iteration",
+ "ilc.trackingNmse": "Pooled tracking NMSE (dB)",
+ "ilc.stop.target_reached": "Target reached",
+ "ilc.stop.iteration_limit": "Iteration limit reached",
+ "ilc.stop.no_improving_step": "No improving step found",
+ "ilc.stop.improvement_below_tolerance": "Improvement below tolerance",
+ "ilc.summary": "Training: {trainReason} · {trainCount} I/Q samples · Testing: {testReason} · {testCount} I/Q samples",
+ "ilc.plant": "PA: {pa} · Peak limit: {peak} · {calls} test plant calls"
}
diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json
index 27ee04d..2337dff 100644
--- a/frontend/src/i18n/es.json
+++ b/frontend/src/i18n/es.json
@@ -248,8 +248,8 @@
"signal.sub_channel_bandwidth_hz": "Ancho de banda del subcanal (Hz)",
"signal.n_sub_ch": "Subcanales",
"signal.nperseg": "Longitud del segmento de PSD (nperseg)",
- "results.detail.profile": "Perfil de métricas",
- "results.detail.profile.help": "Todos los perfiles registrados se puntúan a partir del mismo mejor punto de control; el configurado es el resultado principal.",
+ "results.detail.profile": "Metric calculation",
+ "results.detail.profile.help": "Same output, different score definitions. Select the same calculation for fair comparisons.",
"results.detail.definitions": "Definiciones de métricas",
"results.detail.definitions.help": "Qué significa cada puntuación bajo {profile} v{version}. Los nombres, las fórmulas y las direcciones de mejora provienen del registro de métricas, no de la página.",
"results.detail.parameters": "Parámetros del perfil",
@@ -257,8 +257,8 @@
"metric.definition": "Definición de {name}",
"metric.formula": "Fórmula",
"metric.aggregation": "Agregación",
- "form.profile": "Perfil de métricas",
- "form.profile.help": "La definición detrás de cada puntuación del resultado. Todos los perfiles registrados también se almacenan; este se muestra primero.",
+ "form.profile": "Metric calculation",
+ "form.profile.help": "Changes score definitions, not the model. Every supported calculation is saved; use the same method when comparing runs.",
"results.detail.metric": "Métrica",
"results.detail.unit": "Unidad · dirección",
"form.import": "Importar configuración…",
@@ -1101,7 +1101,7 @@
"generator.generate": "Generate & preview",
"generator.generating": "Generating signal…",
"generator.applyGenerate": "Apply & regenerate",
- "generator.firstPreview": "Preparing a reproducible 5G NR numerology stimulus and its measurements.",
+ "generator.firstPreview": "Choose a waveform, then click Generate to create your PA Input Dataset.",
"generator.advanced": "Advanced parameters",
"generator.advancedHelp": "Customize the waveform geometry and impairments. Modified numerology is labeled custom. Invalid allocations are rejected before generation.",
"generator.fft": "FFT size before oversampling",
@@ -1289,5 +1289,30 @@
"spectrum.trace.model": "PA model prediction",
"spectrum.trace.synthetic": "Dataset output · synthetic",
"spectrum.trace.measured": "Measured output",
- "spectrum.runKey": "Run key"
+ "spectrum.runKey": "Run key",
+ "generator.sharedChannels": "Use the same settings for all channels",
+ "generator.allChannels": "All channels",
+ "profile.general": "General baseband · pooled error & leakage",
+ "profile.legacy": "OpenDPD legacy · segment-averaged scores",
+ "profile.ofdm": "OFDM symbol EVM · reference required",
+ "profile.help": "The calculation method defines averaging, spectral bands and references. It does not change training or the waveform.",
+ "ilc.title": "ILC linearization",
+ "ilc.help": "Learn a PA input waveform by repeated feedback through a trained PA surrogate, then fit an ILA memory-polynomial DPD. ILC is waveform control; train a forward PA model first.",
+ "ilc.defaults": "Starting settings: 30 iterations, learning gain 0.5, target NMSE −45 dB, peak limit 1.5× training-input peak, 6 backtracking steps. These are conservative starting values, not universally optimal.",
+ "ilc.ideal": "ILC Ideal DPD · waveform-specific",
+ "ilc.idealHelp": "Feedback-optimized on this test waveform through the selected surrogate. This is an achievable reference for this configuration, not a transferable model or measured hardware result.",
+ "ilc.benchmark": "ILC-DPD / Ideal benchmark",
+ "paInput.remove": "Remove PA Input Dataset",
+ "paInput.removed": "PA Input Dataset removed from this workspace.",
+ "common.undo": "Undo",
+ "ilc.trainingCurve": "Training waveform · ILC",
+ "ilc.testCurve": "Test waveform · Ideal feedback",
+ "ilc.iteration": "ILC iteration",
+ "ilc.trackingNmse": "Pooled tracking NMSE (dB)",
+ "ilc.stop.target_reached": "Target reached",
+ "ilc.stop.iteration_limit": "Iteration limit reached",
+ "ilc.stop.no_improving_step": "No improving step found",
+ "ilc.stop.improvement_below_tolerance": "Improvement below tolerance",
+ "ilc.summary": "Training: {trainReason} · {trainCount} I/Q samples · Testing: {testReason} · {testCount} I/Q samples",
+ "ilc.plant": "PA: {pa} · Peak limit: {peak} · {calls} test plant calls"
}
diff --git a/frontend/src/i18n/fr.json b/frontend/src/i18n/fr.json
index c14f82f..15ca821 100644
--- a/frontend/src/i18n/fr.json
+++ b/frontend/src/i18n/fr.json
@@ -248,8 +248,8 @@
"signal.sub_channel_bandwidth_hz": "Largeur de bande du sous-canal (Hz)",
"signal.n_sub_ch": "Sous-canaux",
"signal.nperseg": "Longueur de segment PSD (nperseg)",
- "results.detail.profile": "Profil de métriques",
- "results.detail.profile.help": "Chaque profil enregistré est évalué à partir du même meilleur point de contrôle ; le profil configuré est le résultat principal.",
+ "results.detail.profile": "Metric calculation",
+ "results.detail.profile.help": "Same output, different score definitions. Select the same calculation for fair comparisons.",
"results.detail.definitions": "Définitions des métriques",
"results.detail.definitions.help": "Ce que signifie chaque score sous {profile} v{version}. Les noms, les formules et le sens d'amélioration proviennent du registre, pas de la page.",
"results.detail.parameters": "Paramètres du profil",
@@ -257,8 +257,8 @@
"metric.definition": "Définition de {name}",
"metric.formula": "Formule",
"metric.aggregation": "Agrégation",
- "form.profile": "Profil de métriques",
- "form.profile.help": "La définition derrière chaque score du résultat. Chaque profil enregistré est aussi stocké ; celui-ci est affiché en premier.",
+ "form.profile": "Metric calculation",
+ "form.profile.help": "Changes score definitions, not the model. Every supported calculation is saved; use the same method when comparing runs.",
"results.detail.metric": "Métrique",
"results.detail.unit": "Unité · sens",
"form.import": "Importer une configuration…",
@@ -1101,7 +1101,7 @@
"generator.generate": "Generate & preview",
"generator.generating": "Generating signal…",
"generator.applyGenerate": "Apply & regenerate",
- "generator.firstPreview": "Preparing a reproducible 5G NR numerology stimulus and its measurements.",
+ "generator.firstPreview": "Choose a waveform, then click Generate to create your PA Input Dataset.",
"generator.advanced": "Advanced parameters",
"generator.advancedHelp": "Customize the waveform geometry and impairments. Modified numerology is labeled custom. Invalid allocations are rejected before generation.",
"generator.fft": "FFT size before oversampling",
@@ -1289,5 +1289,30 @@
"spectrum.trace.model": "PA model prediction",
"spectrum.trace.synthetic": "Dataset output · synthetic",
"spectrum.trace.measured": "Measured output",
- "spectrum.runKey": "Run key"
+ "spectrum.runKey": "Run key",
+ "generator.sharedChannels": "Use the same settings for all channels",
+ "generator.allChannels": "All channels",
+ "profile.general": "General baseband · pooled error & leakage",
+ "profile.legacy": "OpenDPD legacy · segment-averaged scores",
+ "profile.ofdm": "OFDM symbol EVM · reference required",
+ "profile.help": "The calculation method defines averaging, spectral bands and references. It does not change training or the waveform.",
+ "ilc.title": "ILC linearization",
+ "ilc.help": "Learn a PA input waveform by repeated feedback through a trained PA surrogate, then fit an ILA memory-polynomial DPD. ILC is waveform control; train a forward PA model first.",
+ "ilc.defaults": "Starting settings: 30 iterations, learning gain 0.5, target NMSE −45 dB, peak limit 1.5× training-input peak, 6 backtracking steps. These are conservative starting values, not universally optimal.",
+ "ilc.ideal": "ILC Ideal DPD · waveform-specific",
+ "ilc.idealHelp": "Feedback-optimized on this test waveform through the selected surrogate. This is an achievable reference for this configuration, not a transferable model or measured hardware result.",
+ "ilc.benchmark": "ILC-DPD / Ideal benchmark",
+ "paInput.remove": "Remove PA Input Dataset",
+ "paInput.removed": "PA Input Dataset removed from this workspace.",
+ "common.undo": "Undo",
+ "ilc.trainingCurve": "Training waveform · ILC",
+ "ilc.testCurve": "Test waveform · Ideal feedback",
+ "ilc.iteration": "ILC iteration",
+ "ilc.trackingNmse": "Pooled tracking NMSE (dB)",
+ "ilc.stop.target_reached": "Target reached",
+ "ilc.stop.iteration_limit": "Iteration limit reached",
+ "ilc.stop.no_improving_step": "No improving step found",
+ "ilc.stop.improvement_below_tolerance": "Improvement below tolerance",
+ "ilc.summary": "Training: {trainReason} · {trainCount} I/Q samples · Testing: {testReason} · {testCount} I/Q samples",
+ "ilc.plant": "PA: {pa} · Peak limit: {peak} · {calls} test plant calls"
}
diff --git a/frontend/src/i18n/it.json b/frontend/src/i18n/it.json
index 8a710e1..ee0d156 100644
--- a/frontend/src/i18n/it.json
+++ b/frontend/src/i18n/it.json
@@ -248,8 +248,8 @@
"signal.sub_channel_bandwidth_hz": "Banda del sottocanale (Hz)",
"signal.n_sub_ch": "Sottocanali",
"signal.nperseg": "Lunghezza del segmento PSD (nperseg)",
- "results.detail.profile": "Profilo delle metriche",
- "results.detail.profile.help": "Ogni profilo registrato viene valutato dallo stesso checkpoint migliore; quello configurato è il risultato principale.",
+ "results.detail.profile": "Metric calculation",
+ "results.detail.profile.help": "Same output, different score definitions. Select the same calculation for fair comparisons.",
"results.detail.definitions": "Definizioni delle metriche",
"results.detail.definitions.help": "Significato di ogni punteggio secondo {profile} v{version}. Nomi, formule e direzioni preferibili provengono dal registro, non dalla pagina.",
"results.detail.parameters": "Parametri del profilo",
@@ -257,8 +257,8 @@
"metric.definition": "Definizione di {name}",
"metric.formula": "Formula",
"metric.aggregation": "Aggregazione",
- "form.profile": "Profilo delle metriche",
- "form.profile.help": "La definizione alla base di ogni punteggio del risultato. Vengono salvati anche tutti i profili registrati; questo appare per primo.",
+ "form.profile": "Metric calculation",
+ "form.profile.help": "Changes score definitions, not the model. Every supported calculation is saved; use the same method when comparing runs.",
"results.detail.metric": "Metrica",
"results.detail.unit": "Unità · direzione",
"form.import": "Importa configurazione…",
@@ -1101,7 +1101,7 @@
"generator.generate": "Generate & preview",
"generator.generating": "Generating signal…",
"generator.applyGenerate": "Apply & regenerate",
- "generator.firstPreview": "Preparing a reproducible 5G NR numerology stimulus and its measurements.",
+ "generator.firstPreview": "Choose a waveform, then click Generate to create your PA Input Dataset.",
"generator.advanced": "Advanced parameters",
"generator.advancedHelp": "Customize the waveform geometry and impairments. Modified numerology is labeled custom. Invalid allocations are rejected before generation.",
"generator.fft": "FFT size before oversampling",
@@ -1289,5 +1289,30 @@
"spectrum.trace.model": "PA model prediction",
"spectrum.trace.synthetic": "Dataset output · synthetic",
"spectrum.trace.measured": "Measured output",
- "spectrum.runKey": "Run key"
+ "spectrum.runKey": "Run key",
+ "generator.sharedChannels": "Use the same settings for all channels",
+ "generator.allChannels": "All channels",
+ "profile.general": "General baseband · pooled error & leakage",
+ "profile.legacy": "OpenDPD legacy · segment-averaged scores",
+ "profile.ofdm": "OFDM symbol EVM · reference required",
+ "profile.help": "The calculation method defines averaging, spectral bands and references. It does not change training or the waveform.",
+ "ilc.title": "ILC linearization",
+ "ilc.help": "Learn a PA input waveform by repeated feedback through a trained PA surrogate, then fit an ILA memory-polynomial DPD. ILC is waveform control; train a forward PA model first.",
+ "ilc.defaults": "Starting settings: 30 iterations, learning gain 0.5, target NMSE −45 dB, peak limit 1.5× training-input peak, 6 backtracking steps. These are conservative starting values, not universally optimal.",
+ "ilc.ideal": "ILC Ideal DPD · waveform-specific",
+ "ilc.idealHelp": "Feedback-optimized on this test waveform through the selected surrogate. This is an achievable reference for this configuration, not a transferable model or measured hardware result.",
+ "ilc.benchmark": "ILC-DPD / Ideal benchmark",
+ "paInput.remove": "Remove PA Input Dataset",
+ "paInput.removed": "PA Input Dataset removed from this workspace.",
+ "common.undo": "Undo",
+ "ilc.trainingCurve": "Training waveform · ILC",
+ "ilc.testCurve": "Test waveform · Ideal feedback",
+ "ilc.iteration": "ILC iteration",
+ "ilc.trackingNmse": "Pooled tracking NMSE (dB)",
+ "ilc.stop.target_reached": "Target reached",
+ "ilc.stop.iteration_limit": "Iteration limit reached",
+ "ilc.stop.no_improving_step": "No improving step found",
+ "ilc.stop.improvement_below_tolerance": "Improvement below tolerance",
+ "ilc.summary": "Training: {trainReason} · {trainCount} I/Q samples · Testing: {testReason} · {testCount} I/Q samples",
+ "ilc.plant": "PA: {pa} · Peak limit: {peak} · {calls} test plant calls"
}
diff --git a/frontend/src/i18n/ja.json b/frontend/src/i18n/ja.json
index 9b45cd1..d22eb77 100644
--- a/frontend/src/i18n/ja.json
+++ b/frontend/src/i18n/ja.json
@@ -248,8 +248,8 @@
"signal.sub_channel_bandwidth_hz": "サブチャネル帯域幅 (Hz)",
"signal.n_sub_ch": "サブチャネル数",
"signal.nperseg": "PSD セグメント長 (nperseg)",
- "results.detail.profile": "メトリクスプロファイル",
- "results.detail.profile.help": "登録済みのすべてのプロファイルは同じ最良チェックポイントから採点されます。構成で指定されたプロファイルが主要な結果です。",
+ "results.detail.profile": "Metric calculation",
+ "results.detail.profile.help": "Same output, different score definitions. Select the same calculation for fair comparisons.",
"results.detail.definitions": "メトリクス定義",
"results.detail.definitions.help": "{profile} v{version} における各スコアの意味です。名前、式、改善方向はレジストリから取得したもので、このページで定義されたものではありません。",
"results.detail.parameters": "プロファイルパラメーター",
@@ -257,8 +257,8 @@
"metric.definition": "{name} の定義",
"metric.formula": "式",
"metric.aggregation": "集約",
- "form.profile": "メトリクスプロファイル",
- "form.profile.help": "結果のすべてのスコアの背後にある定義です。登録済みのすべてのプロファイルも保存されますが、これが最初に表示されます。",
+ "form.profile": "Metric calculation",
+ "form.profile.help": "Changes score definitions, not the model. Every supported calculation is saved; use the same method when comparing runs.",
"results.detail.metric": "メトリクス",
"results.detail.unit": "単位 · 方向",
"form.import": "構成をインポート…",
@@ -1101,7 +1101,7 @@
"generator.generate": "Generate & preview",
"generator.generating": "Generating signal…",
"generator.applyGenerate": "Apply & regenerate",
- "generator.firstPreview": "Preparing a reproducible 5G NR numerology stimulus and its measurements.",
+ "generator.firstPreview": "Choose a waveform, then click Generate to create your PA Input Dataset.",
"generator.advanced": "Advanced parameters",
"generator.advancedHelp": "Customize the waveform geometry and impairments. Modified numerology is labeled custom. Invalid allocations are rejected before generation.",
"generator.fft": "FFT size before oversampling",
@@ -1289,5 +1289,30 @@
"spectrum.trace.model": "PA model prediction",
"spectrum.trace.synthetic": "Dataset output · synthetic",
"spectrum.trace.measured": "Measured output",
- "spectrum.runKey": "Run key"
+ "spectrum.runKey": "Run key",
+ "generator.sharedChannels": "Use the same settings for all channels",
+ "generator.allChannels": "All channels",
+ "profile.general": "General baseband · pooled error & leakage",
+ "profile.legacy": "OpenDPD legacy · segment-averaged scores",
+ "profile.ofdm": "OFDM symbol EVM · reference required",
+ "profile.help": "The calculation method defines averaging, spectral bands and references. It does not change training or the waveform.",
+ "ilc.title": "ILC linearization",
+ "ilc.help": "Learn a PA input waveform by repeated feedback through a trained PA surrogate, then fit an ILA memory-polynomial DPD. ILC is waveform control; train a forward PA model first.",
+ "ilc.defaults": "Starting settings: 30 iterations, learning gain 0.5, target NMSE −45 dB, peak limit 1.5× training-input peak, 6 backtracking steps. These are conservative starting values, not universally optimal.",
+ "ilc.ideal": "ILC Ideal DPD · waveform-specific",
+ "ilc.idealHelp": "Feedback-optimized on this test waveform through the selected surrogate. This is an achievable reference for this configuration, not a transferable model or measured hardware result.",
+ "ilc.benchmark": "ILC-DPD / Ideal benchmark",
+ "paInput.remove": "Remove PA Input Dataset",
+ "paInput.removed": "PA Input Dataset removed from this workspace.",
+ "common.undo": "Undo",
+ "ilc.trainingCurve": "Training waveform · ILC",
+ "ilc.testCurve": "Test waveform · Ideal feedback",
+ "ilc.iteration": "ILC iteration",
+ "ilc.trackingNmse": "Pooled tracking NMSE (dB)",
+ "ilc.stop.target_reached": "Target reached",
+ "ilc.stop.iteration_limit": "Iteration limit reached",
+ "ilc.stop.no_improving_step": "No improving step found",
+ "ilc.stop.improvement_below_tolerance": "Improvement below tolerance",
+ "ilc.summary": "Training: {trainReason} · {trainCount} I/Q samples · Testing: {testReason} · {testCount} I/Q samples",
+ "ilc.plant": "PA: {pa} · Peak limit: {peak} · {calls} test plant calls"
}
diff --git a/frontend/src/i18n/ko.json b/frontend/src/i18n/ko.json
index fa2895b..eb7eeff 100644
--- a/frontend/src/i18n/ko.json
+++ b/frontend/src/i18n/ko.json
@@ -248,8 +248,8 @@
"signal.sub_channel_bandwidth_hz": "서브채널 대역폭 (Hz)",
"signal.n_sub_ch": "서브채널 수",
"signal.nperseg": "PSD 세그먼트 길이 (nperseg)",
- "results.detail.profile": "메트릭 프로필",
- "results.detail.profile.help": "등록된 모든 프로필은 같은 최적 체크포인트로 채점되며, 구성된 프로필이 주 결과입니다.",
+ "results.detail.profile": "Metric calculation",
+ "results.detail.profile.help": "Same output, different score definitions. Select the same calculation for fair comparisons.",
"results.detail.definitions": "메트릭 정의",
"results.detail.definitions.help": "{profile} v{version}에서 각 점수가 의미하는 바입니다. 이름, 수식, 개선 방향은 페이지가 아니라 레지스트리에서 가져옵니다.",
"results.detail.parameters": "프로필 매개변수",
@@ -257,8 +257,8 @@
"metric.definition": "{name} 정의",
"metric.formula": "수식",
"metric.aggregation": "집계",
- "form.profile": "메트릭 프로필",
- "form.profile.help": "결과의 모든 점수 뒤에 있는 정의입니다. 등록된 모든 프로필도 함께 저장되며, 이 프로필이 먼저 표시됩니다.",
+ "form.profile": "Metric calculation",
+ "form.profile.help": "Changes score definitions, not the model. Every supported calculation is saved; use the same method when comparing runs.",
"results.detail.metric": "메트릭",
"results.detail.unit": "단위 · 방향",
"form.import": "구성 가져오기…",
@@ -1101,7 +1101,7 @@
"generator.generate": "Generate & preview",
"generator.generating": "Generating signal…",
"generator.applyGenerate": "Apply & regenerate",
- "generator.firstPreview": "Preparing a reproducible 5G NR numerology stimulus and its measurements.",
+ "generator.firstPreview": "Choose a waveform, then click Generate to create your PA Input Dataset.",
"generator.advanced": "Advanced parameters",
"generator.advancedHelp": "Customize the waveform geometry and impairments. Modified numerology is labeled custom. Invalid allocations are rejected before generation.",
"generator.fft": "FFT size before oversampling",
@@ -1289,5 +1289,30 @@
"spectrum.trace.model": "PA model prediction",
"spectrum.trace.synthetic": "Dataset output · synthetic",
"spectrum.trace.measured": "Measured output",
- "spectrum.runKey": "Run key"
+ "spectrum.runKey": "Run key",
+ "generator.sharedChannels": "Use the same settings for all channels",
+ "generator.allChannels": "All channels",
+ "profile.general": "General baseband · pooled error & leakage",
+ "profile.legacy": "OpenDPD legacy · segment-averaged scores",
+ "profile.ofdm": "OFDM symbol EVM · reference required",
+ "profile.help": "The calculation method defines averaging, spectral bands and references. It does not change training or the waveform.",
+ "ilc.title": "ILC linearization",
+ "ilc.help": "Learn a PA input waveform by repeated feedback through a trained PA surrogate, then fit an ILA memory-polynomial DPD. ILC is waveform control; train a forward PA model first.",
+ "ilc.defaults": "Starting settings: 30 iterations, learning gain 0.5, target NMSE −45 dB, peak limit 1.5× training-input peak, 6 backtracking steps. These are conservative starting values, not universally optimal.",
+ "ilc.ideal": "ILC Ideal DPD · waveform-specific",
+ "ilc.idealHelp": "Feedback-optimized on this test waveform through the selected surrogate. This is an achievable reference for this configuration, not a transferable model or measured hardware result.",
+ "ilc.benchmark": "ILC-DPD / Ideal benchmark",
+ "paInput.remove": "Remove PA Input Dataset",
+ "paInput.removed": "PA Input Dataset removed from this workspace.",
+ "common.undo": "Undo",
+ "ilc.trainingCurve": "Training waveform · ILC",
+ "ilc.testCurve": "Test waveform · Ideal feedback",
+ "ilc.iteration": "ILC iteration",
+ "ilc.trackingNmse": "Pooled tracking NMSE (dB)",
+ "ilc.stop.target_reached": "Target reached",
+ "ilc.stop.iteration_limit": "Iteration limit reached",
+ "ilc.stop.no_improving_step": "No improving step found",
+ "ilc.stop.improvement_below_tolerance": "Improvement below tolerance",
+ "ilc.summary": "Training: {trainReason} · {trainCount} I/Q samples · Testing: {testReason} · {testCount} I/Q samples",
+ "ilc.plant": "PA: {pa} · Peak limit: {peak} · {calls} test plant calls"
}
diff --git a/frontend/src/i18n/nl.json b/frontend/src/i18n/nl.json
index 43521b5..a9089f5 100644
--- a/frontend/src/i18n/nl.json
+++ b/frontend/src/i18n/nl.json
@@ -248,8 +248,8 @@
"signal.sub_channel_bandwidth_hz": "Subkanaalbandbreedte (Hz)",
"signal.n_sub_ch": "Subkanalen",
"signal.nperseg": "PSD-segmentlengte (nperseg)",
- "results.detail.profile": "Meetprofiel",
- "results.detail.profile.help": "Elk geregistreerd profiel wordt geëvalueerd met hetzelfde beste checkpoint; het ingestelde profiel is het primaire resultaat.",
+ "results.detail.profile": "Metric calculation",
+ "results.detail.profile.help": "Same output, different score definitions. Select the same calculation for fair comparisons.",
"results.detail.definitions": "Definities van metingen",
"results.detail.definitions.help": "Betekenis van elke score onder {profile} v{version}. Namen, formules en voorkeursrichtingen komen uit het register, niet uit de pagina.",
"results.detail.parameters": "Profielparameters",
@@ -257,8 +257,8 @@
"metric.definition": "Definitie van {name}",
"metric.formula": "Formule",
"metric.aggregation": "Aggregatie",
- "form.profile": "Meetprofiel",
- "form.profile.help": "De definitie achter elke score in het resultaat. Alle geregistreerde profielen worden ook opgeslagen; dit profiel wordt eerst getoond.",
+ "form.profile": "Metric calculation",
+ "form.profile.help": "Changes score definitions, not the model. Every supported calculation is saved; use the same method when comparing runs.",
"results.detail.metric": "Meting",
"results.detail.unit": "Eenheid · richting",
"form.import": "Configuratie importeren…",
@@ -1101,7 +1101,7 @@
"generator.generate": "Generate & preview",
"generator.generating": "Generating signal…",
"generator.applyGenerate": "Apply & regenerate",
- "generator.firstPreview": "Preparing a reproducible 5G NR numerology stimulus and its measurements.",
+ "generator.firstPreview": "Choose a waveform, then click Generate to create your PA Input Dataset.",
"generator.advanced": "Advanced parameters",
"generator.advancedHelp": "Customize the waveform geometry and impairments. Modified numerology is labeled custom. Invalid allocations are rejected before generation.",
"generator.fft": "FFT size before oversampling",
@@ -1289,5 +1289,30 @@
"spectrum.trace.model": "PA model prediction",
"spectrum.trace.synthetic": "Dataset output · synthetic",
"spectrum.trace.measured": "Measured output",
- "spectrum.runKey": "Run key"
+ "spectrum.runKey": "Run key",
+ "generator.sharedChannels": "Use the same settings for all channels",
+ "generator.allChannels": "All channels",
+ "profile.general": "General baseband · pooled error & leakage",
+ "profile.legacy": "OpenDPD legacy · segment-averaged scores",
+ "profile.ofdm": "OFDM symbol EVM · reference required",
+ "profile.help": "The calculation method defines averaging, spectral bands and references. It does not change training or the waveform.",
+ "ilc.title": "ILC linearization",
+ "ilc.help": "Learn a PA input waveform by repeated feedback through a trained PA surrogate, then fit an ILA memory-polynomial DPD. ILC is waveform control; train a forward PA model first.",
+ "ilc.defaults": "Starting settings: 30 iterations, learning gain 0.5, target NMSE −45 dB, peak limit 1.5× training-input peak, 6 backtracking steps. These are conservative starting values, not universally optimal.",
+ "ilc.ideal": "ILC Ideal DPD · waveform-specific",
+ "ilc.idealHelp": "Feedback-optimized on this test waveform through the selected surrogate. This is an achievable reference for this configuration, not a transferable model or measured hardware result.",
+ "ilc.benchmark": "ILC-DPD / Ideal benchmark",
+ "paInput.remove": "Remove PA Input Dataset",
+ "paInput.removed": "PA Input Dataset removed from this workspace.",
+ "common.undo": "Undo",
+ "ilc.trainingCurve": "Training waveform · ILC",
+ "ilc.testCurve": "Test waveform · Ideal feedback",
+ "ilc.iteration": "ILC iteration",
+ "ilc.trackingNmse": "Pooled tracking NMSE (dB)",
+ "ilc.stop.target_reached": "Target reached",
+ "ilc.stop.iteration_limit": "Iteration limit reached",
+ "ilc.stop.no_improving_step": "No improving step found",
+ "ilc.stop.improvement_below_tolerance": "Improvement below tolerance",
+ "ilc.summary": "Training: {trainReason} · {trainCount} I/Q samples · Testing: {testReason} · {testCount} I/Q samples",
+ "ilc.plant": "PA: {pa} · Peak limit: {peak} · {calls} test plant calls"
}
diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json
index 4370cd3..c2f75a0 100644
--- a/frontend/src/i18n/zh.json
+++ b/frontend/src/i18n/zh.json
@@ -248,8 +248,8 @@
"signal.sub_channel_bandwidth_hz": "子信道带宽(Hz)",
"signal.n_sub_ch": "子信道数",
"signal.nperseg": "PSD 段长度(nperseg)",
- "results.detail.profile": "指标配置",
- "results.detail.profile.help": "每个已注册的指标配置都从同一最佳检查点评分;所配置的那个是主要结果。",
+ "results.detail.profile": "指标计算方式",
+ "results.detail.profile.help": "同一输出,不同指标定义。公平比较时请选用相同计算方式。",
"results.detail.definitions": "指标定义",
"results.detail.definitions.help": "在 {profile} v{version} 下每个分数的含义。名称、公式和优劣方向来自注册表,而非本页面。",
"results.detail.parameters": "指标配置参数",
@@ -257,8 +257,8 @@
"metric.definition": "{name} 的定义",
"metric.formula": "公式",
"metric.aggregation": "聚合方式",
- "form.profile": "指标配置",
- "form.profile.help": "结果中每个分数背后的定义。所有已注册的指标配置也都会存储;此配置优先显示。",
+ "form.profile": "指标计算方式",
+ "form.profile.help": "改变指标定义,不改变模型。系统保存全部支持的计算方式;比较实验时请使用相同方式。",
"results.detail.metric": "指标",
"results.detail.unit": "单位 · 方向",
"form.import": "导入配置…",
@@ -1101,7 +1101,7 @@
"generator.generate": "生成并预览",
"generator.generating": "正在生成信号…",
"generator.applyGenerate": "应用并重新生成",
- "generator.firstPreview": "正在准备可复现的 5G NR 数字参数测试信号与测量图表。",
+ "generator.firstPreview": "选择波形,点击 Generate 后才会创建 PA Input Dataset。",
"generator.advanced": "高级参数",
"generator.advancedHelp": "自定义波形结构和损伤参数。更改预设数字参数后会标记为自定义。无效子载波分配将在生成前被拒绝。",
"generator.fft": "过采样前 FFT 点数",
@@ -1289,5 +1289,30 @@
"spectrum.trace.model": "PA 模型预测",
"spectrum.trace.synthetic": "数据集输出 · 合成",
"spectrum.trace.measured": "实测输出",
- "spectrum.runKey": "实验编号对照"
+ "spectrum.runKey": "实验编号对照",
+ "generator.sharedChannels": "所有 channel 使用相同设置",
+ "generator.allChannels": "所有 channel",
+ "profile.general": "通用基带 · 汇总误差与泄漏",
+ "profile.legacy": "OpenDPD 传统 · 分段指标平均",
+ "profile.ofdm": "OFDM 符号 EVM · 需要参考",
+ "profile.help": "计算方式规定平均方法、频带和参考信号,不改变训练或波形。",
+ "ilc.title": "ILC 线性化",
+ "ilc.help": "通过已训练的 PA 代理模型反复反馈,学习 PA 输入波形,再用 ILA 拟合记忆多项式 DPD。ILC 是波形控制;请先训练前向 PA 模型。",
+ "ilc.defaults": "建议起点:30 次迭代、学习增益 0.5、目标 NMSE −45 dB、峰值上限为训练输入峰值的 1.5 倍、6 次回退。这些是保守的初始值,不是适合所有 PA 的最优值。",
+ "ilc.ideal": "ILC Ideal DPD · 仅针对当前波形",
+ "ilc.idealHelp": "通过选定代理模型,对当前测试波形进行反馈优化。这是当前配置下可达的参考,不是可泛化模型或真实硬件实测结果。",
+ "ilc.benchmark": "ILC-DPD / Ideal 基准",
+ "paInput.remove": "移除 PA Input Dataset",
+ "paInput.removed": "已从当前工作区移除 PA Input Dataset。",
+ "common.undo": "撤销",
+ "ilc.trainingCurve": "训练波形 · ILC",
+ "ilc.testCurve": "测试波形 · Ideal 反馈",
+ "ilc.iteration": "ILC 迭代次数",
+ "ilc.trackingNmse": "汇总跟踪 NMSE(dB)",
+ "ilc.stop.target_reached": "已达到目标",
+ "ilc.stop.iteration_limit": "已达到迭代上限",
+ "ilc.stop.no_improving_step": "未找到可改善的更新",
+ "ilc.stop.improvement_below_tolerance": "改善幅度低于容差",
+ "ilc.summary": "训练:{trainReason} · {trainCount} 个 I/Q 样本 · 测试:{testReason} · {testCount} 个 I/Q 样本",
+ "ilc.plant": "PA:{pa} · 峰值上限:{peak} · 测试 PA 调用次数:{calls}"
}
diff --git a/frontend/src/pages/AboutPage.test.tsx b/frontend/src/pages/AboutPage.test.tsx
index d2e4518..226f533 100644
--- a/frontend/src/pages/AboutPage.test.tsx
+++ b/frontend/src/pages/AboutPage.test.tsx
@@ -2,13 +2,14 @@ import { screen } from '@testing-library/react'
import { mockApi, renderWithProviders } from '@/test/utils'
import { AboutPage } from './AboutPage'
-test('shows requested leadership, original logos, and linked GitHub activity', async () => {
+test('shows requested leadership, original logos, without GitHub activity', async () => {
mockApi({ 'GET /api/v1/system/about': () => ({ version: '2.2.0.dev0', local_commit: '123456abcdef', status: 'current', updated_at: '2026-09-12T11:00:00Z', contributors: [{ login: 'colleague', contributions: 3, url: 'https://github.com/colleague' }], commits: [{ sha: '12345678', url: 'https://github.com/lab-emi/OpenDPD/commit/12345678', message: 'Recent work', author: 'Contributor', date: '2026-09-12T11:00:00Z' }] }) })
renderWithProviders()
- await screen.findByRole('link', { name: 'Recent work' })
+ await screen.findByText('v2.2.0.dev0')
expect(screen.getByRole('img', { name: 'EMI Lab' })).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'TU Delft' })).toBeInTheDocument()
expect(screen.getByText('Project Leader')).toBeInTheDocument()
expect(screen.getByText('Leading Developer')).toBeInTheDocument()
- expect(screen.getByRole('link', { name: 'colleague' })).toHaveAttribute('href', 'https://github.com/colleague')
+ expect(screen.queryByText('GitHub activity')).not.toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: 'colleague' })).not.toBeInTheDocument()
})
diff --git a/frontend/src/pages/AboutPage.tsx b/frontend/src/pages/AboutPage.tsx
index 9d85bdd..dba3f5e 100644
--- a/frontend/src/pages/AboutPage.tsx
+++ b/frontend/src/pages/AboutPage.tsx
@@ -1,8 +1,6 @@
import GitHubIcon from '@mui/icons-material/GitHub'
-import RefreshIcon from '@mui/icons-material/Refresh'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import Avatar from '@mui/material/Avatar'
-import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
@@ -13,7 +11,7 @@ import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/api/client'
-import { formatDateTime, formatNumber, t, type MessageKey } from '@/i18n'
+import { t, type MessageKey } from '@/i18n'
import emi from '@/assets/emi-logo.svg'
import tudelft from '@/assets/tudelft-logo.svg'
import changGao from '@/assets/chang-gao.webp'
@@ -38,7 +36,7 @@ export function AboutPage() {
const colors = useStudioColors()
// Partner artwork keeps its official colors on an intentional light brand plate.
const partnerPlate = { display: 'flex', alignItems: 'center', p: 1.5, borderRadius: 1, bgcolor: '#F9FBFD' }
- const info = useQuery({ queryKey: ['system', 'about'], queryFn: () => api.get('/system/about'), refetchInterval: 300_000, staleTime: 300_000, retry: false })
+ const info = useQuery({ queryKey: ['system', 'about'], queryFn: () => api.get('/system/about?activity=false'), staleTime: 300_000, retry: false })
const data = info.data
return
{t('about.title')}
@@ -55,19 +53,6 @@ export function AboutPage() {
{t('about.people')}
{LEADERS.map((person) => {person.name.split(' ').map((word) => word[0]).join('')}{person.name}{t(person.role)})}
-
- {t('about.activity')}} disabled={info.isFetching} onClick={() => void info.refetch()}>{t('about.refresh')}
- {t('about.refreshNote')}{data?.updated_at ? ` · ${formatDateTime(data.updated_at)}` : ''}
- {(info.isError || data?.status === 'unavailable' || data?.status === 'stale') && {t('about.offline')}}
- {info.isPending && {t('about.loading')}}
-
- {t('about.contributors')}
- {(data?.contributors ?? []).map((person) => {LEADERS.find((leader) => leader.login === person.login)?.name ?? person.login}{t('about.commits', { count: formatNumber(person.contributions) })})}
- {t('about.allContributors')}
-
- {t('about.latest')}{t('about.original')}{(data?.commits ?? []).map((commit) => {commit.message}{commit.sha.slice(0, 8)} · {commit.author} · {formatDateTime(commit.date)})}
-
-
Apache-2.0 · {t('about.localCommit')}: {data?.local_commit?.slice(0, 12) ?? '—'}
}
diff --git a/frontend/src/pages/NewExperimentPage.test.tsx b/frontend/src/pages/NewExperimentPage.test.tsx
index c8664ea..80fc16b 100644
--- a/frontend/src/pages/NewExperimentPage.test.tsx
+++ b/frontend/src/pages/NewExperimentPage.test.tsx
@@ -197,10 +197,10 @@ test('a profile pending cross-validation is computed by the service but never of
expect(ofdmProfile.data.validation).toBe('pending_cross_validation')
await continueStep()
await userEvent.click(screen.getByRole('button', { name: 'Advanced settings' }))
- await userEvent.click(screen.getByLabelText('Metric profile'))
+ await userEvent.click(screen.getByLabelText('Metric calculation'))
const options = screen.getAllByRole('option').map((o) => o.textContent)
- expect(options.some((t) => t?.startsWith('legacy-opendpd-v1'))).toBe(true)
- expect(options.some((t) => t?.startsWith('general-spectral-v1'))).toBe(true)
+ expect(options.some((t) => t?.startsWith('OpenDPD legacy'))).toBe(true)
+ expect(options.some((t) => t?.startsWith('General baseband'))).toBe(true)
expect(options.some((t) => t?.startsWith('ofdm-lte20-evm-v1'))).toBe(false)
})
diff --git a/frontend/src/pages/NewExperimentPage.tsx b/frontend/src/pages/NewExperimentPage.tsx
index d2a933a..eef7a01 100644
--- a/frontend/src/pages/NewExperimentPage.tsx
+++ b/frontend/src/pages/NewExperimentPage.tsx
@@ -21,7 +21,7 @@ import { useEffect, useRef, useState, type FormEvent } from 'react'
import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router'
import { versionNames } from '@/api/datasets'
import { useCapabilities, useDatasets, useMetricProfiles, useModels, useRecipes, useRunConfig, useRuns, useSubmitRun, validateConfig } from '@/api/hooks'
-import { offeredProfiles } from '@/api/profiles'
+import { offeredProfiles, profileLabel } from '@/api/profiles'
import type { ConfigIssue, Device, ExperimentConfigInput, ModelInfo, RecipeInfo, ValidationReport } from '@/api/types'
import { datasetLabel, message, phaseLabel, t } from '@/i18n'
import { WorkflowSteps } from '@/components/WorkflowSteps'
@@ -159,7 +159,9 @@ function ExperimentForm({ task }: { task: ExperimentTask }) {
const [confirmed, setConfirmed] = useState>({})
const testing = task === 'evaluate_pa' || task === 'run_dpd'
- const taskRecipes = (recipes.data ?? []).filter((entry) => entry.task === task)
+ const ilcRequested = params.get('method') === 'ilc'
+ const displayGroup = ilcRequested && params.get('workspace') === 'pa' ? 'pa' : taskGroup(task)
+ const taskRecipes = (recipes.data ?? []).filter((entry) => entry.task === task && (!ilcRequested || entry.model.key === 'ilc_dpd'))
const sourceRunId = edits.sourceRunId || params.get('modelRun') || ''
const source = useRunConfig(sourceRunId, testing && !!sourceRunId)
const sourceRuns = (succeeded.data ?? []).filter((run) => run.task === (task === 'evaluate_pa' ? 'train_pa' : 'train_dpd'))
@@ -173,7 +175,7 @@ function ExperimentForm({ task }: { task: ExperimentTask }) {
const requestedVersion = edits.dataVersion || (datasetId === params.get('dataset') ? params.get('version') : '') || (testing && source.data?.dataset.id === datasetId ? source.data.dataset.preprocessing_version : '') || ''
// Capability discovery is asynchronous. Derive the initial device until the
// user chooses one; later refetches must never overwrite that explicit choice.
- const defaultDevice = caps.data?.devices.some((d) => d.device === 'cuda' && d.detected) ? 'cuda' : 'cpu'
+ const defaultDevice = ['cuda', 'mps', 'cpu'].find(device => caps.data?.devices.some(d => d.device === device && d.detected)) ?? 'cpu'
const form: FormState = { ...edits, paRunId: edits.paRunId || params.get('paRun') || '', device: edits.device || defaultDevice, recipeId: edits.recipeId || taskRecipes[0]?.recipe_id || '', datasetId, dataVersion: versions.includes(requestedVersion) ? requestedVersion : '' }
const recipe = taskRecipes.find((r) => r.recipe_id === form.recipeId) ?? null
@@ -266,21 +268,37 @@ function ExperimentForm({ task }: { task: ExperimentTask }) {
return (
- {t(`modelWorkflow.${taskGroup(config?.task ?? task)}`)}
+ {t(`modelWorkflow.${displayGroup}`)}
-
-
+
+
{(['train', 'test'] as const).map((mode) => {
- const nextTask = taskGroup(task) === 'pa' ? (mode === 'train' ? 'train_pa' : 'evaluate_pa') : (mode === 'train' ? 'train_dpd' : 'run_dpd')
+ const nextTask = displayGroup === 'pa' ? (mode === 'train' ? 'train_pa' : 'evaluate_pa') : (mode === 'train' ? 'train_dpd' : 'run_dpd')
const query = new URLSearchParams({ task: nextTask })
if (form.datasetId) query.set('dataset', form.datasetId)
if (form.dataVersion) query.set('version', form.dataVersion)
return
})}
+
{(config?.task === 'evaluate_pa' || config?.task === 'run_dpd' || testing) && }
+
+ {step > 0 && } onClick={() => setStep(step - 1)}>{t('workflow.back')}}
+ {step < 2 && } onClick={next} disabled={!canNext}>{t('workflow.next')}}
+ } type="submit" variant="contained" disabled={!canSubmit} sx={{ display: step === 2 ? 'inline-flex' : 'none' }}>
+ {submit.isPending ? t('form.submitting') : t('form.submit')}
+
+
+
+ {checking ? t('form.validating') : report?.ok ? t('form.valid') : ''}
+
+
+ {modelKey === 'ilc_dpd' && {t('ilc.help')}{t('ilc.defaults')}}
{recipe?.purpose === 'smoke' && !imported && {t('form.smokeBanner')}}
{jsonOpen && setJsonOpen(false)} onApply={(edited, label) => { setImportedFile({ config: edited, source: label }); setJsonOpen(false); setStep(0); setConfirmed({}) }} />}
{imported && (
@@ -444,7 +462,7 @@ function ExperimentForm({ task }: { task: ExperimentTask }) {
0} helperText={errorText('profileId') || t('form.profile.help')}>
{offeredProfiles(metricProfiles.data).map((p) => (
))}
@@ -513,19 +531,7 @@ function ExperimentForm({ task }: { task: ExperimentTask }) {
)}
{submit.isError && }
-
- {step > 0 && } onClick={() => setStep(step - 1)}>{t('workflow.back')}}
- {step < 2 && } onClick={next} disabled={!canNext}>{t('workflow.next')}}
-
-
-
- {checking ? t('form.validating') : report?.ok ? t('form.valid') : ''}
-
-
+
)
}
diff --git a/frontend/src/pages/PALibraryPage.tsx b/frontend/src/pages/PALibraryPage.tsx
index 4b1cba2..29e9bbf 100644
--- a/frontend/src/pages/PALibraryPage.tsx
+++ b/frontend/src/pages/PALibraryPage.tsx
@@ -1,3 +1,6 @@
+import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined'
+import { api } from '@/api/client'
+import { MathFormula } from '@/components/MathFormula'
import DownloadIcon from '@mui/icons-material/Download'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import PlayArrowIcon from '@mui/icons-material/PlayArrow'
@@ -36,9 +39,8 @@ const CATEGORIES = ['reference', 'static', 'memory', 'dynamics', 'architecture']
const GROUPS = ['gain', 'memory', 'dynamics', 'architecture'] as const
const validParameter = (p: PAParameter, value: number) => Number.isFinite(value) && value >= p.minimum && value <= p.maximum && (!p.integer || Number.isInteger(value))
-/** No formula evaluation or injected markup: only catalog tokens become controls. */
+/** Display-only KaTeX: catalog coefficients become linked controls, never executable formulas. */
export function PAEquations({ model, active, select }: { model: VirtualPA; active: string; select: (key: string) => void }) {
- const colors = useStudioColors()
const scroll = useRef(null)
useEffect(() => {
const box = scroll.current
@@ -49,16 +51,9 @@ export function PAEquations({ model, active, select }: { model: VirtualPA; activ
if (token.left < area.left || token.right > area.right) box.scrollLeft += token.left - area.left - box.clientWidth / 2 + token.width / 2
}, [active])
return
- {model.equations.map((equation, index) =>
- {equation.split(/(\{\{\w+\}\})/g).map((token, i) => {
- const key = /^\{\{(\w+)\}\}$/.exec(token)?.[1]
- const p = model.parameters.find(entry => entry.key === key)
- return p ? select(p.key)} aria-label={paText(p.label)} aria-pressed={active === p.key}
- data-testid={'equation-' + p.key} sx={{ font: 'inherit', fontStyle: 'italic', fontWeight: 700, px: .4, mx: .1, borderRadius: .5,
- color: active === p.key ? 'primary.main' : 'text.primary', bgcolor: active === p.key ? colors.selected : 'transparent',
- outline: active === p.key ? '1px solid ' + colors.primary : 'none', '&:focus-visible': { outline: '2px solid ' + colors.primary } }}>{p.symbol} : {token}
- })}
- )}
+ {(model.equations_latex?.length ? model.equations_latex : model.equations).map((equation, index) =>
+ ({ key: p.key, symbol: p.symbol_latex || p.symbol, label: paText(p.label) }))} />)}
}
@@ -110,6 +105,8 @@ function Library({ models }: { models: VirtualPA[] }) {
const [inputId, setInputId] = useState(query.get('input') ?? (workflow.state.origin === 'generated' ? workflow.state.inputId ?? '' : ''))
const [active, setActive] = useState(initialModel.parameters[0]!.key)
const [error, setError] = useState(null)
+ const [removed, setRemoved] = useState(null)
+ const [removing, setRemoving] = useState(false)
const [downloading, setDownloading] = useState(false)
const simulate = useSimulatePA()
const create = usePairedDataset()
@@ -138,6 +135,9 @@ function Library({ models }: { models: VirtualPA[] }) {
{t('paLibrary.eyebrow')}{t('paLibrary.title')}
+ {removed && {
+ setRemoving(true); void api.post(`/signal-generator/signals/${removed}/restore`, {}).then(() => { setInputId(removed); setRemoved(null); return inputs.refetch() }).catch(setError).finally(() => setRemoving(false))
+ }}>{t('common.undo')}}>{t('paInput.removed')}}
{t('paLibrary.intro')}
{t('paLibrary.chooseModel')}
@@ -152,6 +152,31 @@ function Library({ models }: { models: VirtualPA[] }) {
)}
+
+ {t('paLibrary.feed')}
+ {inputs.isPending ? : inputs.isError ? void inputs.refetch()} /> :
+ { setInputId(e.target.value); simulate.reset(); create.reset() }}>
+
+ {inputs.data.map(entry => )}
+ }
+ {input ? {t('paLibrary.inputSummary', { count: formatNumber(input.n_samples), duration: (1000 * input.n_samples / input.sample_rate_hz).toPrecision(5) })}
+ : {t('paLibrary.noInput')}}
+
+ } endIcon={} onClick={run} disabled={!input || !valid || simulate.isPending || create.isPending}>{t(simulate.isPending ? 'paLibrary.simulating' : 'paLibrary.simulate')}
+ } disabled={!input || removing || simulate.isPending || create.isPending} onClick={() => {
+ if (!input) return
+ const id = input.signal_id; setRemoving(true)
+ void api.post(`/signal-generator/signals/${id}/archive`, {}).then(() => {
+ setRemoved(id); setInputId(''); simulate.reset(); create.reset(); workflow.reset(); return inputs.refetch()
+ }).catch(setError).finally(() => setRemoving(false))
+ }}>{t('paInput.remove')}
+
+
+ {simulate.isPending && }
+ {candidate && !preview && !simulate.isPending && {t('paLibrary.stale')}}
+ {(simulate.isError || saved.isError || !!error) && }
+
{paText(model.name)}{paText(model.description)}
{paText(model.limitations)}
@@ -173,28 +198,10 @@ function Library({ models }: { models: VirtualPA[] }) {
)} : null
})}
-
- {t('paLibrary.feed')}
- {inputs.isPending ? : inputs.isError ? void inputs.refetch()} /> :
- { setInputId(e.target.value); simulate.reset(); create.reset() }}>
-
- {inputs.data.map(entry => )}
- }
- {input ? {t('paLibrary.inputSummary', { count: formatNumber(input.n_samples), duration: (1000 * input.n_samples / input.sample_rate_hz).toPrecision(5) })}
- : {t('paLibrary.noInput')}}
-
- } onClick={run} disabled={!input || !valid || simulate.isPending || create.isPending}>{t(simulate.isPending ? 'paLibrary.simulating' : 'paLibrary.simulate')}
-
-
- {simulate.isPending && }
- {candidate && !preview && !simulate.isPending && {t('paLibrary.stale')}}
- {(simulate.isError || saved.isError || !!error) && }
-
+
{preview && <>
-
} variant="outlined" disabled={downloading} onClick={() => download(preview.output_csv_url)}>{t('paLibrary.outputCsv')}
@@ -208,6 +215,7 @@ function Library({ models }: { models: VirtualPA[] }) {
} })
}} />
+
>}
}
@@ -226,6 +234,7 @@ function PairedDatasetForm({ result, allowed, pending, error, onCreate }: { resu
&& Math.min(nTrain, nVal, nTest) >= 256 && n >= 8192
return { e.preventDefault(); if (valid && allowed && !pending) onCreate({ dataset_id: id, display_name: name.trim(), guard_samples: g, train_ratio: tr, val_ratio: vr }) }}>
{t('paLibrary.pairTitle')}{t('paLibrary.pairHelp')}
+ } disabled={!allowed || !valid || pending} sx={{ alignSelf: 'flex-start' }}>{t(pending ? 'paLibrary.creating' : 'paLibrary.create')}
setId(e.target.value)} />
setName(e.target.value)} />
}>{t('paLibrary.splits')}
@@ -236,7 +245,7 @@ function PairedDatasetForm({ result, allowed, pending, error, onCreate }: { resu
{valid ? t('paLibrary.splitCounts', { train: formatNumber(nTrain), val: formatNumber(nVal), test: formatNumber(nTest) }) : t('paLibrary.splitInvalid')}
{!allowed && {t('paLibrary.importDisabled')}}
{!!error && }
- } disabled={!allowed || !valid || pending} sx={{ alignSelf: 'flex-start' }}>{t(pending ? 'paLibrary.creating' : 'paLibrary.create')}
+
}
diff --git a/frontend/src/pages/ResultDetailPage.test.tsx b/frontend/src/pages/ResultDetailPage.test.tsx
index 5b697da..fde020b 100644
--- a/frontend/src/pages/ResultDetailPage.test.tsx
+++ b/frontend/src/pages/ResultDetailPage.test.tsx
@@ -52,15 +52,16 @@ test('shows the profile behind every score, its definitions, and switches to ano
'GET /api/v1/metrics/profiles': () => [legacyProfile.data, generalProfile.data, ofdmProfile.data],
})
renderWithProviders(, { route: '/results/run-pa-0001', path: '/results/:runId' })
- await screen.findByText('legacy-opendpd-v1 v1 · frozen')
+ await screen.findAllByText('OpenDPD legacy · segment-averaged scores')
expect(screen.getByRole('heading', { level: 3, name: 'NMSE (mean of segment dB)' })).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Definition of EVM' }))
await screen.findByText(/Not a demodulated constellation EVM/)
expect(screen.getByRole('button', { name: 'Metric definitions' })).toBeInTheDocument()
- await userEvent.click(screen.getByLabelText('Metric profile'))
- expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual(['legacy-opendpd-v1', 'general-spectral-v1']) // pending one hidden
- await userEvent.click(await screen.findByRole('option', { name: 'general-spectral-v1' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Metric definitions' }))
+ await userEvent.click(screen.getByLabelText('Metric calculation'))
+ expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual(['OpenDPD legacy · segment-averaged scores', 'General baseband · pooled error & leakage']) // pending one hidden
+ await userEvent.click(await screen.findByRole('option', { name: 'General baseband · pooled error & leakage' }))
await screen.findByRole('heading', { level: 3, name: 'NMSE (pooled)' })
expect(screen.getByText('-22.50 dB')).toBeInTheDocument()
const acprL = screen.getByRole('region', { name: 'ACPR_L' })
@@ -160,7 +161,7 @@ test('the export panel is not offered for a mock result', async () => {
'GET /api/v1/metrics/profiles': () => [legacyProfile.data],
})
renderWithProviders(, { route: '/results/run-pa-0001', path: '/results/:runId' })
- await screen.findByText('legacy-opendpd-v1 v1 · frozen')
+ await screen.findAllByText('OpenDPD legacy · segment-averaged scores')
expect(screen.queryByRole('region', { name: 'Export and report' })).not.toBeInTheDocument()
})
@@ -172,7 +173,7 @@ test('a measured result shows the attestation, the declared conditions, every ca
'GET /api/v1/metrics/profiles': () => [legacyProfile.data],
})
renderWithProviders(, { route: '/results/run-meas-0001', path: '/results/:runId' })
- await screen.findByText('legacy-opendpd-v1 v1 · frozen')
+ await screen.findAllByText('OpenDPD legacy · segment-averaged scores')
expect(screen.getByText('MOCK · DPD · measured')).toBeInTheDocument()
const panel = screen.getByRole('region', { name: 'Measurement' })
expect(within(panel).getByTestId('attestation')).toHaveTextContent('mock instrument adapter')
diff --git a/frontend/src/pages/ResultDetailPage.tsx b/frontend/src/pages/ResultDetailPage.tsx
index 32a6c29..dc3e780 100644
--- a/frontend/src/pages/ResultDetailPage.tsx
+++ b/frontend/src/pages/ResultDetailPage.tsx
@@ -1,3 +1,6 @@
+import { ILCResults } from '@/components/ILCResults'
+import { MetricFormula } from '@/components/MetricFormula'
+import { profileLabel } from '@/api/profiles'
import { RFFactsPanel } from '@/components/RFFactsPanel'
import { MeasurementSessions } from '@/components/MeasurementSessions'
import { DownloadLink } from '@/components/DownloadLink'
@@ -98,6 +101,7 @@ function ExportPanel({ runId }: { runId: string }) {
}
const BASELINE: Record = {
+ ilc_ideal: 'ilc.ideal',
surrogate_without_dpd: 'results.detail.baselines.surrogate_without_dpd',
measured_without_dpd: 'results.detail.baselines.measured_without_dpd',
}
@@ -451,22 +455,14 @@ export function ResultView({ result, profile, stored = [], onProfile }: { result
{result.result_id}
-
+
{t('results.columns.run')}:{' '}
{result.run_id}
- {stored.length > 1 && onProfile && (
- onProfile(e.target.value)} helperText={t('results.detail.profile.help')} sx={{ minWidth: 220, ml: 'auto' }}>
- {stored.map((id) => (
-
- ))}
-
- )}
+
{t('results.detail.protocol')}:{' '}
@@ -495,6 +491,7 @@ export function ResultView({ result, profile, stored = [], onProfile }: { result
{!WEB_MODE && result.run_id && }
{result.measurement && !WEB_MODE && }
+ {result.ilc && }
{result.surrogate_coverage && (
0 ? 'warning' : 'info'} data-testid="surrogate-coverage">
@@ -520,6 +517,16 @@ export function ResultView({ result, profile, stored = [], onProfile }: { result
{t('results.detail.definitions')}
+ {t('profile.help')}
+ {stored.length > 1 && onProfile && (
+ onProfile(e.target.value)} helperText={t('results.detail.profile.help')} sx={{ minWidth: 220, ml: 'auto' }}>
+ {stored.map((id) => (
+
+ ))}
+
+ )}
{t('results.detail.definitions.help', { profile: profile.profile_id, version: profile.version })} {message(profile.description)}
@@ -540,7 +547,7 @@ export function ResultView({ result, profile, stored = [], onProfile }: { result
{m.name} {message(m.display_name)}
- {m.formula}
+
{m.notes ? (
{message(m.notes)}
diff --git a/frontend/src/pages/SignalGeneratorPage.test.tsx b/frontend/src/pages/SignalGeneratorPage.test.tsx
index 76e2849..bc6ac1a 100644
--- a/frontend/src/pages/SignalGeneratorPage.test.tsx
+++ b/frontend/src/pages/SignalGeneratorPage.test.tsx
@@ -29,9 +29,11 @@ function setup() {
function Probe() { const location = useLocation(); return }
-test('one initial preview, simple family selection, stale-export guard and duration conversion', async () => {
+test('explicit preview only, simple family selection, stale-export guard and duration conversion', async () => {
const { calls } = setup()
renderWithProviders()
+ expect(calls.filter(c => c.method === 'POST')).toHaveLength(0)
+ await userEvent.click(await screen.findByRole('button', { name: 'Generate & preview' }))
await screen.findByTestId('signal-generator-results')
expect(calls.filter(c => c.path === '/api/v1/signal-generator/signals')).toHaveLength(1)
expect(screen.getByText('Time-domain I/Q')).toBeVisible()
@@ -51,8 +53,11 @@ test('one initial preview, simple family selection, stale-export guard and durat
test('advanced OFDMA channels and pilots reach the generator request', async () => {
const { calls } = setup()
renderWithProviders()
+ expect(calls.filter(c => c.method === 'POST')).toHaveLength(0)
+ await userEvent.click(await screen.findByRole('button', { name: 'Generate & preview' }))
await screen.findByTestId('signal-generator-results')
await userEvent.click(screen.getByRole('button', { name: 'Advanced parameters' }))
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Use the same settings for all channels' }))
await userEvent.click(screen.getByRole('button', { name: 'Add OFDMA channel' }))
expect(screen.getAllByLabelText('Subcarriers including pilots')).toHaveLength(2)
fireEvent.change(screen.getAllByLabelText('Subcarriers including pilots')[1]!, { target: { value: '52' } })
@@ -67,6 +72,7 @@ test('advanced OFDMA channels and pilots reach the generator request', async ()
test('generated signal is input-only, with separate exports and an explicit Virtual PA step', async () => {
const { calls } = setup()
renderWithProviders(<>>)
+ await userEvent.click(await screen.findByRole('button', { name: 'Generate & preview' }))
await screen.findByTestId('signal-generator-results')
expect(screen.getByRole('heading', { name: 'PA Input Dataset' })).toBeVisible()
expect(screen.getAllByText(/complete training dataset needs matching PA input x and PA output y/).length).toBeGreaterThan(0)
diff --git a/frontend/src/pages/SignalGeneratorPage.tsx b/frontend/src/pages/SignalGeneratorPage.tsx
index 74c2990..bf8b65a 100644
--- a/frontend/src/pages/SignalGeneratorPage.tsx
+++ b/frontend/src/pages/SignalGeneratorPage.tsx
@@ -1,3 +1,5 @@
+import ArrowForwardIcon from '@mui/icons-material/ArrowForward'
+import Checkbox from '@mui/material/Checkbox'
import AddIcon from '@mui/icons-material/Add'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined'
import DownloadIcon from '@mui/icons-material/Download'
@@ -65,13 +67,9 @@ function Generator({ presets, saved }: { presets: GeneratorPreset[]; saved?: Gen
const [error, setError] = useState(null)
const [downloading, setDownloading] = useState(false)
const [pilotText, setPilotText] = useState('')
- const initialized = useRef(!!saved)
- const initial = useRef(config)
- useEffect(() => {
- if (!initialized.current) { initialized.current = true; generate.mutate(initial.current) }
- }, [generate]) // The ref limits this to one private preview; edits require Generate.
const preset = presets.find(p => p.preset_id === config.preset_id)
const family = preset?.family ?? 'custom'
+ const shared = config.shared_channel_settings ?? [config.channel_subcarriers, config.channel_modulations, config.channel_power_db].every(values => new Set(values).size === 1)
const ofdm = config.waveform === 'ofdm'
const result = generate.data ?? saved
const stale = !!result && JSON.stringify(result.config) !== JSON.stringify(config)
@@ -114,6 +112,14 @@ function Generator({ presets, saved }: { presets: GeneratorPreset[]; saved?: Gen
{t(family === 'wifi8' ? 'generator.wifi8Scope' : 'generator.scopeHelp')}
+ {result && {t('generator.next')}
+ {t('paInput.help')}
+ } disabled={stale} component={RouterLink} to={'/pa-library?input=' + encodeURIComponent(result.signal_id)}>{t('paInput.next')}
+ } disabled={stale || downloading} onClick={() => { setDownloading(true); void downloadFile('/api/v1/signal-generator/signals/' + result.signal_id + '/input.csv').catch(setError).finally(() => setDownloading(false)) }}>{t('paInput.csv')}
+ } disabled={stale || downloading} onClick={() => { setDownloading(true); void downloadFile('/api/v1/signal-generator/signals/' + result.signal_id + '/metadata.json').catch(setError).finally(() => setDownloading(false)) }}>{t('paInput.metadata')}
+ } disabled={stale || downloading} onClick={() => { setDownloading(true); void downloadFile(result.download_url).catch(setError).finally(() => setDownloading(false)) }}>{t('generator.exportIq')}
+
+ }
{t('generator.setup')}
{ const entry = presets.find(p => p.preset_id === e.target.value); if (entry) select(entry) }}>
@@ -145,13 +151,15 @@ function Generator({ presets, saved }: { presets: GeneratorPreset[]; saved?: Gen
change('dc_null', value)} />} />
{t('generator.channels')} · {config.channel_subcarriers.length}
{t('generator.channelsHelp')}
- {config.channel_subcarriers.map((carrierCount, index) =>
- {t('generator.channel')} {index + 1} setConfig(old => ({ ...old, channel_subcarriers: old.channel_subcarriers.filter((_, i) => i !== index), channel_modulations: old.channel_modulations.filter((_, i) => i !== index), channel_power_db: old.channel_power_db.filter((_, i) => i !== index) }))}>
- change('channel_subcarriers', config.channel_subcarriers.map((v, i) => i === index ? value : v))} />
- change('channel_modulations', config.channel_modulations.map((v, i) => i === index ? Number(e.target.value) : v))}>{ORDERS.map(m => )}
- change('channel_power_db', config.channel_power_db.map((v, i) => i === index ? value : v))} />
+ setConfig(old => ({ ...old, shared_channel_settings: checked,
+ ...(checked ? { channel_subcarriers: old.channel_subcarriers.map(() => old.channel_subcarriers[0]!), channel_modulations: old.channel_modulations.map(() => old.channel_modulations[0]!), channel_power_db: old.channel_power_db.map(() => old.channel_power_db[0]!) } : {}) }))} />} />
+ {(shared ? config.channel_subcarriers.slice(0, 1) : config.channel_subcarriers).map((carrierCount, index) =>
+ {shared ? t('generator.allChannels') : `${t('generator.channel')} ${index + 1}`} setConfig(old => ({ ...old, channel_subcarriers: old.channel_subcarriers.filter((_, i) => i !== (shared ? old.channel_subcarriers.length - 1 : index)), channel_modulations: old.channel_modulations.filter((_, i) => i !== (shared ? old.channel_modulations.length - 1 : index)), channel_power_db: old.channel_power_db.filter((_, i) => i !== (shared ? old.channel_power_db.length - 1 : index)) }))}>
+ change('channel_subcarriers', config.channel_subcarriers.map((v, i) => shared || i === index ? value : v))} />
+ change('channel_modulations', config.channel_modulations.map((v, i) => shared || i === index ? Number(e.target.value) : v))}>{ORDERS.map(m => )}
+ change('channel_power_db', config.channel_power_db.map((v, i) => shared || i === index ? value : v))} />
)}
- } variant="outlined" disabled={config.channel_subcarriers.length >= 16} onClick={() => setConfig(old => ({ ...old, channel_subcarriers: [...old.channel_subcarriers, 26], channel_modulations: [...old.channel_modulations, 64], channel_power_db: [...old.channel_power_db, 0] }))}>{t('generator.addChannel')}
+ } variant="outlined" disabled={config.channel_subcarriers.length >= 16} onClick={() => setConfig(old => ({ ...old, shared_channel_settings: shared, channel_subcarriers: [...old.channel_subcarriers, shared ? old.channel_subcarriers[0]! : 26], channel_modulations: [...old.channel_modulations, shared ? old.channel_modulations[0]! : 64], channel_power_db: [...old.channel_power_db, shared ? old.channel_power_db[0]! : 0] }))}>{t('generator.addChannel')}
{numeric('channel_gap_bins', 'generator.channelGap')}
change('pilot_mode', e.target.value as GeneratorConfig['pilot_mode'])}>{(['comb', 'explicit', 'none'] as const).map(mode => )}
{config.pilot_mode === 'comb' && numeric('pilot_spacing', 'generator.pilotSpacing')}
@@ -175,14 +183,7 @@ function Generator({ presets, saved }: { presets: GeneratorPreset[]; saved?: Gen
} disabled={generate.isPending || !validNumbers || count < 256 || count > 1_000_000} onClick={() => generate.mutate(config)}>{t('generator.applyGenerate')}
- {result && {t('generator.next')}
- {t('paInput.help')}
-
- } disabled={stale || downloading} onClick={() => { setDownloading(true); void downloadFile('/api/v1/signal-generator/signals/' + result.signal_id + '/input.csv').catch(setError).finally(() => setDownloading(false)) }}>{t('paInput.csv')}
- } disabled={stale || downloading} onClick={() => { setDownloading(true); void downloadFile('/api/v1/signal-generator/signals/' + result.signal_id + '/metadata.json').catch(setError).finally(() => setDownloading(false)) }}>{t('paInput.metadata')}
- } disabled={stale || downloading} onClick={() => { setDownloading(true); void downloadFile(result.download_url).catch(setError).finally(() => setDownloading(false)) }}>{t('generator.exportIq')}
-
- }
+
{(generate.isError || !!error) && }
{result ? : {t(generate.isPending ? 'generator.generating' : 'generator.generate')}{t(generate.isError ? 'generator.checkParameters' : 'generator.firstPreview')}}
diff --git a/mkdocs.yml b/mkdocs.yml
index 08c5738..a6b70c3 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -51,6 +51,7 @@ nav:
- First Studio experiment: tutorials/gui-quickstart.md
- Signal Generator: guides/signal-generator.md
- Virtual PA Library: guides/virtual-pa-library.md
+ - ILC and ILA DPD: guides/ilc-dpd.md
- Headless CLI: tutorials/headless-cli.md
- Python examples & Colab: examples.md
- Core workflow:
@@ -130,6 +131,7 @@ exclude_docs: |
# Keep the primary navigation focused on the user workflows above.
not_in_nav: |
performance/studio-2.2.4.md
+ performance/studio-2.2.5.md
architecture/**
community/**
design/**
diff --git a/opendpd/__init__.py b/opendpd/__init__.py
index 6d543b3..748d213 100644
--- a/opendpd/__init__.py
+++ b/opendpd/__init__.py
@@ -8,7 +8,7 @@
Website: https://www.tudemi.com
"""
-__version__ = "2.2.4"
+__version__ = "2.2.5"
__author__ = "Yizhuo Wu, Ang Li, Chang Gao"
__license__ = "Apache-2.0"
__email__ = "chang.gao@tudelft.nl"
diff --git a/opendpd/core/ilc.py b/opendpd/core/ilc.py
new file mode 100644
index 0000000..e3cafa1
--- /dev/null
+++ b/opendpd/core/ilc.py
@@ -0,0 +1,109 @@
+"""Bounded gain-inverse ILC with a fixed target and monotone backtracking.
+
+A waveform controller, not a forward PA identifier. The plant must reset its
+state identically on each call. No measurement or generalization is implied.
+Reference framework: Schoukens et al., doi:10.1109/TMTT.2017.2694822.
+"""
+from __future__ import annotations
+from dataclasses import dataclass
+from typing import Callable
+import numpy as np
+
+
+@dataclass
+class ILCResult:
+ input: np.ndarray
+ output: np.ndarray
+ history: list[dict]
+ stop_reason: str
+ plant_calls: int
+
+
+def learn(plant: Callable, x, target_gain: float, inverse_gain: complex, *,
+ iterations=30, learning_gain=.5, target_nmse_db=-45., peak_limit=1.,
+ backtracking_steps=6, min_improvement_db=.001, callback=None, cancel=None):
+ x = np.asarray(x, dtype=np.complex128)
+ values = [target_gain, inverse_gain.real, inverse_gain.imag, learning_gain, target_nmse_db, peak_limit, min_improvement_db]
+ if x.ndim != 1 or not x.size or not np.isfinite(x).all() or not np.isfinite(values).all():
+ raise ValueError('ILC requires finite one-dimensional IQ and parameters.')
+ if not (1 <= iterations <= 200 and int(iterations) == iterations and 0 <= backtracking_steps <= 10
+ and int(backtracking_steps) == backtracking_steps and 0 < learning_gain <= 1.5
+ and target_gain > 0 and abs(inverse_gain) > 0 and peak_limit > 0 and min_improvement_db >= 0):
+ raise ValueError('ILC parameters are outside their supported bounds.')
+ target = target_gain * x
+ energy = float(np.vdot(target, target).real)
+ if energy <= 0:
+ raise ValueError('ILC needs a nonzero target waveform.')
+ def clip(u):
+ return u * np.minimum(1., peak_limit / np.maximum(np.abs(u), np.finfo(float).tiny))
+ calls = 0
+ def evaluate(u):
+ nonlocal calls
+ if cancel:
+ cancel()
+ out = np.asarray(plant(u), dtype=np.complex128)
+ calls += 1
+ if out.shape != x.shape or not np.isfinite(out).all():
+ raise ValueError('ILC plant returned non-finite or mismatched IQ.')
+ error = target - out
+ nmse = float(10 * np.log10(max(float(np.vdot(error, error).real) / energy, 1e-30)))
+ return out, error, nmse
+ u = clip(x.copy())
+ y, error, nmse = evaluate(u)
+ history = []
+ def record(i, step):
+ row = {'iteration': i, 'nmse_db': nmse, 'learning_gain': step,
+ 'peak_abs': float(np.abs(u).max()), 'limited_fraction': float(np.mean(np.abs(u) >= peak_limit * (1 - 1e-10)))}
+ history.append(row)
+ if callback:
+ callback(row)
+ record(0, 0.)
+ reason = 'iteration_limit'
+ for i in range(1, iterations + 1):
+ if nmse <= target_nmse_db:
+ reason = 'target_reached'
+ break
+ previous = nmse
+ accepted = False
+ for backtrack in range(backtracking_steps + 1):
+ step = learning_gain * 2. ** -backtrack
+ candidate = clip(u + step * inverse_gain * error)
+ yp, ep, np_db = evaluate(candidate)
+ if np_db < nmse:
+ u, y, error, nmse = candidate, yp, ep, np_db
+ accepted = True
+ break
+ if not accepted:
+ reason = 'no_improving_step'
+ break
+ record(i, step)
+ if nmse <= target_nmse_db:
+ reason = 'target_reached'
+ break
+ if previous - nmse < min_improvement_db:
+ reason = 'improvement_below_tolerance'
+ break
+ return ILCResult(u, y, history, reason, calls)
+
+
+def torch_plant(model, segment_length, device='cpu', batch_segments=32):
+ """Replay independent, zero-initialized segments; pad only the final segment."""
+ import torch
+ model = model.to(device).eval()
+ def plant(iq):
+ n = len(iq)
+ pad = (-n) % segment_length
+ z = np.pad(iq, (0, pad))
+ pairs = np.stack((z.real, z.imag), axis=-1).astype(np.float32).reshape(-1, segment_length, 2)
+ outputs = []
+ with torch.inference_mode():
+ for start in range(0, len(pairs), batch_segments):
+ value = model(torch.from_numpy(pairs[start:start+batch_segments]).to(device)).cpu().numpy()
+ outputs.append(value)
+ flat = np.concatenate(outputs).reshape(-1, 2)[:n]
+ return flat[:, 0].astype(np.float64) + 1j * flat[:, 1].astype(np.float64)
+ return plant
+
+
+def options(params):
+ return {k: params[k] for k in ('iterations', 'learning_gain', 'target_nmse_db', 'backtracking_steps', 'min_improvement_db')}
diff --git a/opendpd/core/pa_equations.py b/opendpd/core/pa_equations.py
new file mode 100644
index 0000000..ac42c4e
--- /dev/null
+++ b/opendpd/core/pa_equations.py
@@ -0,0 +1,55 @@
+"""Display-only LaTeX for the fixed virtual-PA catalog (never evaluated)."""
+SYMBOLS = {
+ 'gain': 'G', 'saturation': 's', 'smoothness': 'p', 'phase': r'\phi_\infty',
+ 'phase_scale': 'b', 'phase_deg': r'\theta', 'compression': r'\beta_a',
+ 'cubic': 'c_3', 'quintic': 'c_5', 'memory': r'\mu', 'depth': 'M',
+ 'decay': 'd', 'memory_phase': r'\psi', 'cross_memory': r'c_\times',
+ 'trap_strength': r'\kappa_t', 'capture_us': r'\tau_c', 'release_us': r'\tau_{e,25}',
+ 'activation_ev': 'E_a', 'trap_phase': r'\phi_t', 'thermal_us': r'\tau_T',
+ 'ambient_c': 'T_a', 'heating_c': r'\Delta T', 'thermal_gain': r'\kappa_T',
+ 'ir_drop': r'\rho', 'bias_us': r'\tau_b', 'knee': 'r_k', 'peaker': r'\beta_p',
+ 'supply_floor': r'v_{\min}', 'supply_span': 'h_v', 'tracking_us': r'\tau_v',
+}
+RAPP = r'R(x;G,s,p)=\frac{Gx}{[1+(G|x|/s)^{2p}]^{1/(2p)}}'
+BOUND = r'R(x)=\frac{{{gain}}x}{[1+({{gain}}|x|/{{saturation}})^{2{{smoothness}}}]^{1/(2{{smoothness}})}}'
+MP = [
+ r'u[n]={{gain}}x[n]+{{cubic}}x[n]|x[n]|^2+{{quintic}}x[n]|x[n]|^4',
+ r'w_m=\frac{{{decay}}^{m-1}e^{jm{{memory_phase}}\pi/180}}{\sum_{k=1}^{{{depth}}}{{decay}}^{k-1}}',
+ r'z[n]=u[n]+{{memory}}\sum_{m=1}^{{{depth}}} w_m u[n-m]',
+]
+LPF = r'\operatorname{LPF}(P,\tau)[n]=a\operatorname{LPF}[n-1]+(1-a)P[n],\quad a=e^{-1/(F_s\tau\,10^{-6})},\quad\tau\text{ in }\mu\mathrm{s}'
+EQUATIONS = {
+ 'linear-reference': [r'y[n]={{gain}}x[n]e^{j{{phase_deg}}\pi/180}'],
+ 'rapp-solid-state': [BOUND, r'y[n]=R(x[n])'],
+ 'rapp-am-pm': [BOUND, r'\Phi(r)=\frac{{{phase}}r^2}{r^2+{{phase_scale}}^2}', r'y[n]=R(x[n])e^{j\Phi(|x[n]|)}'],
+ 'saleh-twta': [r'y[n]=A(r)e^{j(\arg x[n]+\Phi(r))},\quad r=|x[n]|',
+ r'A(r)=\frac{{{gain}}r}{1+{{compression}}r^2}', r'\Phi(r)=\frac{{{phase}}r^2}{1+{{phase_scale}}r^2}'],
+ 'memory-polynomial': [*MP, 'y[n]=z[n]'],
+ 'generalized-memory': [*MP, r'q[n]=\sum_{m=1}^{{{depth}}}\frac{{{decay}}^{m-1}}{\sum_{k=1}^{{{depth}}}{{decay}}^{k-1}}|x[n-m]|^2',
+ r'y[n]=z[n]+{{cross_memory}}x[n]q[n]'],
+ 'gan-trap-thermal': [BOUND,
+ r'P[n]=\frac{|x[n]|^2}{|x[n]|^2+({{saturation}}/{{gain}})^2}',
+ r'T[n]={{ambient_c}}+{{heating_c}}\operatorname{LPF}(P,{{thermal_us}})',
+ r'\tau_e[n]={{release_us}}\exp\!\left[\frac{{{activation_ev}}}{k_B}\left(\frac{1}{T[n]+273.15}-\frac{1}{298.15}\right)\right]',
+ r'\tau[n]=\begin{cases}{{capture_us}}&P[n]\ge q_t[n-1]\\\tau_e[n]&\text{otherwise}\end{cases}',
+ r'q_t[n]=a[n]q_t[n-1]+(1-a[n])P[n],\quad a[n]=e^{-1/(F_s\tau[n]10^{-6})}',
+ r'v[n]=1-{{ir_drop}}\operatorname{LPF}(P,{{bias_us}})',
+ r'y[n]=R(x[n])(1-{{trap_strength}}q_t[n])v[n]e^{-{{thermal_gain}}(T[n]-25)+j{{trap_phase}}q_t[n]}',
+ LPF, r'k_B=8.617333262\times 10^{-5}\,\mathrm{eV/K}'],
+ 'doherty-two-path': [BOUND, r'x_p[n]=\max(|x[n]|-{{knee}},0)e^{j\arg x[n]}',
+ r'y[n]=R(x[n])+{{peaker}}R(x_p[n])e^{j{{phase_deg}}\pi/180}'],
+ 'envelope-tracking': [RAPP, r'D[n]=\min({{gain}}|x[n]|/{{saturation}},1)',
+ r'P[n]=\frac{|x[n]|^2}{|x[n]|^2+({{saturation}}/{{gain}})^2}',
+ r'v[n]=\operatorname{clip}({{supply_floor}}+{{supply_span}}\operatorname{LPF}(D,{{tracking_us}})-{{ir_drop}}\operatorname{LPF}(P,{{bias_us}}),{{supply_floor}},1.5)',
+ r'y[n]=R(x[n];{{gain}},{{saturation}}v[n],{{smoothness}})e^{j{{phase}}(v[n]-1)}', LPF],
+}
+
+
+def attach_latex(models):
+ for model in models:
+ model.equations_latex = EQUATIONS[model.model_id]
+ for p in model.parameters:
+ p.symbol_latex = SYMBOLS[p.key]
+ if model.model_id == 'saleh-twta':
+ p.symbol_latex = {'phase': r'\alpha_\phi', 'phase_scale': r'\beta_\phi'}.get(p.key, p.symbol_latex)
+ return models
diff --git a/opendpd/core/polynomial.py b/opendpd/core/polynomial.py
index 6eac509..c099806 100644
--- a/opendpd/core/polynomial.py
+++ b/opendpd/core/polynomial.py
@@ -16,7 +16,7 @@
import numpy as np
-POLYNOMIAL_KEYS = ("mp_ls", "gmp_ls")
+POLYNOMIAL_KEYS = ("mp_ls", "gmp_ls", "ilc_dpd")
MEMORY_BUDGET_BYTES = 3 << 30 # the full basis is held in memory for the solve (complex128)
@@ -63,7 +63,7 @@ def gmp_basis(x: np.ndarray, Ka: int, La: int, Kb: int, Lb: int, Mb: int, Kc: in
def coefficient_count(key: str, params: Mapping[str, object]) -> int:
p = {k: int(v) for k, v in params.items() if k != "rcond"}
- if key == "mp_ls":
+ if key in ("mp_ls", "ilc_dpd"):
return p["K"] * p["Q"]
if key == "gmp_ls":
return p["Ka"] * p["La"] + p["Kb"] * p["Lb"] * p["Mb"] + p["Kc"] * p["Lc"] * p["Mc"]
@@ -78,7 +78,7 @@ def lookahead_samples(key: str, params: Mapping[str, object]) -> int:
def context_samples(key: str, params: Mapping[str, object]) -> int:
"""Past samples a model reads (its memory): what a split guard must cover."""
p = {k: int(v) for k, v in params.items() if k != "rcond"}
- if key == "mp_ls":
+ if key in ("mp_ls", "ilc_dpd"):
return p["Q"]
if key == "gmp_ls":
return max(p["La"], p["Lb"] + p["Mb"] if p["Kb"] > 0 else 0, p["Lc"])
@@ -87,7 +87,7 @@ def context_samples(key: str, params: Mapping[str, object]) -> int:
def basis(key: str, params: Mapping[str, object], x: np.ndarray) -> np.ndarray:
p = {k: int(v) for k, v in params.items() if k != "rcond"}
- if key == "mp_ls":
+ if key in ("mp_ls", "ilc_dpd"):
return mp_basis(x, p["K"], p["Q"])
if key == "gmp_ls":
return gmp_basis(x, **p)
@@ -174,7 +174,7 @@ class _Polynomial(nn.Module):
def __init__(self) -> None:
super().__init__()
self.key = key
- self.params = {k: (float(v) if k == "rcond" else int(v)) for k, v in params.items()}
+ self.params = {k: (float(v) if k == "rcond" else int(v)) for k, v in params.items() if key != "ilc_dpd" or k in {"K", "Q", "rcond"}}
p = coefficient_count(key, self.params)
init = torch.zeros(p, dtype=torch.complex128) if coefficients is None \
else torch.as_tensor(np.asarray(coefficients, dtype=np.complex128))
@@ -186,9 +186,20 @@ def __init__(self) -> None:
def n_real_parameters(self) -> int:
return 2 * int(self.coefficients.numel())
+ def _apply(self, fn, recurse=True):
+ # NumPy evaluates the polynomial in CPU complex128. Keep its
+ # checkpoint buffer there even when the surrounding PA cascade
+ # moves to CUDA/MPS (MPS does not support float64/complex128).
+ coefficients = self._buffers.pop("coefficients")
+ try:
+ return super()._apply(fn, recurse=recurse)
+ finally:
+ self._buffers["coefficients"] = coefficients
+
def forward(self, x, h_0=None):
# x: (batch, frame, 2) float; every frame is one segment (delays reset), like the legacy loaders
- xc = torch.complex(x[..., 0].to(torch.float64), x[..., 1].to(torch.float64)).cpu().numpy()
+ cpu = x.detach().to(device="cpu", dtype=torch.float64)
+ xc = torch.complex(cpu[..., 0], cpu[..., 1]).numpy()
out = np.empty_like(xc)
w = self.coefficients.cpu().numpy()
for i in range(xc.shape[0]):
diff --git a/opendpd/core/registry.py b/opendpd/core/registry.py
index 4fe9269..5b9ad8e 100644
--- a/opendpd/core/registry.py
+++ b/opendpd/core/registry.py
@@ -50,6 +50,10 @@ def coerce(self, value: ParamValue) -> ParamValue:
value = py_type(value)
except (TypeError, ValueError):
raise RegistryError(self.name, f"{self.name} must be of type {self.type}") from None
+ if self.type == "float":
+ import math
+ if not math.isfinite(value):
+ raise RegistryError(self.name, f"{self.name} must be finite")
if self.minimum is not None and value < self.minimum:
raise RegistryError(self.name, f"{self.name} must be >= {self.minimum}")
if self.maximum is not None and value > self.maximum:
@@ -161,6 +165,25 @@ def _thh() -> ParamSpec:
constraints="memory length 11 and degree 5 are fixed in backbones/gmp.py (the --K / --gmp_memory_length "
"flags are not wired)", reference="OpenDPD (ISCAS 2024)", evidence=_CPU_WEEKLY,
),
+ ModelDescriptor(
+ key="ilc_dpd", display_name="ILC-DPD (ILA) + Ideal waveform benchmark", family="polynomial",
+ legacy_backbone="mp", training_method="least_squares", roles=("dpd",),
+ params=(_poly("K", 7, 15, "MP envelope powers 0..K-1 for the transferable ILA fit"),
+ _poly("Q", 5, 50, "MP memory depth in samples"),
+ ParamSpec("rcond", "float", 1e-6, "Relative SVD cutoff for the ILA fit", minimum=0., maximum=.1),
+ _poly("iterations", 30, 200, "Maximum accepted ILC waveform updates"),
+ ParamSpec("learning_gain", "float", .5, "ILC correction step; backtracking halves an unhelpful step", minimum=.001, maximum=1.5),
+ ParamSpec("target_nmse_db", "float", -45., "Stop when pooled waveform tracking NMSE reaches this target (dB)", minimum=-100., maximum=-5.),
+ ParamSpec("peak_factor", "float", 1.5, "Input peak limit relative to the PA training-input peak; above 1 permits surrogate extrapolation", minimum=1., maximum=4.),
+ _poly("backtracking_steps", 6, 10, "Maximum step halvings per iteration", minimum=0),
+ ParamSpec("min_improvement_db", "float", .001, "Stop when accepted improvement is smaller than this (dB)", minimum=0., maximum=1.),
+ _poly("fit_samples", 32768, 131072, "Maximum leading training samples for waveform learning and ILA fit", minimum=256)),
+ status="experimental", devices_tested=("cpu", "cuda"), lookahead_samples=0,
+ lookahead_note="The fitted MP is causal with Q-1 past samples; the Ideal waveform is offline feedback optimization.",
+ constraints="Requires a trained PA surrogate. ILC optimizes training input waveforms; ILA fits normalized PA output to that input. Test feedback is a separate waveform-specific Ideal baseline, never used to fit the DPD. No hardware claim.",
+ reference="https://doi.org/10.1109/TMTT.2017.2694822",
+ evidence="tests/unit/test_ilc.py; tests/integration/test_ilc_runs.py; docs/performance/studio-2.2.5/ilc.json (CPU/CUDA)",
+ ),
ModelDescriptor(
key="mp_ls", display_name="MP (least squares)", family="polynomial", legacy_backbone="mp",
training_method="least_squares", roles=("pa", "dpd"),
diff --git a/opendpd/core/spectrum_layout.py b/opendpd/core/spectrum_layout.py
index 23f5fb5..e7b4d40 100644
--- a/opendpd/core/spectrum_layout.py
+++ b/opendpd/core/spectrum_layout.py
@@ -45,6 +45,8 @@ def spectrum_groups(traces):
def spectrum_legend(trace):
name = trace['name'].lower()
synthetic = 'synthetic' in trace.get('source', '')
+ if name.startswith('ilc ideal'):
+ return 'ILC Ideal · waveform-specific'
if trace.get('role') == 'input':
return 'Input x'
if trace.get('role') == 'predistorted':
diff --git a/opendpd/core/virtual_pa.py b/opendpd/core/virtual_pa.py
index 571fdf9..ad0593e 100644
--- a/opendpd/core/virtual_pa.py
+++ b/opendpd/core/virtual_pa.py
@@ -214,7 +214,8 @@ def catalog():
"y[n] = R(x[n]; {{gain}}, {{saturation}}·v[n], {{smoothness}})·exp(j·{{phase}}·(v[n]−1))",
"LPF(P,τ)[n] = a·LPF[n−1] + (1−a)·P[n]; a = exp(−1/(Fs·τ·10⁻⁶)); τ in µs"],
references=[ET_REF, MEMORY_REF]))
- return models
+ from .pa_equations import attach_latex
+ return attach_latex(models)
def resolve(model_id, supplied):
diff --git a/opendpd/schemas/results.py b/opendpd/schemas/results.py
index f18c865..dd748b1 100644
--- a/opendpd/schemas/results.py
+++ b/opendpd/schemas/results.py
@@ -55,7 +55,7 @@ class BaselineScore(StrictModel):
and least-squares gain, both recorded in the measurement) and the level
difference between the captures is reported, never scaled away."""
- kind: Literal["surrogate_without_dpd", "measured_without_dpd"]
+ kind: Literal["surrogate_without_dpd", "measured_without_dpd", "ilc_ideal"]
description: str = Field(min_length=1)
metrics: List[MetricValue] = Field(min_length=1)
@@ -125,7 +125,7 @@ class ModelEvidence(StrictModel):
lookahead_samples: Optional[int] = Field(default=None, ge=0)
# how the weights were obtained: gradient descent (PA), gradient descent through the surrogate (DPD, DLA),
# direct least squares (PA) or indirect learning by least squares on measured data (DPD, ILA)
- training_path: Optional[Literal["gradient", "gradient_dla", "least_squares", "ila_least_squares"]] = None
+ training_path: Optional[Literal["gradient", "gradient_dla", "least_squares", "ila_least_squares", "ilc_ila"]] = None
class EvaluationResult(StrictModel):
@@ -153,6 +153,7 @@ class EvaluationResult(StrictModel):
numeric_mode: str = "float32"
limitations: List[str] = Field(default_factory=list)
signal_chain: List[SignalStage] = Field(default_factory=list)
+ ilc: Optional[Dict[str, object]] = None
baselines: List[BaselineScore] = Field(default_factory=list)
surrogate_coverage: Optional[SurrogateCoverage] = None
scaling: Optional[ScalingInfo] = None
diff --git a/opendpd/schemas/signal_generator.py b/opendpd/schemas/signal_generator.py
index d5bd234..532f7ed 100644
--- a/opendpd/schemas/signal_generator.py
+++ b/opendpd/schemas/signal_generator.py
@@ -27,6 +27,7 @@ class GeneratorConfig(StrictModel):
channel_subcarriers: list[int] = Field(default_factory=lambda: [242], min_length=1, max_length=16)
channel_modulations: list[int] = Field(default_factory=lambda: [64], min_length=1, max_length=16)
channel_power_db: list[float] = Field(default_factory=lambda: [0.], min_length=1, max_length=16)
+ shared_channel_settings: bool | None = None
channel_gap_bins: int = Field(default=0, ge=0, le=1024)
dc_null: bool = True
pilot_mode: Literal["comb", "explicit", "none"] = "comb"
@@ -70,6 +71,11 @@ def _valid(self):
raise ValueError("Use BPSK or square QAM orders 4, 16, 64, 256, 1024 or 4096.")
if any(not math.isfinite(p) or abs(p) > 40 for p in self.channel_power_db):
raise ValueError("Channel powers must be finite and within ±40 dB.")
+ same = all(len(set(values)) == 1 for values in (self.channel_subcarriers, self.channel_modulations, self.channel_power_db))
+ if self.shared_channel_settings is None:
+ self.shared_channel_settings = same
+ elif self.shared_channel_settings and not same:
+ raise ValueError("Shared OFDMA settings require identical subcarrier counts, modulation and power across channels.")
if len(set(self.pilot_indices)) != len(self.pilot_indices):
raise ValueError("Pilot carrier indices must be unique.")
if self.waveform == "ofdm":
diff --git a/opendpd/schemas/virtual_pa.py b/opendpd/schemas/virtual_pa.py
index 263a767..c6262cb 100644
--- a/opendpd/schemas/virtual_pa.py
+++ b/opendpd/schemas/virtual_pa.py
@@ -15,6 +15,7 @@ class PALocalizedText(StrictModel):
class PAParameter(StrictModel):
key: str
symbol: str
+ symbol_latex: str = ""
label: PALocalizedText
description: PALocalizedText
group: Literal["gain", "memory", "dynamics", "architecture"] = "gain"
@@ -36,6 +37,7 @@ class VirtualPAModel(StrictModel):
limitations: PALocalizedText
parameters: list[PAParameter]
equations: list[str]
+ equations_latex: list[str] = Field(default_factory=list)
references: list[str]
diff --git a/opendpd/server/routes.py b/opendpd/server/routes.py
index 3aabb2d..ce1f30f 100644
--- a/opendpd/server/routes.py
+++ b/opendpd/server/routes.py
@@ -130,8 +130,12 @@ def session_info(request: Request):
@router.get("/system/about", response_model=Dict[str, Any], tags=["system"],
dependencies=[Depends(require_session)])
-def system_about():
+def system_about(activity: bool = True):
from opendpd.services.about import project_info
+ if not activity:
+ from opendpd import __version__
+ from opendpd.services.about import local_commit
+ return {"version": __version__, "local_commit": local_commit()}
return project_info()
# --- workbench settings -----------------------------------------------------------
diff --git a/opendpd/server/signal_generator_routes.py b/opendpd/server/signal_generator_routes.py
index aa046af..dad510b 100644
--- a/opendpd/server/signal_generator_routes.py
+++ b/opendpd/server/signal_generator_routes.py
@@ -69,3 +69,13 @@ def dataset(signal_id: str, body: GeneratorDatasetRequest, request: Request):
@router.get("/datasets/{dataset_id}/sample-counts", response_model=DatasetSampleCounts, dependencies=[Depends(require_session)])
def counts(dataset_id: str, request: Request, version: str = "raw-v1"):
return service.sample_counts(request.app.state.ws, dataset_id, version)
+
+
+@router.post('/signal-generator/signals/{signal_id}/archive', dependencies=[Depends(require_csrf)])
+def archive(signal_id: str, request: Request):
+ return service.archive_input(request.app.state.ws, signal_id)
+
+
+@router.post('/signal-generator/signals/{signal_id}/restore', response_model=PAInputDataset, dependencies=[Depends(require_csrf)])
+def restore(signal_id: str, request: Request):
+ return service.archive_input(request.app.state.ws, signal_id, restore=True)
diff --git a/opendpd/services/about.py b/opendpd/services/about.py
index a0b30bd..5af8392 100644
--- a/opendpd/services/about.py
+++ b/opendpd/services/about.py
@@ -36,14 +36,9 @@ def project_info():
with _lock:
if _cache is not None and time.monotonic() - _checked < 300:
return _cache
- local_commit = None
- try:
- local_commit = subprocess.check_output(["git", "rev-parse", "HEAD"],
- cwd=Path(__file__).resolve().parents[2], text=True, stderr=subprocess.DEVNULL, timeout=2).strip()
- except (OSError, subprocess.SubprocessError):
- pass
+ commit = local_commit()
base = {"version": __version__, "repository": REPOSITORY, "lab_url": "https://www.tudemi.com/",
- "local_commit": local_commit, "refresh_seconds": 300, "contributors": [], "commits": [],
+ "local_commit": commit, "refresh_seconds": 300, "contributors": [], "commits": [],
"updated_at": None, "status": "unavailable"}
try:
with ThreadPoolExecutor(max_workers=2) as pool:
@@ -64,3 +59,15 @@ def project_info():
"error": f"GitHub is unavailable ({type(error).__name__}); use the repository links or retry later."}
_checked = time.monotonic()
return _cache
+
+
+def local_commit():
+ root = Path(__file__).resolve().parents[2]
+ release = root / "release-commit"
+ if release.is_file():
+ return release.read_text().strip()
+ try:
+ return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root,
+ text=True, stderr=subprocess.DEVNULL, timeout=2).strip()
+ except (OSError, subprocess.SubprocessError):
+ return None
diff --git a/opendpd/services/evaluation.py b/opendpd/services/evaluation.py
index 08f74b4..27846cb 100644
--- a/opendpd/services/evaluation.py
+++ b/opendpd/services/evaluation.py
@@ -60,7 +60,8 @@ class Predictions:
def __init__(self, prediction: np.ndarray, ground_truth: np.ndarray, n_valid: int, target_gain: Optional[float],
*, x: Optional[np.ndarray] = None, u: Optional[np.ndarray] = None,
surrogate_without_dpd: Optional[np.ndarray] = None, measured: Optional[np.ndarray] = None,
- fitted_peak_abs: Optional[float] = None, execution: Optional[ExecutionEvidence] = None):
+ fitted_peak_abs: Optional[float] = None, execution: Optional[ExecutionEvidence] = None,
+ ilc_output=None, ilc_input=None, ilc_evidence=None):
self.prediction = prediction # PA model output (train_pa) or y = PA_surrogate(u) (DPD tasks)
self.ground_truth = ground_truth # measured y (train_pa) or the linear target gain * x (DPD tasks)
self.n_valid = n_valid
@@ -70,6 +71,9 @@ def __init__(self, prediction: np.ndarray, ground_truth: np.ndarray, n_valid: in
self.surrogate_without_dpd = surrogate_without_dpd # DPD tasks: PA_surrogate(x)
self.measured = measured # measured PA output of the test split
self.fitted_peak_abs = fitted_peak_abs # DPD tasks: max |x| the surrogate was trained on
+ self.ilc_input = ilc_input
+ self.ilc_output = ilc_output
+ self.ilc_evidence = ilc_evidence
self.execution = execution # streaming variants: how the signal was consumed (S18)
@@ -223,6 +227,11 @@ def predict_test_split(ws: Workspace, run_id: str, resolved: ResolvedExperimentC
extra.update(u=torch.cat(us).numpy(), surrogate_without_dpd=torch.cat(y0s).numpy(),
measured=IQSegmentDataset(x_test, y_test, nperseg=nperseg).targets.numpy(),
fitted_peak_abs=_fitted_peak(ws, resolved.pa_reference.run_id))
+ if dpd_task and resolved.model.key == "ilc_dpd":
+ from opendpd.services.ilc import ideal_test
+ ideal, ideal_input, evidence = ideal_test(ws, run_dir, resolved, net.pa_model, np.asarray(x_test),
+ float(proj.target_gain), nperseg, save=observer is not None)
+ extra.update(ilc_output=segments(ideal, nperseg), ilc_input=segments(ideal_input, nperseg), ilc_evidence=evidence)
n_valid = _n_test_samples(ws, resolved) or int(prediction.shape[0] * prediction.shape[1])
gain = getattr(proj, "target_gain", None)
return Predictions(prediction, ground_truth, n_valid, float(gain) if gain is not None else None, **extra)
@@ -314,6 +323,11 @@ def _surrogate_evidence(ws: Workspace, run_id: str, resolved: ResolvedExperiment
description="measured PA output of the test split (no DPD), scored against the same linear target",
metrics=score(profile_id, predictions.measured, reference, dataset.signal, valid_samples=n)),
]
+ if predictions.ilc_output is not None:
+ baselines.append(BaselineScore(kind="ilc_ideal",
+ description="ILC Ideal: feedback-optimized on this test waveform through the selected PA surrogate; "
+ "waveform-specific, not a transferable model, global optimum or hardware measurement.",
+ metrics=score(profile_id, predictions.ilc_output, reference, dataset.signal, valid_samples=n)))
fitted = float(predictions.fitted_peak_abs or 0.0)
above = float(np.mean(_amplitude(_valid(predictions.u, n)) > fitted)) if fitted > 0 and n else 0.0
if above > 0:
@@ -326,7 +340,7 @@ def _surrogate_evidence(ws: Workspace, run_id: str, resolved: ResolvedExperiment
coverage = SurrogateCoverage(fitted_peak_abs=fitted, u_peak_abs=u_peak, fraction_above_fitted_peak=above, note=note)
scaling = ScalingInfo(amplitude_units=dataset.signal.amplitude_units, input_scaling=_input_scaling(dataset, version),
reference_gain=predictions.target_gain, physical_calibration=False)
- return dict(signal_chain=chain, baselines=baselines, surrogate_coverage=coverage, scaling=scaling)
+ return dict(signal_chain=chain, baselines=baselines, surrogate_coverage=coverage, scaling=scaling, ilc=predictions.ilc_evidence)
def result_for(ws: Workspace, run_id: str, resolved: ResolvedExperimentConfig, manifest: ArtifactManifest,
@@ -363,6 +377,12 @@ def write_plots(ws: Workspace, run_id: str, resolved: ResolvedExperimentConfig,
"with DPD: PA_surrogate(u)": "primary", "surrogate without DPD": "baseline",
"measured PA without DPD": "baseline"}
outputs = {k: signals[k] for k in ("with DPD: PA_surrogate(u)", "surrogate without DPD", "measured PA without DPD")}
+ if predictions.ilc_output is not None:
+ signals["ILC Ideal PA output"] = predictions.ilc_output
+ signals["ILC Ideal PA input"] = predictions.ilc_input
+ roles["ILC Ideal PA output"] = "baseline"
+ roles["ILC Ideal PA input"] = "predistorted"
+ outputs["ILC Ideal PA output"] = predictions.ilc_output
sig = dataset.signal
out_dir = ws.run_dir(run_id) / PLOTS_DIR
out_dir.mkdir(parents=True, exist_ok=True)
diff --git a/opendpd/services/experiments.py b/opendpd/services/experiments.py
index c2ec1f4..8bddcc9 100644
--- a/opendpd/services/experiments.py
+++ b/opendpd/services/experiments.py
@@ -702,6 +702,9 @@ def add(artifact_id: str, kind: ArtifactKind, path: Path, required: bool, descri
"conditions declared by the operator, capture hashes, alignment and level statistics")
for name in ("worker.log", "stdout.log"):
add(name.replace(".", "-"), ArtifactKind.worker_log, run_dir / "logs" / name, False)
+ for name in ("ilc.json", "ilc-training.npz", "ilc-benchmark.json", "ilc-ideal-test.csv"):
+ add(name.replace(".", "-"), ArtifactKind.other, run_dir / name, False,
+ "ILC waveform controller diagnostics/data; test feedback is a separate Ideal baseline, never fitted training data")
add("fit-diagnostics", ArtifactKind.other, run_dir / "fit.json", False,
"least-squares fit record: method, rank, condition number, cutoff, residual")
add("init-weights", ArtifactKind.other, run_dir / "init" / "weights.pt", False,
@@ -749,7 +752,7 @@ def build_result(ws: Workspace, run_id: str, resolved: ResolvedExperimentConfig,
target_gain: Optional[float], signal_chain: Optional[List[SignalStage]] = None,
baselines: Optional[List[BaselineScore]] = None, surrogate_coverage: Optional[SurrogateCoverage] = None,
scaling: Optional[ScalingInfo] = None, measurement: Optional[MeasurementEvidence] = None,
- execution: Optional[ExecutionEvidence] = None, limitations: Optional[List[str]] = None) -> EvaluationResult:
+ execution: Optional[ExecutionEvidence] = None, ilc: Optional[dict] = None, limitations: Optional[List[str]] = None) -> EvaluationResult:
"""Assemble the evidence around metrics scored by ``opendpd.core.metrics``."""
run_dir = ws.run_dir(run_id)
dataset = ws.get_dataset(resolved.dataset.id)
@@ -841,7 +844,7 @@ def build_result(ws: Workspace, run_id: str, resolved: ResolvedExperimentConfig,
models.append(ModelEvidence(role="dpd", model=resolved.model, run_id=dpd_run_id, weights_sha256=dpd_sha,
n_parameters=dpd_params, lookahead_samples=_lookahead(resolved.model),
execution_semantics=semantics,
- training_path="ila_least_squares" if least_squares else "gradient_dla"))
+ training_path="ilc_ila" if resolved.model.key == "ilc_dpd" else "ila_least_squares" if least_squares else "gradient_dla"))
if resolved.measurement is not None and resolved.measurement.source == "mock_adapter":
source, is_mock = "mock", True
elif resolved.task in (TaskType.train_pa, TaskType.evaluate_pa):
@@ -861,7 +864,7 @@ def build_result(ws: Workspace, run_id: str, resolved: ResolvedExperimentConfig,
models.append(ModelEvidence(role="dpd", model=resolved.model, run_id=dpd_run_id, weights_sha256=dpd_sha,
n_parameters=dpd_params, lookahead_samples=_lookahead(resolved.model),
execution_semantics=semantics,
- training_path="ila_least_squares" if least_squares else "gradient_dla"))
+ training_path="ilc_ila" if resolved.model.key == "ilc_dpd" else "ila_least_squares" if least_squares else "gradient_dla"))
pa_manifest = load_artifacts(ws, pa.run_id)
pa_artifact = next((a for a in (pa_manifest.artifacts if pa_manifest else [])
if a.artifact_id == pa.checkpoint_artifact_id), None)
@@ -886,7 +889,7 @@ def build_result(ws: Workspace, run_id: str, resolved: ResolvedExperimentConfig,
else dataset.raw_sha256 if resolved.dataset.preprocessing_version == "raw-v1" else None),
preprocessing_version=resolved.dataset.preprocessing_version,
split_version=resolved.dataset.split_version, n_samples=n_valid),
- models=models, reference=reference, execution=execution, evaluated_signal=dataset.signal,
+ models=models, reference=reference, execution=execution, ilc=ilc, evaluated_signal=dataset.signal,
valid_sample_range=(measurement.captures[0].valid_sample_range or (0, n_valid)) if measurement else (0, n_valid),
n_segments=n_segments, nperseg=nperseg,
metrics=metrics, selected_epoch=selected_epoch, history=history,
diff --git a/opendpd/services/ilc.py b/opendpd/services/ilc.py
new file mode 100644
index 0000000..cc3253a
--- /dev/null
+++ b/opendpd/services/ilc.py
@@ -0,0 +1,81 @@
+"""ILC training data and separately labelled test-waveform ideal reference."""
+from __future__ import annotations
+import numpy as np
+from opendpd.core.ilc import learn, options, torch_plant
+from opendpd.core.polynomial import to_complex
+from opendpd.services.workspace import read_json, write_json_atomic
+
+ILC_STATEMENT = ('ILC + ILA: optimize only the training waveform through the bound PA surrogate; fit '
+ 'Phi(y_ILC / G) w ~= u_ILC and copy that postdistorter to the predistorter. '
+ 'The test waveform is never used to fit coefficients. The separate Ideal baseline uses test-waveform feedback; '
+ 'it is not transferable, globally optimal, or measured hardware evidence.')
+
+
+def cancellation(run_dir):
+ def check():
+ if (run_dir / 'CANCEL').exists():
+ from opendpd.services.legacy_adapter import RunCancelled
+ raise RunCancelled('ILC cancelled between plant evaluations')
+ return check
+
+
+def training_waveform(ws, run_dir, resolved, x, gain, nperseg, on_epoch):
+ from opendpd.services.evaluation import trained_model
+ p = resolved.model.parameters
+ x = to_complex(x)
+ from opendpd.services.evaluation import _fitted_peak
+ peak_limit = _fitted_peak(ws, resolved.pa_reference.run_id) * p['peak_factor']
+ x = x[:int(p['fit_samples'])]
+ loaded = trained_model(ws, resolved.pa_reference.run_id)
+ plant = torch_plant(loaded.net, nperseg, resolved.execution.device)
+ y0 = plant(x)
+ g = np.vdot(x, y0) / np.vdot(x, x)
+ if not np.isfinite(g) or abs(g) < 1e-8:
+ raise ValueError('PA surrogate has near-zero linear gain; train a usable PA model before ILC.')
+ inverse = 1 / g
+ def progress(row):
+ if on_epoch:
+ on_epoch({'EPOCH': row['iteration'], 'N_EPOCH': int(p['iterations']) + 1, 'TRAIN_LOSS': row['nmse_db']})
+ learned = learn(plant, x, gain, inverse, peak_limit=peak_limit, **options(p),
+ callback=progress, cancel=cancellation(run_dir))
+ write_json_atomic(run_dir / 'ilc.json', {
+ 'version': 'ilc-ila-v1', 'method': ILC_STATEMENT, 'plant_run_id': resolved.pa_reference.run_id,
+ 'plant_sha256': resolved.pa_reference.checkpoint_sha256,
+ 'target_gain': gain, 'inverse_gain': [inverse.real, inverse.imag], 'peak_limit': peak_limit,
+ 'fit_samples': len(x), 'segment_length': nperseg, 'settings': p,
+ 'training_history': learned.history, 'training_stop_reason': learned.stop_reason,
+ 'training_plant_calls': learned.plant_calls, 'test_feedback_used_for_fit': False,
+ })
+ np.savez(run_dir / 'ilc-training.npz', x=x, u=learned.input, y=learned.output)
+ return learned.output / gain, learned.input
+
+
+def ideal_test(ws, run_dir, resolved, plant_model, x, gain, nperseg, *, save=False):
+ source = resolved.dpd_reference.run_id if resolved.task.value == 'run_dpd' else run_dir.name
+ record = read_json(ws.run_dir(source) / 'ilc.json')
+ # Recompute when testing through another surrogate; do not reuse the old plant's inverse.
+ plant = torch_plant(plant_model, nperseg, resolved.execution.device)
+ inverse = complex(*record['inverse_gain'])
+ if resolved.pa_reference.checkpoint_sha256 != record['plant_sha256']:
+ from opendpd.services.datasets import load_version_arrays
+ arrays = load_version_arrays(ws, resolved.dataset.id, resolved.dataset.preprocessing_version)
+ # A different bound surrogate needs its own training-only gain approximation.
+ lo, hi = arrays[2].boundaries['train']
+ train = to_complex(arrays[0][lo:min(hi, lo + int(resolved.model.parameters['fit_samples']))])
+ g = np.vdot(train, plant(train)) / np.vdot(train, train)
+ if not np.isfinite(g) or abs(g) < 1e-8:
+ raise ValueError('The selected PA surrogate has near-zero gain.')
+ inverse = 1 / g
+ learned = learn(plant, to_complex(x), gain, inverse, peak_limit=record['peak_limit'],
+ **options(resolved.model.parameters), cancel=cancellation(run_dir))
+ evidence = {**record, 'test_history': learned.history, 'test_stop_reason': learned.stop_reason,
+ 'test_plant_calls': learned.plant_calls, 'test_samples': len(x),
+ 'test_inverse_gain': [inverse.real, inverse.imag],
+ 'test_plant_run_id': resolved.pa_reference.run_id, 'test_plant_sha256': resolved.pa_reference.checkpoint_sha256}
+ if save:
+ write_json_atomic(run_dir / 'ilc-benchmark.json', evidence)
+ np.savetxt(run_dir / 'ilc-ideal-test.csv', np.column_stack((x, learned.input.real, learned.input.imag,
+ learned.output.real, learned.output.imag)), delimiter=',', header='I,Q,I_ideal,Q_ideal,I_pa_out,Q_pa_out', comments='')
+ out = np.stack((learned.output.real, learned.output.imag), axis=-1).astype(np.float32)
+ inp = np.stack((learned.input.real, learned.input.imag), axis=-1).astype(np.float32)
+ return out, inp, evidence
diff --git a/opendpd/services/polynomial.py b/opendpd/services/polynomial.py
index cf97de9..bc3b7cb 100644
--- a/opendpd/services/polynomial.py
+++ b/opendpd/services/polynomial.py
@@ -49,6 +49,7 @@ def polynomial_module(spec: ModelSpec):
def model_id(role: str, resolved: ResolvedExperimentConfig) -> str:
"""Legacy-shaped id; ``_P_`` carries the real parameter count like the trainer's checkpoints."""
params = resolved.model.parameters
+ params = {k: v for k, v in params.items() if k in ("K", "Q", "Ka", "La", "Kb", "Lb", "Mb", "Kc", "Lc", "Mc")}
tag = "_".join(f"{k.upper()}_{int(v)}" for k, v in params.items() if k != "rcond")
n_real = 2 * coefficient_count(resolved.model.key, params)
return f"{role.upper()}_S_{resolved.training.seed}_M_{resolved.model.key.upper()}_{tag}_F_{resolved.training.frame_length}_P_{n_real}"
@@ -90,6 +91,7 @@ def fit_run(ws: Workspace, run_dir: Path, resolved: ResolvedExperimentConfig, ns
from modules.data_collector import load_dataset
from utils.util import set_target_gain
from opendpd.core.metrics import evaluate
+ from opendpd.services.ilc import ILC_STATEMENT
dataset = ws.get_dataset(resolved.dataset.id)
nperseg = int(dataset.signal.nperseg)
@@ -99,6 +101,8 @@ def fit_run(ws: Workspace, run_dir: Path, resolved: ResolvedExperimentConfig, ns
gain = float(set_target_gain(x_tr, y_tr))
if resolved.training.train_samples is not None: # S17 budget: the first N samples of the train split
x_tr, y_tr = x_tr[:resolved.training.train_samples], y_tr[:resolved.training.train_samples]
+ if key == "ilc_dpd":
+ x_tr, y_tr = x_tr[:int(params["fit_samples"])], y_tr[:int(params["fit_samples"])]
need = basis_bytes(key, params, len(x_tr))
if need > MEMORY_BUDGET_BYTES:
raise MemoryError(f"the {key} basis for {len(x_tr)} training samples needs {need / 2 ** 30:.1f} GiB "
@@ -106,7 +110,11 @@ def fit_run(ws: Workspace, run_dir: Path, resolved: ResolvedExperimentConfig, ns
"or use benchmark/benchmark_volterra.py on a GPU")
role = "pa" if resolved.task == TaskType.train_pa else "dpd"
xc, yc = to_complex(x_tr), to_complex(y_tr)
- if role == "pa":
+ if key == "ilc_dpd":
+ from opendpd.services.ilc import training_waveform
+ post_input, target = training_waveform(ws, run_dir, resolved, x_tr, gain, nperseg, on_epoch)
+ phi = segmented_basis(key, params, post_input, nperseg)
+ elif role == "pa":
phi, target = segmented_basis(key, params, xc, nperseg), yc
else:
phi, target = segmented_basis(key, params, yc / gain, nperseg), xc # ILA: postdistorter on measured data
@@ -138,7 +146,7 @@ def fit_run(ws: Workspace, run_dir: Path, resolved: ResolvedExperimentConfig, ns
_write_logs(hist, best, row)
write_json_atomic(run_dir / FIT_FILE, {
"schema_version": 1, "model": resolved.model.model_dump(mode="json"), "role": role,
- "method": "direct least squares on the train split" if role == "pa" else ILA_STATEMENT,
+ "method": "direct least squares on the train split" if role == "pa" else (ILC_STATEMENT if key == "ilc_dpd" else ILA_STATEMENT),
"segment_length": nperseg, "reference_gain": gain if role == "dpd" else None,
"n_real_parameters": 2 * int(w.size), "diagnostics": diag.to_dict(), "checkpoint": str(save),
})
@@ -186,5 +194,5 @@ def fit_limitations(ws: Workspace, run_id: str, resolved: ResolvedExperimentConf
f"(cutoff rcond={d['rcond']:g}), condition number {d['condition_number']:.3g}, "
f"train residual {d['train_nmse_db']:.2f} dB; deterministic, no seed or epochs"]
if fit["role"] == "dpd":
- out.append(ILA_STATEMENT)
+ out.append(fit["method"])
return out
diff --git a/opendpd/services/recipes.py b/opendpd/services/recipes.py
index eb371d9..d2aa413 100644
--- a/opendpd/services/recipes.py
+++ b/opendpd/services/recipes.py
@@ -111,6 +111,12 @@ def to_dict(self) -> Dict:
"seconds on CPU"),
]
+RECIPES.append(Recipe("dpd-ilc-ila-v1", "ILC-DPD / Ideal DPD benchmark", "baseline", TaskType.train_dpd,
+ ModelSpec(key="ilc_dpd", parameters={}), TrainingConfig(**_LS),
+ "Gain-inverse iterative learning with peak limiting and backtracking, followed by an ILA memory-polynomial fit. Includes a separate waveform-specific Ideal test baseline.",
+ "Simulation through a trained PA surrogate only. The Ideal baseline uses test-waveform feedback; the fitted DPD never does. Defaults are starting values, not a guaranteed optimum.",
+ "seconds to minutes, depending on waveform length, surrogate and iteration count"))
+
def _backbone_starting_points() -> List[Recipe]:
"""Expose the compute registry without copying model construction into the GUI.
diff --git a/opendpd/services/signal_generator.py b/opendpd/services/signal_generator.py
index f8fcfca..bfd34a0 100644
--- a/opendpd/services/signal_generator.py
+++ b/opendpd/services/signal_generator.py
@@ -38,7 +38,7 @@ def list_inputs(ws):
root = ws.root / "signals"
paths = sorted(root.glob("sg-*/manifest.json"), key=lambda p: p.stat().st_mtime, reverse=True)
return [input_summary(read_signal(ws, p.parent.name)) for p in paths[:100]
- if not p.is_symlink() and not p.parent.is_symlink()]
+ if not p.is_symlink() and not p.parent.is_symlink() and not (p.parent / ".removed").exists()]
def export_input(ws, identifier, kind):
@@ -97,6 +97,7 @@ def generate(ws: Workspace, config: GeneratorConfig) -> GeneratedSignal:
with _LOCK:
target = directory(ws, identifier)
if (target / "manifest.json").is_file():
+ (target / ".removed").unlink(missing_ok=True)
return read_signal(ws, identifier)
try:
iq, analysis = synthesize(config)
@@ -206,3 +207,17 @@ def sample_counts(ws, dataset_id, version):
return DatasetSampleCounts(dataset_id=dataset_id, version=version, total_samples=total,
counts={name: end-start for name, (start, end) in bounds.items()},
sample_rate_hz=manifest.signal.sample_rate_hz, guard_samples=split.guard_samples)
+
+
+def archive_input(ws, identifier, *, restore=False):
+ """Hide an input from the picker; retain bytes so simulations and Undo remain valid."""
+ with _LOCK:
+ result = read_signal(ws, identifier)
+ marker = directory(ws, identifier) / '.removed'
+ if marker.is_symlink():
+ raise WorkspaceError('Invalid PA input removal marker.')
+ if restore:
+ marker.unlink(missing_ok=True)
+ return input_summary(result)
+ marker.touch()
+ return {'signal_id': identifier, 'removed': True}
diff --git a/opendpd/studio/launcher.py b/opendpd/studio/launcher.py
index e4a2603..f0e3407 100644
--- a/opendpd/studio/launcher.py
+++ b/opendpd/studio/launcher.py
@@ -125,7 +125,7 @@ def choose_port(requested: Optional[int]) -> int:
def probe(url: str, timeout: float = 1.0) -> Optional[dict]:
try:
- with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310 - loopback only
+ with urllib.request.build_opener(urllib.request.ProxyHandler({})).open(url, timeout=timeout) as resp: # noqa: S310 - loopback only
return json.loads(resp.read().decode("utf-8") or "{}")
except Exception: # noqa: BLE001 - not up yet / connection refused
return None
@@ -346,7 +346,9 @@ def after_ready() -> None:
return
_print_ready(chosen, url, out, stop_hint="Press Ctrl+C to stop.")
if surface == "browser" and not open_browser(url, opener):
- print("could not open a browser (no desktop session?); open the URL above yourself", file=out)
+ print("No browser could be opened here; Studio is still running. Open the URL on this computer, "
+ "or use an SSH port forward from another computer.", file=out)
+ _flush(out)
threading.Thread(target=after_ready, name="opendpd-launcher", daemon=True).start()
_sigterm_as_keyboard_interrupt()
@@ -372,6 +374,11 @@ def _print_ready(port: int, url: str, out, stop_hint: str) -> None:
print(f"warning: {problem}", file=out)
print(f"OpenDPD Studio: {url}", file=out)
print(stop_hint, file=out)
+ print("This address is local to the computer running OpenDPD. Keep this terminal running.", file=out)
+ if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"):
+ print(f"SSH session detected. On the computer with your browser, forward this port:\n"
+ f" ssh -N -L {port}:127.0.0.1:{port} USER@SERVER\n"
+ "Replace USER@SERVER with this SSH destination, then open the printed URL there.", file=out)
_flush(out)
diff --git a/opendpd/studio/window.py b/opendpd/studio/window.py
index 32c2685..957f95a 100644
--- a/opendpd/studio/window.py
+++ b/opendpd/studio/window.py
@@ -17,7 +17,7 @@
from opendpd.studio.strings import ShellStrings
-INSTALL_HINT = 'install the desktop extra: pip install "opendpd[desktop]"'
+INSTALL_HINT = 'repair the installation: uv pip install --reinstall opendpd --torch-backend=auto'
LINUX_HINT = ("install the WebKit2GTK bindings (Debian/Ubuntu: python3-gi gir1.2-webkit2-4.1) "
'or pip install "pywebview[qt]"')
ICON_DIR = Path(__file__).resolve().parent
diff --git a/opendpd/web/app.py b/opendpd/web/app.py
index 06826b3..967caae 100644
--- a/opendpd/web/app.py
+++ b/opendpd/web/app.py
@@ -180,7 +180,7 @@ async def read_body():
if path in {"/models", "/recipes"}:
from opendpd.core.registry import list_models
from opendpd.services.recipes import list_recipes
- models = [m for m in list_models() if m.status == "supported" and m.training_method == "gradient"]
+ models = [m for m in list_models() if (m.status == "supported" and m.training_method == "gradient") or m.key == "ilc_dpd"]
keys = {m.key for m in models}
payload = [m.to_dict() for m in models] if path == "/models" else [r.to_dict() for r in list_recipes() if r.model.key in keys]
await JSONResponse(payload)(scope, receive, send)
diff --git a/opendpd/web/policy.py b/opendpd/web/policy.py
index dfed5cb..d733303 100644
--- a/opendpd/web/policy.py
+++ b/opendpd/web/policy.py
@@ -39,7 +39,7 @@
ROUTES["POST"] += [r"/signal-generator/validate", r"/signal-generator/signals", r"/signal-generator/signals/sg-[a-f0-9]{64}/dataset"]
ROUTES["GET"] += [r"/signal-generator/signals", r"/signal-generator/signals/sg-[a-f0-9]{64}/(input\.csv|metadata\.json)",
r"/pa-library/models", r"/pa-library/simulations/vpa-[a-f0-9]{64}(/(output\.csv|paired\.csv|metadata\.json))?"]
-ROUTES["POST"] += [r"/pa-library/simulations", r"/pa-library/simulations/vpa-[a-f0-9]{64}/dataset"]
+ROUTES["POST"] += [r"/signal-generator/signals/sg-[a-f0-9]{64}/(archive|restore)", r"/pa-library/simulations", r"/pa-library/simulations/vpa-[a-f0-9]{64}/dataset"]
def reject(status: int, code: str, message: str):
@@ -132,6 +132,13 @@ def check_config(config: ExperimentConfig):
if config.evaluation.chunk_samples and config.evaluation.chunk_samples > 65536:
reject(422, "compute_limit", "chunk_samples must be at most 65536")
model = get_model(config.model.key)
+ if model.key == "ilc_dpd":
+ from opendpd.core.registry import validate_parameters
+ params = validate_parameters(model.key, config.model.parameters, "dpd")
+ for name, maximum in {"K": 9, "Q": 16, "iterations": 60, "fit_samples": 32768, "backtracking_steps": 6}.items():
+ if params[name] > maximum:
+ reject(422, "compute_limit", f"ILC {name} must be at most {maximum} in the public app")
+ return
if model.status != "supported" or model.training_method != "gradient":
reject(422, "compute_limit", "the public demo currently supports reviewed neural models only")
params = {**model.defaults(), **config.model.parameters}
diff --git a/pics/studio-home.png b/pics/studio-home.png
index a270837..3918f84 100644
Binary files a/pics/studio-home.png and b/pics/studio-home.png differ
diff --git a/pics/studio-ilc.png b/pics/studio-ilc.png
new file mode 100644
index 0000000..ea8eff3
Binary files /dev/null and b/pics/studio-ilc.png differ
diff --git a/pics/studio-pa-library.png b/pics/studio-pa-library.png
index 1c06513..e2cf0af 100644
Binary files a/pics/studio-pa-library.png and b/pics/studio-pa-library.png differ
diff --git a/pics/studio-psd-chain.png b/pics/studio-psd-chain.png
index b25aa14..04c07b9 100644
Binary files a/pics/studio-psd-chain.png and b/pics/studio-psd-chain.png differ
diff --git a/pics/studio-signal-generator.png b/pics/studio-signal-generator.png
index 96a0339..a2fc5b0 100644
Binary files a/pics/studio-signal-generator.png and b/pics/studio-signal-generator.png differ
diff --git a/pyproject.toml b/pyproject.toml
index 23d1a90..1af9040 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "opendpd"
-version = "2.2.4"
+version = "2.2.5"
description = "An end-to-end learning framework for modeling power amplifiers and digital pre-distortion"
readme = "README.md"
requires-python = ">=3.10"
@@ -43,22 +43,20 @@ dependencies = [
# Versioned data contracts (opendpd/schemas) shared by CLI, API and GUI.
# See docs/architecture/adr/0001-studio-technical-stack.md.
"pydantic>=2.5,<3",
-]
-
-[project.optional-dependencies]
-# OpenDPD Studio: local FastAPI server + prebuilt browser workbench (`opendpd gui`).
-gui = [
"fastapi>=0.110",
"uvicorn>=0.27",
"psutil>=5.9",
"python-multipart>=0.0.9",
+ # Native macOS/Windows bindings are selected by pywebview's platform markers.
+ "pywebview>=6.2,<7; sys_platform != 'linux'",
+ # Qt ships wheels in isolated environments; no system Python/GTK coupling.
+ "pywebview[qt]>=6.2,<7; sys_platform == 'linux'",
]
-# Native application window for `opendpd gui` (pywebview: WKWebView on macOS, WebView2 on
-# Windows, WebKit2GTK or Qt on Linux). Pure Python; the browser stays the fallback.
-desktop = [
- "opendpd[gui]",
- "pywebview>=6.2,<7",
-]
+
+[project.optional-dependencies]
+# Compatibility aliases: Studio and the native window now install by default.
+gui = []
+desktop = []
dev = [
"pytest>=6.0",
"pytest-cov>=2.0",
diff --git a/scripts/check_install.py b/scripts/check_install.py
new file mode 100644
index 0000000..0387f41
--- /dev/null
+++ b/scripts/check_install.py
@@ -0,0 +1,23 @@
+"""Post-install smoke check; no installer hooks or environment mutation."""
+import importlib.metadata
+import sys
+import torch
+
+for package in ('opendpd','torch','fastapi','uvicorn','psutil','python-multipart','pywebview'):
+ print(f'{package}: {importlib.metadata.version(package)}')
+if sys.platform.startswith('linux'):
+ for package in ('PyQt6','PyQt6-WebEngine'):
+ print(f'{package}: {importlib.metadata.version(package)}')
+elif sys.platform=='darwin':
+ print('WKWebView:',importlib.metadata.version('pyobjc-framework-WebKit'))
+elif sys.platform=='win32':
+ print('pythonnet:',importlib.metadata.version('pythonnet'))
+devices=['cpu']
+if torch.cuda.is_available():
+ devices.append('cuda')
+if torch.backends.mps.is_available():
+ devices.append('mps')
+for device in devices:
+ value=torch.tensor([2.,3.],device=device).square().sum().cpu().item()
+ assert value==13.,f'{device} tensor smoke failed'
+ print(f'{device}: tensor arithmetic passed')
diff --git a/scripts/verify_signal_generator.mjs b/scripts/verify_signal_generator.mjs
index 86c1faf..b4002fa 100644
--- a/scripts/verify_signal_generator.mjs
+++ b/scripts/verify_signal_generator.mjs
@@ -41,7 +41,9 @@ try {
assert.deepEqual((await guide.getByRole('button').allTextContents()).slice(0, 3), ['Signal Generator', 'Use an existing dataset', 'Upload CSV'])
assert.equal(await guide.locator('.MuiButton-contained').count(), 1)
await guide.screenshot({ path: path.join(out, `get-started-${width}.png`) })
- const initial = await generated(guide.getByRole('button', { name: 'Signal Generator', exact: true }))
+ await guide.getByRole('button', { name: 'Signal Generator', exact: true }).click()
+ assert.equal(await page.getByTestId('signal-generator-results').count(), 0)
+ const initial = await generated(page.getByRole('button', { name: 'Generate & preview', exact: true }))
await plots()
await page.getByRole('link', { name: 'Choose Virtual PA →', exact: true }).waitFor()
await page.screenshot({ path: path.join(out, `generator-${width}.png`), fullPage: true })
@@ -55,6 +57,7 @@ try {
await page.getByLabel('I/Q samples', { exact: true }).fill('32768')
await page.getByRole('button', { name: 'Advanced parameters', exact: true }).click()
await page.getByLabel('Subcarriers including pilots', { exact: true }).fill('106')
+ await page.getByRole('checkbox', { name: 'Use the same settings for all channels' }).uncheck()
await page.getByRole('button', { name: 'Add OFDMA channel', exact: true }).click()
await page.getByLabel('Subcarriers including pilots', { exact: true }).nth(1).fill('52')
await page.getByRole('combobox', { name: 'Pilot allocation', exact: true }).click()
diff --git a/tests/integration/test_hardening.py b/tests/integration/test_hardening.py
index 4d97314..2c83b11 100644
--- a/tests/integration/test_hardening.py
+++ b/tests/integration/test_hardening.py
@@ -91,12 +91,15 @@ def test_diagnostic_pages_escape_their_text():
assert "