diff --git a/README.md b/README.md index 612ffd0..85bb045 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,11 @@ OpenDPD is a PyTorch framework for power amplifier (PA) modeling and digital pre ## What's new -**OpenDPD 2.2.10** improves Studio security and reliability: bounded uploads, faster built-in dataset browsing, safer worker startup, and hardened publishing and hosted services. Signal Analyzer supports real or complex CSV signals and connects directly to Signal Generator and Virtual PA. +**OpenDPD 2.2.11** makes signal generation easier: 1,186 compact matrix presets, multi-preset datasets, optional ideal input filtering, automatic Virtual PA dataset creation, and CSV/ZIP downloads with a standalone PA replay script. Signal Analyzer accepts real or complex CSV signals. -**Signal Generator → PA Library → PA training → DPD training/testing.** Generate a PA input, simulate its output with one of nine Virtual PAs, or use existing input/output data. Standard presets remain uncoded engineering stimuli; Wi-Fi 8 is experimental. +**Signal Generator → PA Library → PA training → DPD training/testing.** Generate a PA input, simulate its output with one of nine Virtual PAs, or use existing input/output data. Standard presets are uncoded engineering stimuli; each capture keeps its own sample rate and length. -[2.2.10 release notes](https://lab-emi.github.io/OpenDPD/releases/release-notes-2.2.10/) · [Signal Generator](https://lab-emi.github.io/OpenDPD/guides/signal-generator/) · [Signal Analyzer](https://lab-emi.github.io/OpenDPD/guides/signal-analyzer/). During the hosted trial, **2 hours of inactivity clears that IP’s temporary workspaces**; the top bar shows the expiry time. +[2.2.11 release notes](https://lab-emi.github.io/OpenDPD/releases/release-notes-2.2.11/) · [Signal Generator](https://lab-emi.github.io/OpenDPD/guides/signal-generator/) · [Signal Analyzer](https://lab-emi.github.io/OpenDPD/guides/signal-analyzer/). During the hosted trial, **2 hours of inactivity clears that IP’s temporary workspaces**; the top bar shows the expiry time. [Feature history](docs/whats-new.md) · [Verified platform status](docs/releases/support-matrix.md) @@ -70,7 +70,7 @@ powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | ie mkdir opendpd-lab cd opendpd-lab uv venv --python 3.12 -uv pip install --python .venv "opendpd==2.2.10" --torch-backend=auto +uv pip install --python .venv "opendpd==2.2.11" --torch-backend=auto uv run --no-project --python .venv opendpd gui ``` @@ -108,7 +108,7 @@ uv run --no-project --python .venv opendpd gui [PA Library guide](docs/guides/virtual-pa-library.md) · [Reading signal-chain PSD plots](docs/guides/signal-chain-spectra.md) -![Studio 2.2.10: Signal Analyzer with independent spectrum and spectrogram](pics/studio-signal-analyzer.png) +![Studio 2.2.11: compact waveform presets grouped by bandwidth, QAM and OFDMA channels](pics/studio-signal-generator.png) ## Choose your next step diff --git a/docs/architecture/public-studio.md b/docs/architecture/public-studio.md index a5d926c..c2d1c06 100644 --- a/docs/architecture/public-studio.md +++ b/docs/architecture/public-studio.md @@ -403,3 +403,12 @@ The GPU service retains rootful Podman on this installation, with an explicit ca The guest installer moves an existing `OPENDPD_GPU_TOKEN` value into `/etc/opendpd-web-gpu.token`, readable only by root and the worker group, and stores only `OPENDPD_GPU_TOKEN_FILE` in the environment file. New installations should use the file setting directly. Keep public dataset publication disabled unless a separately reviewed submission identity and approval process are configured. The VM administrator has passwordless sudo solely inside the isolated disposable VM; the host administrator's SSH key is root-readable only. Provision the baseline from a reviewed clean image with no user captures, credentials or histories. The production installer refuses to replace an existing VM disk. + + +## Multi-preset generation in 2.2.11 + +`POST /signal-generator/batches` accepts up to 16 distinct presets and four million total samples. `POST /pa-library/datasets` validates all sources before registering any datasets, simulates each at its own sample rate, and saves a collection. Captures use ordinary versioned dataset storage so existing training/evaluation operates on one selected capture with truthful frequency metadata. The first capture is the parent; other captures refer to its ID. The parent manifest records membership, sample counts and independent sample rates. Failed registration removes only datasets created by that operation. + +The two new mutations have explicit public allowlist entries, schema validation, per-IP rates, shared numeric admission and aggregate disk estimates. Custom-dataset capability gating also covers the one-step endpoint. Authenticated `GET /datasets/{id}/download` returns a CSV or collection ZIP and shares heavy-read admission. `collection=false` selects a single capture/version. Export reads verify file membership and hashes; generated replay source is frozen and hash checked, never evaluated by the server. Response completion removes temporary export files. No user-supplied executable code is accepted. + +Deploy the same reviewed 2.2.11 commit to the API, GPU agent/container and static site after draining pending jobs. Retain source/image rollback copies; a server restart expires temporary sessions. Validate multi-preset generation, dataset navigation, authenticated ZIP download and a CUDA training/testing job after deployment. diff --git a/docs/contracts/openapi.json b/docs/contracts/openapi.json index 96fa9a2..6142e53 100644 --- a/docs/contracts/openapi.json +++ b/docs/contracts/openapi.json @@ -2727,6 +2727,51 @@ "title": "DatasetAnalysis", "type": "object" }, + "DatasetCapture": { + "additionalProperties": false, + "properties": { + "bandwidth_hz": { + "exclusiveMinimum": 0.0, + "title": "Bandwidth Hz", + "type": "number" + }, + "dataset_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", + "title": "Dataset Id", + "type": "string" + }, + "label": { + "maxLength": 180, + "title": "Label", + "type": "string" + }, + "n_samples": { + "exclusiveMinimum": 0.0, + "title": "N Samples", + "type": "integer" + }, + "preset_id": { + "maxLength": 64, + "title": "Preset Id", + "type": "string" + }, + "sample_rate_hz": { + "exclusiveMinimum": 0.0, + "title": "Sample Rate Hz", + "type": "number" + } + }, + "required": [ + "dataset_id", + "preset_id", + "label", + "n_samples", + "sample_rate_hz", + "bandwidth_hz" + ], + "title": "DatasetCapture", + "type": "object" + }, "DatasetEvidence": { "additionalProperties": false, "properties": { @@ -2822,6 +2867,14 @@ "DatasetManifest": { "additionalProperties": false, "properties": { + "captures": { + "items": { + "$ref": "#/components/schemas/DatasetCapture" + }, + "maxItems": 16, + "title": "Captures", + "type": "array" + }, "columns": { "anyOf": [ { @@ -2879,6 +2932,18 @@ "origin": { "$ref": "#/components/schemas/DatasetOrigin" }, + "parent_dataset_id": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Dataset Id" + }, "preprocessing_version": { "default": "raw-v1", "title": "Preprocessing Version", @@ -5323,6 +5388,25 @@ "title": "GeneratorAnalysis", "type": "object" }, + "GeneratorBatchRequest": { + "additionalProperties": false, + "properties": { + "configs": { + "items": { + "$ref": "#/components/schemas/GeneratorConfig" + }, + "maxItems": 16, + "minItems": 1, + "title": "Configs", + "type": "array" + } + }, + "required": [ + "configs" + ], + "title": "GeneratorBatchRequest", + "type": "object" + }, "GeneratorConfig": { "additionalProperties": false, "properties": { @@ -5469,6 +5553,11 @@ "title": "Fft Size", "type": "integer" }, + "filter_enabled": { + "default": true, + "title": "Filter Enabled", + "type": "boolean" + }, "frequency_offset_hz": { "default": 0, "title": "Frequency Offset Hz", @@ -5839,6 +5928,11 @@ "GeneratorPreset": { "additionalProperties": false, "properties": { + "channel_count": { + "default": 1, + "title": "Channel Count", + "type": "integer" + }, "config": { "$ref": "#/components/schemas/GeneratorConfig" }, @@ -5851,7 +5945,6 @@ "nr", "wifi6", "wifi7", - "wifi8", "custom" ], "title": "Family", @@ -5861,6 +5954,11 @@ "title": "Label", "type": "string" }, + "numerology": { + "default": "Custom", + "title": "Numerology", + "type": "string" + }, "preset_id": { "title": "Preset Id", "type": "string" @@ -11134,7 +11232,7 @@ "title": "Csrf Token" }, "version": { - "default": "2.2.10", + "default": "2.2.11", "title": "Version", "type": "string" } @@ -13242,6 +13340,40 @@ "title": "Verification", "type": "object" }, + "VirtualPADatasetRequest": { + "additionalProperties": false, + "properties": { + "input_signal_ids": { + "items": { + "pattern": "^sg-[a-f0-9]{64}$", + "type": "string" + }, + "maxItems": 16, + "minItems": 1, + "title": "Input Signal Ids", + "type": "array" + }, + "model_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", + "title": "Model Id", + "type": "string" + }, + "parameters": { + "additionalProperties": { + "type": "number" + }, + "maxProperties": 32, + "title": "Parameters", + "type": "object" + } + }, + "required": [ + "input_signal_ids", + "model_id" + ], + "title": "VirtualPADatasetRequest", + "type": "object" + }, "VirtualPAModel": { "additionalProperties": false, "properties": { @@ -13360,6 +13492,18 @@ "title": "Input Iq Sha256", "type": "string" }, + "kernel_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Kernel Sha256" + }, "kind": { "const": "simulated_pa_output", "default": "simulated_pa_output", @@ -13624,7 +13768,7 @@ }, "info": { "title": "OpenDPD Studio API", - "version": "2.2.10" + "version": "2.2.11" }, "openapi": "3.1.0", "paths": { @@ -14649,6 +14793,66 @@ ] } }, + "/api/v1/datasets/{dataset_id}/download": { + "get": { + "operationId": "dataset_download_api_v1_datasets__dataset_id__download_get", + "parameters": [ + { + "in": "path", + "name": "dataset_id", + "required": true, + "schema": { + "title": "Dataset Id", + "type": "string" + } + }, + { + "in": "query", + "name": "version", + "required": false, + "schema": { + "default": "raw-v1", + "title": "Version", + "type": "string" + } + }, + { + "in": "query", + "name": "collection", + "required": false, + "schema": { + "default": true, + "title": "Collection", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Dataset Download", + "tags": [ + "signal generator" + ] + } + }, "/api/v1/datasets/{dataset_id}/manifest": { "post": { "operationId": "dataset_update_api_v1_datasets__dataset_id__manifest_post", @@ -15794,6 +15998,47 @@ ] } }, + "/api/v1/pa-library/datasets": { + "post": { + "operationId": "simulate_dataset_api_v1_pa_library_datasets_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VirtualPADatasetRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GeneratorDatasetResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Simulate Dataset", + "tags": [ + "virtual PA library" + ] + } + }, "/api/v1/pa-library/models": { "get": { "operationId": "models_api_v1_pa_library_models_get", @@ -17501,6 +17746,51 @@ ] } }, + "/api/v1/signal-generator/batches": { + "post": { + "operationId": "generate_batch_api_v1_signal_generator_batches_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GeneratorBatchRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PAInputDataset" + }, + "title": "Response Generate Batch Api V1 Signal Generator Batches Post", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Generate Batch", + "tags": [ + "signal generator" + ] + } + }, "/api/v1/signal-generator/presets": { "get": { "operationId": "list_presets_api_v1_signal_generator_presets_get", diff --git a/docs/guides/signal-generator.md b/docs/guides/signal-generator.md index 894ad89..977c471 100644 --- a/docs/guides/signal-generator.md +++ b/docs/guides/signal-generator.md @@ -1,11 +1,11 @@ # Studio Signal Generator Open **Signal Generator** in the sidebar, or choose **Get Started → Signal Generator**. -The first visit selects a private 5G NR numerology configuration; generation starts only when requested. Choose one of the five -signal families, select a preset, and press **Generate & preview**. Parameter edits -mark the current plots stale and disable waveform export and the next step until -generation succeeds. Returning to the tab restores the selected input. The result -is explicitly a **PA Input Dataset (x)**, with no PA output. +The first visit selects a private NR 20 MHz configuration; generation starts only when requested. Choose **5G NR**, **Wi-Fi 6**, **Wi-Fi 7** or **Custom**. The **Signal setup** panel is directly below the family buttons, followed by **Use this signal**. + +Select one or more compact matrix cells: bandwidth runs horizontally, QAM vertically, and each group has a different OFDMA channel count. NR numerology buttons switch FR1/FR2 and subcarrier spacing. Selected chips let you edit or preview each preset. Sample count, duration and the optional default filter apply to the highlighted preset. Press **Generate & preview** once for the whole selection. + +The result is explicitly a **PA Input Dataset (x)**, with no PA output. Parameter changes disable exports and the next step until regenerated. Returning to this tab restores the selected batch. Up to 16 presets can be selected, with different sample rates and lengths; they are never silently concatenated. The onboarding dialog has exactly one highlighted action: Signal Generator. Existing datasets are the second choice, and CSV upload is third. PA and DPD each have one @@ -20,25 +20,12 @@ not complete protocol implementations or certified reference test models. | Family | Presets | Implemented signal | | --- | --- | --- | -| 5G NR | FR1 20 / 100 MHz; FR2 100 MHz | 30 / 120 kHz subcarrier spacing, normal CP, 51 / 273 / 66 resource-block-sized payload grids, generic pilots | -| Wi-Fi 6 | 20 / 40 / 80 / 160 MHz | 78.125 kHz spacing, 0.8 µs guard interval, up to 1024-QAM | -| Wi-Fi 7 | 20 / 40 / 80 / 160 / 320 MHz | 78.125 kHz spacing, up to 4096-QAM | -| Wi-Fi 8 | 80 / 160 / 320 MHz | Experimental OFDM numerology profile; generic allocations and pilots | -| Custom | OFDM/OFDMA, DFT-spread OFDM, QAM, PSK, FSK/GFSK, noise, tone, multitone, chirp | Fully editable baseband parameters | - -OFDM payload symbols are uncoded and continuous. Pilots are generic seeded BPSK, -including when their bin positions are explicitly specified. Synchronization, FEC, -NR physical/control-channel mapping, WLAN preambles, standard RU allocation bitmaps, -MAC packets, and draft-specific UHR mechanisms are **not implemented**. The GUI, -saved metadata, and exports all disclose this scope. Changing preset numerology -marks the waveform custom. Existing known-waveform evaluation bindings are not -assigned to these generated signals. - -NR CP timing follows [TS 38.211, §5.3.1](https://www.etsi.org/deliver/etsi_ts/138200_138299/138211/15.02.00_60/ts_138211v150200p.pdf). -The long normal CP appears twice per subframe; extended CP requires 60 kHz spacing. -For WLAN background, see the [IEEE 802.11 working group](https://www.ieee802.org/11/). -As of 2026-09-13, 802.11bn remains a draft; its development status is tracked by -[IEEE TGbn](https://www.ieee802.org/11/Reports/tgbn_update.htm). +| 5G NR | 940 presets | FR1 3–100 MHz and FR2-1 50–400 MHz; 15/30/60/120 kHz SCS where defined; QPSK through 1024-QAM | +| Wi-Fi 6 | 96 presets | 20/40/80/160 MHz; BPSK through 1024-QAM; 1/2/4/8 allocations | +| Wi-Fi 7 | 140 presets | 20/40/80/160/320 MHz; BPSK through 4096-QAM; 1/2/4/8 allocations | +| Custom | 10 waveforms | OFDM/OFDMA, DFT-spread OFDM, QAM, PSK, FSK/GFSK, noise, tone, multitone, chirp | + +See the [preset tables and source references](signal-presets.md) for exact RB/tone counts, timing and implementation limits. These uncoded payloads use generic pilots and allocation placement, with no full protocol framing or conformance certification. Wi-Fi 8 generation has been removed. Existing stored signals remain readable. ## Advanced parameters @@ -61,8 +48,7 @@ samples. Changing SCS in the GUI updates the output sample rate. Carrier frequen is saved as RF metadata and never digitally mixes a GHz signal into the baseband. RMS normalization precedes impairments. I gain and Q phase mismatch are applied -first, followed by DC offset, frequency offset, clipping and noise. There is no -post-impairment normalization or hidden receiver equalization. +first, followed by DC offset, frequency offset, clipping and noise. The optional default FFT low-pass follows the impairments and preserves their resulting RMS. Its cosine transition spans the outer 4% of nominal half-bandwidth. Filtering can change EVM, peaks and burst edges; the receiver performs no fitted equalization. See [filter semantics](signal-presets.md#length-and-filtering). ## Visualizations and measurements @@ -85,27 +71,21 @@ retained to preserve the exact requested sample count and disclosed in metadata. ## Export and training -**Save configuration** downloads JSON; **Load configuration** validates it against -the server contract before applying it. **Export I/Q + configuration** contains -`iq.csv` (I,Q), `iq.npy` (float32 N×2), configuration, measurements, source hash, -NumPy/SciPy versions and scope notes. CSV float32 values round-trip exactly. Seeded -regeneration requires the recorded configuration, generator implementation and -numeric environment. Numerical dependency versions participate in the signal identity. Generated records live privately under `signals/sg-/` -inside the workspace. - -**Download PA input CSV** and **Download input metadata JSON** are separate actions. -The CSV has two columns, `I,Q`; metadata declares `signal_role: pa_input`, -`has_pa_output: false`, the sample rate/count, carrier metadata, generator parameters -and CSV/NPY hashes. The complete signal archive remains available as well. - -The waveform alone has **no PA output**. **Choose Virtual PA** opens the -[PA Library](virtual-pa-library.md). Users select a mathematical Virtual PA, -adjust its formula parameters, choose a saved input and explicitly simulate y. -After reviewing the output, **Create paired dataset & train PA** pairs the exact -input and frozen output, then opens PA Training. It requires at least 8,192 input -samples and at least 256 samples per split after guards. Both x and y are marked -synthetic. The deprecated implicit-PA API remains available to older clients; -the Studio UI no longer uses it. +**Save configuration** downloads the highlighted preset's JSON; **Load configuration** validates and selects it. **Download PA input CSV** and **Download input metadata JSON** export the currently previewed signal separately. CSV columns are `I,Q`; metadata records the signal role, actual sample rate/count, seed, filter and waveform parameters, numeric environment and hashes. Seeded byte reproduction requires the recorded implementation and environment. + +The waveform has **no PA output**. **Choose Virtual PA** carries all selected inputs to [PA Library](virtual-pa-library.md). Choose the mathematical PA and parameters, then click **Simulate PA output**. Studio simulates every capture independently, saves the complete dataset automatically and opens **Datasets → details**. There is no separate pairing form. Each capture requires at least 8,192 input samples. The default split is 60/20/20 with 256-sample guards; preprocessing can create a different version later. + +For multiple presets, **Visualize subdataset** switches charts, metadata, preprocessing and training to that capture's own sample rate. **Download CSV** exports the selected capture/version as `I_in,Q_in,I_out,Q_out`. **Download all · ZIP** exports every original capture, per-capture metadata and one frozen `simulate_pa.py`: + +```bash +uv run simulate_pa.py 01-nr-20.csv --output pa-output.csv +# For an independently named input: +uv run simulate_pa.py my-input.csv --preset 01-nr-20.csv --output pa-output.csv +``` + +The script accepts `I,Q` or `I_in,Q_in`, requires NumPy/SciPy, and exposes `--sample-rate` and bounded `--parameter NAME=VALUE` overrides. It runs without an OpenDPD installation. Each CSV can have a different length. Memory state starts from zero for each capture; output is not filtered or normalized. Matching numerical libraries reproduce float32 output exactly in release tests; other platforms may differ by roundoff. + +A single-preset dataset downloads directly as CSV. The deprecated separate-pairing APIs remain for older clients; the new GUI uses the one-step dataset endpoint. Get Started → existing dataset opens a paired-data selector and proceeds straight to PA Training. The compact, expandable workflow diagram marks dataset making as @@ -122,19 +102,15 @@ transmitter, GitHub submission, or email is activated by generating a waveform. ## Verification -Numerical tests cover all 25 presets, exact sample counts, one-millisecond NR CP +Numerical tests cover all 1,186 presets, exact sample counts, one-millisecond NR CP timing, FFT allocations, empty bins, deterministic impairments, analytic single-tone PAPR, invalid parameters, export round trips and dataset provenance. API tests cover authentication, CSRF, host feature gating and per-version test counts. Hosted tests verify that one session cannot access another session's generated signals or data. -`scripts/verify_signal_generator.mjs` exercises the real local browser at 1366×768 -and 1920×1080: onboarding order and highlight, generation, stale-result protection, -custom two-channel OFDMA with explicit pilots and noise, ZIP export, dataset creation, -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. +`scripts/verify_signal_generator.mjs` exercises a real local browser at desktop and mobile widths: matrix selection, heterogeneous captures, formula highlighting, automatic dataset navigation, CSV/ZIP downloads, per-capture visualization and restored selection. Integration tests replay both captures through the exported Python script for all nine Virtual PA families and check tenant isolation, resource limits, rollback and real CPU training. These are software/numerical checks, not physical RF or full standards-conformance evidence. -## Studio 2.2.9 preview +## Studio 2.2.11 preview ![PA input waveform and its independent PSD](../../pics/studio-signal-generator.png) @@ -163,3 +139,5 @@ RRC **span** denotes the total span: `span × samples_per_symbol + 1` taps. This **Open in Signal Analyzer** sends the saved input directly to the [analysis workspace](signal-analyzer.md) for configurable PSD, spectrogram, amplitude statistics, eyes, aligned reference errors and report exports. ![Custom signal controls](../../pics/studio-signal-generator-custom.png) + +![Selecting a subdataset and downloading its CSV or the complete collection](../../pics/studio-dataset-presets.png) diff --git a/docs/guides/signal-presets.md b/docs/guides/signal-presets.md new file mode 100644 index 0000000..8030b3a --- /dev/null +++ b/docs/guides/signal-presets.md @@ -0,0 +1,70 @@ +# Signal preset reference + +Studio 2.2.11 offers **1,186 engineering presets**: 940 NR, 96 Wi-Fi 6, 140 Wi-Fi 7 and 10 custom waveforms. Matrix columns are **nominal baseband bandwidth**, not RF carrier frequency; rows are modulation. Separate matrices select 1, 2, 4 or 8 OFDMA channels. Numerology buttons select the NR frequency range and subcarrier spacing. + +These are **continuous, uncoded complex-baseband PA stimuli**. The catalog adopts published bandwidths, resource sizes, modulation orders and OFDM timing. It does not implement transport coding, NR synchronization/control channels, WLAN preambles, standard pilot sequences or the full RU-placement bitmap. It is not a collection of certified standard test models. Generic per-channel pilots and allocation placement are disclosed in every export. An OFDMA channel here is an independent allocation inside one RF band; it is not a separate adjacent RF carrier or a MIMO spatial stream. + +## NR bandwidths + +The RB counts below follow **3GPP TS 38.104 V18.9.0, Tables 5.3.2-1 and 5.3.2-2**. Normal cyclic-prefix timing and QAM mappings follow **TS 38.211 V18.5.0, §§5.1 and 5.3.1**. All available rows offer QPSK, 16/64/256/1024-QAM. 1024-QAM is a Release 18 modulation option; the catalog does not imply that every RF band, device, direction or coded MCS supports every displayed combination. + +| FR1 bandwidth (MHz) | 15 kHz RBs | 30 kHz RBs | 60 kHz RBs | +| --- | ---: | ---: | ---: | +| 3 | 15 | — | — | +| 5 | 25 | 11 | — | +| 10 | 52 | 24 | 11 | +| 15 | 79 | 38 | 18 | +| 20 | 106 | 51 | 24 | +| 25 | 133 | 65 | 31 | +| 30 | 160 | 78 | 38 | +| 35 | 188 | 92 | 44 | +| 40 | 216 | 106 | 51 | +| 45 | 242 | 119 | 58 | +| 50 | 270 | 133 | 65 | +| 60 | — | 162 | 79 | +| 70 | — | 189 | 93 | +| 80 | — | 217 | 107 | +| 90 | — | 245 | 121 | +| 100 | — | 273 | 135 | + +| FR2-1 bandwidth (MHz) | 60 kHz RBs | 120 kHz RBs | +| --- | ---: | ---: | +| 50 | 66 | 32 | +| 100 | 132 | 66 | +| 200 | 264 | 132 | +| 400 | — | 264 | + +With N allocations, each receives `floor(RBs / N) × 12` active tones. Remainder RBs are unused. The FFT is the next power of two that spans the nominal bandwidth, with 4× output oversampling. Default RF metadata is 3.5 GHz for FR1 and 28 GHz for FR2-1. RF carrier metadata does not digitally upconvert the signal. FR2-2 and band-specific scheduling rules are outside this catalog. + +## WLAN bandwidths and allocations + +Wi-Fi 6 offers 20/40/80/160 MHz and BPSK, QPSK, 16/64/256/1024-QAM. Wi-Fi 7 adds 320 MHz and 4096-QAM. Both use 78.125 kHz spacing, a 12.8 µs useful symbol and a 0.8 µs guard interval by default. Advanced controls can change the guard interval; the fixed CP field is in samples **before** oversampling. + +| Bandwidth (MHz) | Tones / allocation: 1 channel | 2 channels | 4 channels | 8 channels | +| --- | ---: | ---: | ---: | ---: | +| 20 | 242 | 106 | 52 | 26 | +| 40 | 484 | 242 | 106 | 52 | +| 80 | 996 | 484 | 242 | 106 | +| 160 | 1992 | 996 | 484 | 242 | +| 320 (Wi-Fi 7) | 3984 | 1992 | 996 | 484 | + +These are RU-sized engineering allocations. Aggregate 1992/3984-tone payloads represent 2×996/4×996 active tones; their contiguous placement, four-bin inter-allocation gaps and generic comb pilots **do not reproduce a standard packet tone map**. The selected QAM is the constellation order, not an MCS index or a coding rate. Default sample rate is four times nominal bandwidth. + +The public IEEE task-group records and specification-framework links identify the HE/EHT development basis. The final IEEE standards and framework downloads were not accessible to the release validation environment; full clause-by-clause PHY validation is therefore not claimed. Public task-group allocation records and the published HE timing description support the limited engineering scope above. + +## Length and filtering + +Defaults approximate 0.25 ms, bounded to 16,384–196,608 samples. Each selected preset can have its own exact length or duration. The batch limit is 16 presets and 4,000,000 total samples; one preset allows 256–1,000,000 samples, with at least 8,192 needed for a training dataset. Hosted storage admission may require a smaller batch. + +The default ideal PA-input filter is a periodic-record, zero-phase FFT low-pass, centered on the configured frequency offset. Its gain is one to 96% of the nominal half-bandwidth, a cosine transition to 100%, then zero. It preserves the exact sample count and pre-filter RMS. It runs after impairments, so it also band-limits noise and can change EVM, peak amplitude and burst edges. It is not a causal hardware filter. Disable it per preset for unfiltered numerical experiments. **Virtual PA output is not filtered:** nonlinear spectral regrowth remains visible. + +Stop-band energy is tested on the full-record DFT; the displayed Welch PSD includes finite-window leakage and should not be interpreted as the exact filter response. Float32 export introduces a small numerical floor. + +## Primary references + +- [ETSI / 3GPP TS 38.104 V18.9.0](https://www.etsi.org/deliver/etsi_ts/138100_138199/138104/18.09.00_60/ts_138104v180900p.pdf), Tables 5.3.2-1 and 5.3.2-2. +- [ETSI / 3GPP TS 38.211 V18.5.0](https://www.etsi.org/deliver/etsi_ts/138200_138299/138211/18.05.00_60/ts_138211v180500p.pdf), modulation and OFDM signal generation. +- [IEEE TGax official records and specification framework](https://grouper.ieee.org/groups/802/11/Reports/tgax_update.htm). +- [IEEE TGbe official records and specification framework](https://grouper.ieee.org/groups/802/11/Reports/tgbe_update.htm). +- [IEEE TGbe resource-allocation discussion](https://www.ieee802.org/11/email/stds-802-11-tgbe/msg02302.html), aggregate RU sizes and zero-user signaling context. +- [NI: HE waveform timing and modulation](https://www.ni.com/en/solutions/semiconductor/wireless-connectivity-test/introduction-to-802-11ax-high-efficiency-wireless.html), supplemental vendor technical documentation. diff --git a/docs/guides/virtual-pa-library.md b/docs/guides/virtual-pa-library.md index 9c0dbe6..6e11d10 100644 --- a/docs/guides/virtual-pa-library.md +++ b/docs/guides/virtual-pa-library.md @@ -12,12 +12,10 @@ Keeping these roles separate makes the provenance of every PA output explicit. describe illustrative applications, not device calibrations or foundry models. 3. Select a saved input. Adjust sliders or numeric fields; selecting a control highlights the matching variables in the displayed equations, and vice versa. -4. Click **Simulate PA output**. Inspect input/output AM/AM, AM/PM, envelope and - spectrum, plus dynamic states where applicable. Output CSV, paired CSV and - simulation metadata JSON are independent downloads. -5. Click **Create paired dataset & train PA**. This is the only new GUI step that - registers the generated pair as trainable data. Then train the PA surrogate - and use it as the reference for DPD training. +4. Click **Simulate PA output**. Studio computes each selected preset at its own sample rate, creates the complete dataset and opens its detail page automatically. +5. Inspect the input/output plots in **Datasets**. For a collection, use **Visualize subdataset** to switch captures. Download the current CSV or the whole ZIP, which includes metadata and one standalone, parameterized PA replay script. Continue to PA Training from the selected capture. + +There is no separate **Paired dataset** node or dataset-creation form. Each capture uses a 60/20/20 split with 256-sample guards and needs at least 8,192 samples. Captures may have unequal lengths; memory state starts from zero independently for each preset. The generator's optional ideal input filter does not filter PA output. The diagram separates dataset making from model training. It stays compact while scrolling and expands for inspection. Existing paired datasets bypass generation @@ -49,7 +47,7 @@ calibrated transistor-physics simulation. ## Equations and units The complete equations, parameter bounds, defaults, explanations and symbols -come from `opendpd/core/virtual_pa.py` and are included in each frozen simulation. +come from `opendpd/core/virtual_pa.py`; the numerical kernel is `opendpd/core/virtual_pa_kernel.py` and are included in each frozen simulation. 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 @@ -125,13 +123,13 @@ causality, zero input, deterministic replay, analytic gain/saturation, memory-ta response and physical-time consistency. API tests cover input-only isolation, exact pairing, hashes, frozen parameters, unchanged splits, real CPU PA/DPD jobs, feature gating and public-session isolation. Frontend tests cover linked controls, -invalidated previews, explicit pairing and existing-dataset bypass. The browser -script `scripts/verify_signal_generator.mjs` exercises real downloads and workers. +invalidated outputs, automatic dataset creation and existing-dataset bypass. The browser +script `scripts/verify_signal_generator.mjs` exercises real downloads and dataset navigation. -## Studio 2.2.5 preview +## Studio 2.2.11 preview ![Virtual PA formula controls](../../pics/studio-pa-library.png) -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 dataset detail page 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). +The input selector and **Simulate PA output** control sit above the mathematical parameters. After simulation, Studio opens the saved dataset details directly. **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 add6e7d..0d16bad 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,6 +1,6 @@ # Installation -[Use the hosted Studio](https://opendpd.com/studio/) without installation, or run OpenDPD **2.2.10** locally. Python 3.12 is the recommended starting point; the compute suite also covers 3.10–3.13. +[Use the hosted Studio](https://opendpd.com/studio/) without installation, or run OpenDPD **2.2.11** locally. Python 3.12 is the recommended starting point; the compute suite also covers 3.10–3.13. ## Install uv @@ -26,7 +26,7 @@ These commands work on macOS, Linux and Windows; activation is unnecessary becau mkdir opendpd-lab cd opendpd-lab uv venv --python 3.12 -uv pip install --python .venv "opendpd==2.2.10" --torch-backend=auto +uv pip install --python .venv "opendpd==2.2.11" --torch-backend=auto uv run --no-project --python .venv opendpd doctor uv run --no-project --python .venv opendpd gui ``` @@ -76,7 +76,7 @@ A browser-open failure is nonfatal: Studio continues serving its URL. The launch Standard pip installs all Python dependencies too: ```sh -python -m pip install "opendpd==2.2.10" +python -m pip install "opendpd==2.2.11" opendpd gui ``` diff --git a/docs/releases/release-notes-2.2.11.md b/docs/releases/release-notes-2.2.11.md new file mode 100644 index 0000000..3918847 --- /dev/null +++ b/docs/releases/release-notes-2.2.11.md @@ -0,0 +1,14 @@ +# OpenDPD Studio 2.2.11 + +Signal Generator now puts the signal family, preset setup and next-step actions in one clear sequence. Compact matrices replace the preset dropdown: bandwidth runs horizontally, QAM vertically, and separate groups show OFDMA channel counts. The catalog includes 940 NR, 96 Wi-Fi 6, 140 Wi-Fi 7 and 10 custom engineering presets. Wi-Fi 8 generation is removed. + +- Select up to 16 presets with independent sample rates, bandwidths and lengths. Each creates a separate capture; signals with different sampling clocks are not concatenated. +- An ideal PA-input filter is enabled by default, with a per-preset switch. It sharpens band edges, preserves sample count and RMS, and reports its effect on measured EVM/PAPR. PA output retains nonlinear spectral regrowth. +- **Simulate PA output** now saves a complete dataset and opens its details automatically. The separate pairing form and diagram node are removed. +- Dataset details offer a subdataset selector and top-level downloads. **Download CSV** uses the selected capture/version. **Download all · ZIP** contains all original captures, per-capture JSON metadata and one parameterized `simulate_pa.py` with frozen formulas, sample rates and model parameters. +- The replay script accepts input CSV and writes output CSV without an OpenDPD installation. Release tests reproduce float32 output exactly for all nine PA model families; numerical libraries/platforms may introduce roundoff differences. +- Batch APIs retain authentication, CSRF protection, tenant isolation, compute admission and aggregate storage quotas. Failed dataset registration rolls back only newly created datasets. Temporary download files are removed after response completion. + +Presets remain continuous, uncoded engineering stimuli with generic pilot/allocation placement. Full NR/WLAN protocol frames and standards-conformance certification are outside this release. See the [preset reference](../guides/signal-presets.md) for exact table sources and validation limits. + +Install with `uv pip install "opendpd==2.2.11" --torch-backend=auto`, or open [Studio on the web](https://opendpd.com/studio/?v=2.2.11). diff --git a/docs/tutorials/gui-quickstart.md b/docs/tutorials/gui-quickstart.md index ebb90eb..e159286 100644 --- a/docs/tutorials/gui-quickstart.md +++ b/docs/tutorials/gui-quickstart.md @@ -12,7 +12,7 @@ Studio opens locally in your browser, or in a native window if you installed the ## 1. Open and inspect a dataset -On **Home**, click **Get Started → Use an existing dataset** and choose **DPA_200MHz**. Studio opens PA Training with dataset making already complete in the workflow diagram. Use the Datasets sidebar to inspect the paired capture. Alternatively choose **Signal Generator**, generate x, then **Choose Virtual PA → Simulate PA output → Create paired dataset & train PA**. +On **Home**, click **Get Started → Use an existing dataset** and choose **DPA_200MHz**. Studio opens PA Training with dataset making already complete in the workflow diagram. Use the Datasets sidebar to inspect the paired capture. Alternatively choose **Signal Generator**, generate x, then **Choose Virtual PA → Simulate PA output → Dataset details → Configure experiment**. The dataset page shows input/output I/Q signals, sample rate, bandwidth and signal quality. Use the **Dataset Doctor** tab to inspect findings, then **Configure experiment** to continue. Built-in measured datasets keep their supplied splits; **MyCustomPA** is explicitly labeled synthetic tutorial data. diff --git a/docs/whats-new.md b/docs/whats-new.md index b37f35e..6c83e53 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,11 @@ # What's new +## 2.2.11: matrix presets and automatic PA datasets + +Select multiple compact presets by bandwidth, QAM and OFDMA channel count. The catalog contains 1,186 NR, Wi-Fi 6/7 and custom engineering presets, with optional ideal input filtering. Virtual PA simulation now creates the dataset and opens its details in one action. Switch subdatasets to inspect different sample rates and lengths; download a CSV or a ZIP with all captures, metadata and a standalone PA replay script. Wi-Fi 8 generation and the separate pairing step are removed. + +[Release notes](releases/release-notes-2.2.11.md) · [Preset reference](guides/signal-presets.md) + ## 2.2.10: security and reliability Faster built-in dataset browsing, resilient worker failures, safe upload cancellation, strict API inputs, explicit Python training arguments and hardened hosted deployment. Desktop dependencies and offline built-in data remain included. diff --git a/frontend/e2e/a11y.spec.ts b/frontend/e2e/a11y.spec.ts index 9906f26..84b1095 100644 --- a/frontend/e2e/a11y.spec.ts +++ b/frontend/e2e/a11y.spec.ts @@ -123,7 +123,7 @@ test.describe('keyboard-only journey', () => { await expect(page.getByRole('button', { name: 'Add & inspect DPA_200MHz' })).toBeVisible() await tabTo(page, /^Add & inspect DPA_200MHz$/) await page.keyboard.press('Enter') - for (const step of ['input', 'virtual', 'output', 'paired']) await expect(page.getByTestId('workflow-' + step)).toHaveAttribute('data-complete', 'true') + for (const step of ['input', 'virtual', 'output']) await expect(page.getByTestId('workflow-' + step)).toHaveAttribute('data-complete', 'true') await expect(page.getByRole('heading', { level: 1, name: 'PA Model', exact: true })).toBeVisible() await expect(page.getByText('Configuration is valid')).toBeVisible() await tabTo(page, /^Continue$/) diff --git a/frontend/e2e/journey.spec.ts b/frontend/e2e/journey.spec.ts index dff0aed..8851237 100644 --- a/frontend/e2e/journey.spec.ts +++ b/frontend/e2e/journey.spec.ts @@ -9,7 +9,7 @@ test.describe('J1 — reproduce the built-in example (mock API)', () => { await page.getByRole('link', { name: 'Get Started' }).click() await page.getByRole('button', { name: 'Use an existing dataset' }).click() await page.getByRole('button', { name: 'Add & inspect DPA_200MHz' }).click() - for (const step of ['input', 'virtual', 'output', 'paired']) await expect(page.getByTestId('workflow-' + step)).toHaveAttribute('data-complete', 'true') + for (const step of ['input', 'virtual', 'output']) await expect(page.getByTestId('workflow-' + step)).toHaveAttribute('data-complete', 'true') await expect(page.getByRole('heading', { level: 1, name: 'PA Model', exact: true })).toBeVisible() await expect(page.getByText(/Quick trial: a few epochs/)).toBeVisible() await expect(page.getByText('Configuration is valid')).toBeVisible() diff --git a/frontend/mocks/dataset_builtin.json b/frontend/mocks/dataset_builtin.json index a67a8c0..14cca8b 100644 --- a/frontend/mocks/dataset_builtin.json +++ b/frontend/mocks/dataset_builtin.json @@ -2,6 +2,7 @@ "_mock": true, "_note": "Generated from opendpd.schemas.examples; do not edit by hand.", "data": { + "captures": [], "columns": null, "dataset_id": "dpa-200mhz", "display_name": "DPA_200MHz (built-in, measured)", @@ -20,6 +21,7 @@ "n_samples": 38400, "notes": null, "origin": "measured", + "parent_dataset_id": null, "preprocessing_version": "raw-v1", "raw_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "schema_version": 1, diff --git a/frontend/mocks/dataset_missing_metadata.json b/frontend/mocks/dataset_missing_metadata.json index 66fe6df..bca0e06 100644 --- a/frontend/mocks/dataset_missing_metadata.json +++ b/frontend/mocks/dataset_missing_metadata.json @@ -2,6 +2,7 @@ "_mock": true, "_note": "Generated from opendpd.schemas.examples; do not edit by hand.", "data": { + "captures": [], "columns": { "input_i": "I_in", "input_q": "Q_in", @@ -20,6 +21,7 @@ "n_samples": 4096, "notes": null, "origin": "unknown", + "parent_dataset_id": null, "preprocessing_version": "raw-v1", "raw_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "schema_version": 1, diff --git a/frontend/mocks/generator_presets.json b/frontend/mocks/generator_presets.json index 51ec497..0de925a 100644 --- a/frontend/mocks/generator_presets.json +++ b/frontend/mocks/generator_presets.json @@ -3,6 +3,7 @@ "_note": "Generated from opendpd.schemas.examples; do not edit by hand.", "data": [ { + "channel_count": 1, "config": { "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, @@ -28,6 +29,7 @@ "dft_spreading": false, "duration_ms": 1.0, "fft_size": 1024, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -36,7 +38,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 30720, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -61,27 +63,29 @@ "version": "signal-generator-v1", "waveform": "ofdm" }, - "description": "30 kHz SCS \u00b7 normal CP \u00b7 64-QAM", + "description": "30 kHz SCS \u00b7 normal CP \u00b7 51 RB per channel \u00b7 uncoded payload", "family": "nr", - "label": "FR1 \u00b7 20 MHz", + "label": "FR1 \u00b7 20 MHz \u00b7 64-QAM \u00b7 1 ch", + "numerology": "FR1 \u00b7 30 kHz", "preset_id": "nr-20" }, { + "channel_count": 1, "config": { - "bandwidth_hz": 100000000.0, + "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, "burst_on_samples": null, "burst_ramp_samples": 32, "carrier_frequency_hz": 3500000000.0, "channel_gap_bins": 0, "channel_modulations": [ - 64 + 16 ], "channel_power_db": [ 0.0 ], "channel_subcarriers": [ - 3276 + 612 ], "clip_db": null, "cp_mode": "nr_normal", @@ -91,7 +95,8 @@ "dc_q": 0.0, "dft_spreading": false, "duration_ms": 1.0, - "fft_size": 4096, + "fft_size": 1024, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -100,7 +105,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 30720, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -110,12 +115,12 @@ "pilot_indices": [], "pilot_mode": "comb", "pilot_spacing": 12, - "preset_id": "nr-100", + "preset_id": "nr-fr1-30-20-q16-c1", "psk_order": 8, "rms": 0.2, "rrc_rolloff": 0.25, "rrc_span_symbols": 10, - "sample_rate_hz": 491520000.0, + "sample_rate_hz": 122880000.0, "samples_per_symbol": 8, "seed": 42, "shared_channel_settings": true, @@ -125,27 +130,32 @@ "version": "signal-generator-v1", "waveform": "ofdm" }, - "description": "30 kHz SCS \u00b7 normal CP \u00b7 64-QAM", + "description": "30 kHz SCS \u00b7 normal CP \u00b7 51 RB per channel \u00b7 uncoded payload", "family": "nr", - "label": "FR1 \u00b7 100 MHz", - "preset_id": "nr-100" + "label": "FR1 \u00b7 20 MHz \u00b7 16-QAM \u00b7 1 ch", + "numerology": "FR1 \u00b7 30 kHz", + "preset_id": "nr-fr1-30-20-q16-c1" }, { + "channel_count": 2, "config": { - "bandwidth_hz": 100000000.0, + "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, "burst_on_samples": null, "burst_ramp_samples": 32, - "carrier_frequency_hz": 28000000000.0, + "carrier_frequency_hz": 3500000000.0, "channel_gap_bins": 0, "channel_modulations": [ + 64, 64 ], "channel_power_db": [ + 0.0, 0.0 ], "channel_subcarriers": [ - 792 + 300, + 300 ], "clip_db": null, "cp_mode": "nr_normal", @@ -156,6 +166,7 @@ "dft_spreading": false, "duration_ms": 1.0, "fft_size": 1024, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -164,7 +175,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 30720, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -174,12 +185,12 @@ "pilot_indices": [], "pilot_mode": "comb", "pilot_spacing": 12, - "preset_id": "nr-fr2", + "preset_id": "nr-fr1-30-20-q64-c2", "psk_order": 8, "rms": 0.2, "rrc_rolloff": 0.25, "rrc_span_symbols": 10, - "sample_rate_hz": 491520000.0, + "sample_rate_hz": 122880000.0, "samples_per_symbol": 8, "seed": 42, "shared_channel_settings": true, @@ -189,37 +200,40 @@ "version": "signal-generator-v1", "waveform": "ofdm" }, - "description": "120 kHz SCS \u00b7 normal CP \u00b7 64-QAM", + "description": "30 kHz SCS \u00b7 normal CP \u00b7 25 RB per channel \u00b7 uncoded payload", "family": "nr", - "label": "FR2 \u00b7 100 MHz", - "preset_id": "nr-fr2" + "label": "FR1 \u00b7 20 MHz \u00b7 64-QAM \u00b7 2 ch", + "numerology": "FR1 \u00b7 30 kHz", + "preset_id": "nr-fr1-30-20-q64-c2" }, { + "channel_count": 1, "config": { - "bandwidth_hz": 20000000.0, + "bandwidth_hz": 100000000.0, "burst_off_samples": 1024, "burst_on_samples": null, "burst_ramp_samples": 32, - "carrier_frequency_hz": 5800000000.0, + "carrier_frequency_hz": 3500000000.0, "channel_gap_bins": 0, "channel_modulations": [ - 1024 + 16 ], "channel_power_db": [ 0.0 ], "channel_subcarriers": [ - 242 + 3276 ], "clip_db": null, - "cp_mode": "fixed", + "cp_mode": "nr_normal", "cp_samples": 16, "dc_i": 0.0, "dc_null": true, "dc_q": 0.0, "dft_spreading": false, "duration_ms": 1.0, - "fft_size": 256, + "fft_size": 4096, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -228,7 +242,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 122880, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -237,13 +251,13 @@ "pilot_boost_db": 0.0, "pilot_indices": [], "pilot_mode": "comb", - "pilot_spacing": 16, - "preset_id": "wifi6-20", + "pilot_spacing": 12, + "preset_id": "nr-fr1-30-100-q16-c1", "psk_order": 8, "rms": 0.2, "rrc_rolloff": 0.25, "rrc_span_symbols": 10, - "sample_rate_hz": 80000000.0, + "sample_rate_hz": 491520000.0, "samples_per_symbol": 8, "seed": 42, "shared_channel_settings": true, @@ -253,37 +267,40 @@ "version": "signal-generator-v1", "waveform": "ofdm" }, - "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard interval \u00b7 continuous OFDM payload", - "family": "wifi6", - "label": "20 MHz \u00b7 1024-QAM", - "preset_id": "wifi6-20" + "description": "30 kHz SCS \u00b7 normal CP \u00b7 273 RB per channel \u00b7 uncoded payload", + "family": "nr", + "label": "FR1 \u00b7 100 MHz \u00b7 16-QAM \u00b7 1 ch", + "numerology": "FR1 \u00b7 30 kHz", + "preset_id": "nr-fr1-30-100-q16-c1" }, { + "channel_count": 1, "config": { - "bandwidth_hz": 40000000.0, + "bandwidth_hz": 100000000.0, "burst_off_samples": 1024, "burst_on_samples": null, "burst_ramp_samples": 32, - "carrier_frequency_hz": 5800000000.0, + "carrier_frequency_hz": 3500000000.0, "channel_gap_bins": 0, "channel_modulations": [ - 1024 + 64 ], "channel_power_db": [ 0.0 ], "channel_subcarriers": [ - 484 + 3276 ], "clip_db": null, - "cp_mode": "fixed", - "cp_samples": 32, + "cp_mode": "nr_normal", + "cp_samples": 16, "dc_i": 0.0, "dc_null": true, "dc_q": 0.0, "dft_spreading": false, "duration_ms": 1.0, - "fft_size": 512, + "fft_size": 4096, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -292,7 +309,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 122880, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -301,13 +318,13 @@ "pilot_boost_db": 0.0, "pilot_indices": [], "pilot_mode": "comb", - "pilot_spacing": 16, - "preset_id": "wifi6-40", + "pilot_spacing": 12, + "preset_id": "nr-100", "psk_order": 8, "rms": 0.2, "rrc_rolloff": 0.25, "rrc_span_symbols": 10, - "sample_rate_hz": 160000000.0, + "sample_rate_hz": 491520000.0, "samples_per_symbol": 8, "seed": 42, "shared_channel_settings": true, @@ -317,37 +334,43 @@ "version": "signal-generator-v1", "waveform": "ofdm" }, - "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard interval \u00b7 continuous OFDM payload", - "family": "wifi6", - "label": "40 MHz \u00b7 1024-QAM", - "preset_id": "wifi6-40" + "description": "30 kHz SCS \u00b7 normal CP \u00b7 273 RB per channel \u00b7 uncoded payload", + "family": "nr", + "label": "FR1 \u00b7 100 MHz \u00b7 64-QAM \u00b7 1 ch", + "numerology": "FR1 \u00b7 30 kHz", + "preset_id": "nr-100" }, { + "channel_count": 2, "config": { - "bandwidth_hz": 80000000.0, + "bandwidth_hz": 100000000.0, "burst_off_samples": 1024, "burst_on_samples": null, "burst_ramp_samples": 32, - "carrier_frequency_hz": 5800000000.0, + "carrier_frequency_hz": 3500000000.0, "channel_gap_bins": 0, "channel_modulations": [ - 1024 + 64, + 64 ], "channel_power_db": [ + 0.0, 0.0 ], "channel_subcarriers": [ - 996 + 1632, + 1632 ], "clip_db": null, - "cp_mode": "fixed", - "cp_samples": 64, + "cp_mode": "nr_normal", + "cp_samples": 16, "dc_i": 0.0, "dc_null": true, "dc_q": 0.0, "dft_spreading": false, "duration_ms": 1.0, - "fft_size": 1024, + "fft_size": 4096, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -356,7 +379,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 122880, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -365,13 +388,13 @@ "pilot_boost_db": 0.0, "pilot_indices": [], "pilot_mode": "comb", - "pilot_spacing": 16, - "preset_id": "wifi6-80", + "pilot_spacing": 12, + "preset_id": "nr-fr1-30-100-q64-c2", "psk_order": 8, "rms": 0.2, "rrc_rolloff": 0.25, "rrc_span_symbols": 10, - "sample_rate_hz": 320000000.0, + "sample_rate_hz": 491520000.0, "samples_per_symbol": 8, "seed": 42, "shared_channel_settings": true, @@ -381,40 +404,40 @@ "version": "signal-generator-v1", "waveform": "ofdm" }, - "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard interval \u00b7 continuous OFDM payload", - "family": "wifi6", - "label": "80 MHz \u00b7 1024-QAM", - "preset_id": "wifi6-80" + "description": "30 kHz SCS \u00b7 normal CP \u00b7 136 RB per channel \u00b7 uncoded payload", + "family": "nr", + "label": "FR1 \u00b7 100 MHz \u00b7 64-QAM \u00b7 2 ch", + "numerology": "FR1 \u00b7 30 kHz", + "preset_id": "nr-fr1-30-100-q64-c2" }, { + "channel_count": 1, "config": { - "bandwidth_hz": 160000000.0, + "bandwidth_hz": 100000000.0, "burst_off_samples": 1024, "burst_on_samples": null, "burst_ramp_samples": 32, - "carrier_frequency_hz": 5800000000.0, - "channel_gap_bins": 4, + "carrier_frequency_hz": 28000000000.0, + "channel_gap_bins": 0, "channel_modulations": [ - 1024, - 1024 + 64 ], "channel_power_db": [ - 0.0, 0.0 ], "channel_subcarriers": [ - 996, - 996 + 792 ], "clip_db": null, - "cp_mode": "fixed", - "cp_samples": 128, + "cp_mode": "nr_normal", + "cp_samples": 16, "dc_i": 0.0, "dc_null": true, "dc_q": 0.0, "dft_spreading": false, "duration_ms": 1.0, - "fft_size": 2048, + "fft_size": 1024, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -423,7 +446,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 122880, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -432,13 +455,13 @@ "pilot_boost_db": 0.0, "pilot_indices": [], "pilot_mode": "comb", - "pilot_spacing": 16, - "preset_id": "wifi6-160", + "pilot_spacing": 12, + "preset_id": "nr-fr2", "psk_order": 8, "rms": 0.2, "rrc_rolloff": 0.25, "rrc_span_symbols": 10, - "sample_rate_hz": 640000000.0, + "sample_rate_hz": 491520000.0, "samples_per_symbol": 8, "seed": 42, "shared_channel_settings": true, @@ -448,21 +471,23 @@ "version": "signal-generator-v1", "waveform": "ofdm" }, - "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard interval \u00b7 continuous OFDM payload", - "family": "wifi6", - "label": "160 MHz \u00b7 1024-QAM", - "preset_id": "wifi6-160" + "description": "120 kHz SCS \u00b7 normal CP \u00b7 66 RB per channel \u00b7 uncoded payload", + "family": "nr", + "label": "FR2-1 \u00b7 100 MHz \u00b7 64-QAM \u00b7 1 ch", + "numerology": "FR2-1 \u00b7 120 kHz", + "preset_id": "nr-fr2" }, { + "channel_count": 1, "config": { "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, "burst_on_samples": null, "burst_ramp_samples": 32, - "carrier_frequency_hz": 6100000000.0, + "carrier_frequency_hz": 5800000000.0, "channel_gap_bins": 0, "channel_modulations": [ - 4096 + 1024 ], "channel_power_db": [ 0.0 @@ -479,6 +504,7 @@ "dft_spreading": false, "duration_ms": 1.0, "fft_size": 256, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -487,7 +513,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 20000, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -497,7 +523,7 @@ "pilot_indices": [], "pilot_mode": "comb", "pilot_spacing": 16, - "preset_id": "wifi7-20", + "preset_id": "wifi6-20", "psk_order": 8, "rms": 0.2, "rrc_rolloff": 0.25, @@ -512,85 +538,23 @@ "version": "signal-generator-v1", "waveform": "ofdm" }, - "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard interval \u00b7 continuous OFDM payload", - "family": "wifi7", - "label": "20 MHz \u00b7 4096-QAM", - "preset_id": "wifi7-20" - }, - { - "config": { - "bandwidth_hz": 40000000.0, - "burst_off_samples": 1024, - "burst_on_samples": null, - "burst_ramp_samples": 32, - "carrier_frequency_hz": 6100000000.0, - "channel_gap_bins": 0, - "channel_modulations": [ - 4096 - ], - "channel_power_db": [ - 0.0 - ], - "channel_subcarriers": [ - 484 - ], - "clip_db": null, - "cp_mode": "fixed", - "cp_samples": 32, - "dc_i": 0.0, - "dc_null": true, - "dc_q": 0.0, - "dft_spreading": false, - "duration_ms": 1.0, - "fft_size": 512, - "frequency_offset_hz": 0.0, - "fsk_deviation_hz": 1000000.0, - "gaussian_bt": 0.5, - "iq_gain_db": 0.0, - "iq_phase_deg": 0.0, - "length_mode": "samples", - "modulation_order": 64, - "multitone_phase": "random", - "n_samples": 131072, - "oversampling": 4, - "payload_bits": "00110101", - "payload_mode": "random", - "phase_noise_rms_deg": 0.0, - "phase_offset_deg": 0.0, - "pilot_boost_db": 0.0, - "pilot_indices": [], - "pilot_mode": "comb", - "pilot_spacing": 16, - "preset_id": "wifi7-40", - "psk_order": 8, - "rms": 0.2, - "rrc_rolloff": 0.25, - "rrc_span_symbols": 10, - "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, - "version": "signal-generator-v1", - "waveform": "ofdm" - }, - "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard interval \u00b7 continuous OFDM payload", - "family": "wifi7", - "label": "40 MHz \u00b7 4096-QAM", - "preset_id": "wifi7-40" + "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard \u00b7 242 tones per channel \u00b7 uncoded payload", + "family": "wifi6", + "label": "20 MHz \u00b7 1024-QAM \u00b7 1 ch", + "numerology": "HE \u00b7 78.125 kHz", + "preset_id": "wifi6-20" }, { + "channel_count": 1, "config": { "bandwidth_hz": 80000000.0, "burst_off_samples": 1024, "burst_on_samples": null, "burst_ramp_samples": 32, - "carrier_frequency_hz": 6100000000.0, + "carrier_frequency_hz": 5800000000.0, "channel_gap_bins": 0, "channel_modulations": [ - 4096 + 1024 ], "channel_power_db": [ 0.0 @@ -607,6 +571,7 @@ "dft_spreading": false, "duration_ms": 1.0, "fft_size": 1024, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -615,7 +580,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 80000, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -625,7 +590,7 @@ "pilot_indices": [], "pilot_mode": "comb", "pilot_spacing": 16, - "preset_id": "wifi7-80", + "preset_id": "wifi6-80", "psk_order": 8, "rms": 0.2, "rrc_rolloff": 0.25, @@ -640,40 +605,40 @@ "version": "signal-generator-v1", "waveform": "ofdm" }, - "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard interval \u00b7 continuous OFDM payload", - "family": "wifi7", - "label": "80 MHz \u00b7 4096-QAM", - "preset_id": "wifi7-80" + "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard \u00b7 996 tones per channel \u00b7 uncoded payload", + "family": "wifi6", + "label": "80 MHz \u00b7 1024-QAM \u00b7 1 ch", + "numerology": "HE \u00b7 78.125 kHz", + "preset_id": "wifi6-80" }, { + "channel_count": 1, "config": { - "bandwidth_hz": 160000000.0, + "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, "burst_on_samples": null, "burst_ramp_samples": 32, "carrier_frequency_hz": 6100000000.0, - "channel_gap_bins": 4, + "channel_gap_bins": 0, "channel_modulations": [ - 4096, 4096 ], "channel_power_db": [ - 0.0, 0.0 ], "channel_subcarriers": [ - 996, - 996 + 242 ], "clip_db": null, "cp_mode": "fixed", - "cp_samples": 128, + "cp_samples": 16, "dc_i": 0.0, "dc_null": true, "dc_q": 0.0, "dft_spreading": false, "duration_ms": 1.0, - "fft_size": 2048, + "fft_size": 256, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -682,7 +647,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 20000, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -692,12 +657,12 @@ "pilot_indices": [], "pilot_mode": "comb", "pilot_spacing": 16, - "preset_id": "wifi7-160", + "preset_id": "wifi7-20", "psk_order": 8, "rms": 0.2, "rrc_rolloff": 0.25, "rrc_span_symbols": 10, - "sample_rate_hz": 640000000.0, + "sample_rate_hz": 80000000.0, "samples_per_symbol": 8, "seed": 42, "shared_channel_settings": true, @@ -707,36 +672,29 @@ "version": "signal-generator-v1", "waveform": "ofdm" }, - "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard interval \u00b7 continuous OFDM payload", + "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard \u00b7 242 tones per channel \u00b7 uncoded payload", "family": "wifi7", - "label": "160 MHz \u00b7 4096-QAM", - "preset_id": "wifi7-160" + "label": "20 MHz \u00b7 4096-QAM \u00b7 1 ch", + "numerology": "EHT \u00b7 78.125 kHz", + "preset_id": "wifi7-20" }, { + "channel_count": 1, "config": { "bandwidth_hz": 320000000.0, "burst_off_samples": 1024, "burst_on_samples": null, "burst_ramp_samples": 32, "carrier_frequency_hz": 6100000000.0, - "channel_gap_bins": 4, + "channel_gap_bins": 0, "channel_modulations": [ - 4096, - 4096, - 4096, 4096 ], "channel_power_db": [ - 0.0, - 0.0, - 0.0, 0.0 ], "channel_subcarriers": [ - 996, - 996, - 996, - 996 + 3984 ], "clip_db": null, "cp_mode": "fixed", @@ -747,6 +705,7 @@ "dft_spreading": false, "duration_ms": 1.0, "fft_size": 4096, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -755,7 +714,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 196608, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -780,216 +739,14 @@ "version": "signal-generator-v1", "waveform": "ofdm" }, - "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard interval \u00b7 continuous OFDM payload", + "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard \u00b7 3984 tones per channel \u00b7 uncoded payload", "family": "wifi7", - "label": "320 MHz \u00b7 4096-QAM", + "label": "320 MHz \u00b7 4096-QAM \u00b7 1 ch", + "numerology": "EHT \u00b7 78.125 kHz", "preset_id": "wifi7-320" }, { - "config": { - "bandwidth_hz": 80000000.0, - "burst_off_samples": 1024, - "burst_on_samples": null, - "burst_ramp_samples": 32, - "carrier_frequency_hz": 6100000000.0, - "channel_gap_bins": 0, - "channel_modulations": [ - 4096 - ], - "channel_power_db": [ - 0.0 - ], - "channel_subcarriers": [ - 996 - ], - "clip_db": null, - "cp_mode": "fixed", - "cp_samples": 64, - "dc_i": 0.0, - "dc_null": true, - "dc_q": 0.0, - "dft_spreading": false, - "duration_ms": 1.0, - "fft_size": 1024, - "frequency_offset_hz": 0.0, - "fsk_deviation_hz": 1000000.0, - "gaussian_bt": 0.5, - "iq_gain_db": 0.0, - "iq_phase_deg": 0.0, - "length_mode": "samples", - "modulation_order": 64, - "multitone_phase": "random", - "n_samples": 131072, - "oversampling": 4, - "payload_bits": "00110101", - "payload_mode": "random", - "phase_noise_rms_deg": 0.0, - "phase_offset_deg": 0.0, - "pilot_boost_db": 0.0, - "pilot_indices": [], - "pilot_mode": "comb", - "pilot_spacing": 16, - "preset_id": "wifi8-80", - "psk_order": 8, - "rms": 0.2, - "rrc_rolloff": 0.25, - "rrc_span_symbols": 10, - "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, - "version": "signal-generator-v1", - "waveform": "ofdm" - }, - "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard interval \u00b7 continuous OFDM payload", - "family": "wifi8", - "label": "80 MHz \u00b7 4096-QAM", - "preset_id": "wifi8-80" - }, - { - "config": { - "bandwidth_hz": 160000000.0, - "burst_off_samples": 1024, - "burst_on_samples": null, - "burst_ramp_samples": 32, - "carrier_frequency_hz": 6100000000.0, - "channel_gap_bins": 4, - "channel_modulations": [ - 4096, - 4096 - ], - "channel_power_db": [ - 0.0, - 0.0 - ], - "channel_subcarriers": [ - 996, - 996 - ], - "clip_db": null, - "cp_mode": "fixed", - "cp_samples": 128, - "dc_i": 0.0, - "dc_null": true, - "dc_q": 0.0, - "dft_spreading": false, - "duration_ms": 1.0, - "fft_size": 2048, - "frequency_offset_hz": 0.0, - "fsk_deviation_hz": 1000000.0, - "gaussian_bt": 0.5, - "iq_gain_db": 0.0, - "iq_phase_deg": 0.0, - "length_mode": "samples", - "modulation_order": 64, - "multitone_phase": "random", - "n_samples": 131072, - "oversampling": 4, - "payload_bits": "00110101", - "payload_mode": "random", - "phase_noise_rms_deg": 0.0, - "phase_offset_deg": 0.0, - "pilot_boost_db": 0.0, - "pilot_indices": [], - "pilot_mode": "comb", - "pilot_spacing": 16, - "preset_id": "wifi8-160", - "psk_order": 8, - "rms": 0.2, - "rrc_rolloff": 0.25, - "rrc_span_symbols": 10, - "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, - "version": "signal-generator-v1", - "waveform": "ofdm" - }, - "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard interval \u00b7 continuous OFDM payload", - "family": "wifi8", - "label": "160 MHz \u00b7 4096-QAM", - "preset_id": "wifi8-160" - }, - { - "config": { - "bandwidth_hz": 320000000.0, - "burst_off_samples": 1024, - "burst_on_samples": null, - "burst_ramp_samples": 32, - "carrier_frequency_hz": 6100000000.0, - "channel_gap_bins": 4, - "channel_modulations": [ - 4096, - 4096, - 4096, - 4096 - ], - "channel_power_db": [ - 0.0, - 0.0, - 0.0, - 0.0 - ], - "channel_subcarriers": [ - 996, - 996, - 996, - 996 - ], - "clip_db": null, - "cp_mode": "fixed", - "cp_samples": 256, - "dc_i": 0.0, - "dc_null": true, - "dc_q": 0.0, - "dft_spreading": false, - "duration_ms": 1.0, - "fft_size": 4096, - "frequency_offset_hz": 0.0, - "fsk_deviation_hz": 1000000.0, - "gaussian_bt": 0.5, - "iq_gain_db": 0.0, - "iq_phase_deg": 0.0, - "length_mode": "samples", - "modulation_order": 64, - "multitone_phase": "random", - "n_samples": 131072, - "oversampling": 4, - "payload_bits": "00110101", - "payload_mode": "random", - "phase_noise_rms_deg": 0.0, - "phase_offset_deg": 0.0, - "pilot_boost_db": 0.0, - "pilot_indices": [], - "pilot_mode": "comb", - "pilot_spacing": 16, - "preset_id": "wifi8-320", - "psk_order": 8, - "rms": 0.2, - "rrc_rolloff": 0.25, - "rrc_span_symbols": 10, - "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, - "version": "signal-generator-v1", - "waveform": "ofdm" - }, - "description": "78.125 kHz SCS \u00b7 0.8 \u00b5s guard interval \u00b7 continuous OFDM payload", - "family": "wifi8", - "label": "320 MHz \u00b7 4096-QAM", - "preset_id": "wifi8-320" - }, - { + "channel_count": 1, "config": { "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, @@ -1015,6 +772,7 @@ "dft_spreading": false, "duration_ms": 1.0, "fft_size": 256, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -1023,7 +781,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 20000, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -1051,9 +809,11 @@ "description": "Independent channel allocations, pilots and modulation", "family": "custom", "label": "Custom OFDM / OFDMA", + "numerology": "Custom", "preset_id": "custom-ofdm" }, { + "channel_count": 1, "config": { "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, @@ -1079,6 +839,7 @@ "dft_spreading": false, "duration_ms": 1.0, "fft_size": 256, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -1087,7 +848,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 20000, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -1115,9 +876,11 @@ "description": "Root-raised-cosine pulse shaping", "family": "custom", "label": "Single-carrier QAM / PSK", + "numerology": "Custom", "preset_id": "custom-qam" }, { + "channel_count": 1, "config": { "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, @@ -1143,6 +906,7 @@ "dft_spreading": false, "duration_ms": 1.0, "fft_size": 256, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -1151,7 +915,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 20000, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -1179,9 +943,11 @@ "description": "Complex sinusoid for gain and phase checks", "family": "custom", "label": "Single tone", + "numerology": "Custom", "preset_id": "custom-tone" }, { + "channel_count": 1, "config": { "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, @@ -1207,6 +973,7 @@ "dft_spreading": false, "duration_ms": 1.0, "fft_size": 256, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -1215,7 +982,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 20000, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -1243,9 +1010,11 @@ "description": "Equally spaced tones with seeded random phases", "family": "custom", "label": "Multitone", + "numerology": "Custom", "preset_id": "custom-multitone" }, { + "channel_count": 1, "config": { "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, @@ -1271,6 +1040,7 @@ "dft_spreading": false, "duration_ms": 1.0, "fft_size": 256, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -1279,7 +1049,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 20000, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -1307,9 +1077,11 @@ "description": "Complex baseband frequency sweep", "family": "custom", "label": "Linear chirp", + "numerology": "Custom", "preset_id": "custom-chirp" }, { + "channel_count": 1, "config": { "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, @@ -1335,6 +1107,7 @@ "dft_spreading": false, "duration_ms": 1.0, "fft_size": 256, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -1343,7 +1116,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 20000, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -1371,9 +1144,11 @@ "description": "Gray-labeled phase modulation with RRC pulse shaping", "family": "custom", "label": "8-PSK", + "numerology": "Custom", "preset_id": "custom-psk" }, { + "channel_count": 1, "config": { "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, @@ -1399,6 +1174,7 @@ "dft_spreading": false, "duration_ms": 1.0, "fft_size": 256, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -1407,7 +1183,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 20000, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -1435,9 +1211,11 @@ "description": "Continuous-phase frequency modulation", "family": "custom", "label": "Binary FSK", + "numerology": "Custom", "preset_id": "custom-fsk" }, { + "channel_count": 1, "config": { "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, @@ -1463,6 +1241,7 @@ "dft_spreading": false, "duration_ms": 1.0, "fft_size": 256, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -1471,7 +1250,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 20000, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -1499,9 +1278,11 @@ "description": "Continuous phase \u00b7 configurable Gaussian BT", "family": "custom", "label": "Gaussian FSK", + "numerology": "Custom", "preset_id": "custom-gfsk" }, { + "channel_count": 1, "config": { "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, @@ -1527,6 +1308,7 @@ "dft_spreading": false, "duration_ms": 1.0, "fft_size": 256, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -1535,7 +1317,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 20000, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -1563,9 +1345,11 @@ "description": "Complex Gaussian noise for spectral loading", "family": "custom", "label": "Band-limited noise", + "numerology": "Custom", "preset_id": "custom-noise" }, { + "channel_count": 1, "config": { "bandwidth_hz": 20000000.0, "burst_off_samples": 1024, @@ -1591,6 +1375,7 @@ "dft_spreading": true, "duration_ms": 1.0, "fft_size": 256, + "filter_enabled": true, "frequency_offset_hz": 0.0, "fsk_deviation_hz": 1000000.0, "gaussian_bt": 0.5, @@ -1599,7 +1384,7 @@ "length_mode": "samples", "modulation_order": 64, "multitone_phase": "random", - "n_samples": 131072, + "n_samples": 20000, "oversampling": 4, "payload_bits": "00110101", "payload_mode": "random", @@ -1627,6 +1412,7 @@ "description": "Single-carrier-like envelope \u00b7 uncoded uplink stimulus", "family": "custom", "label": "DFT-spread OFDM", + "numerology": "Custom", "preset_id": "custom-dft-ofdm" } ] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d690dee..9ef3f12 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "opendpd-studio", - "version": "2.2.10", + "version": "2.2.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opendpd-studio", - "version": "2.2.10", + "version": "2.2.11", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", diff --git a/frontend/package.json b/frontend/package.json index 382e76d..9a2fcc4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "opendpd-studio", "private": true, - "version": "2.2.10", + "version": "2.2.11", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index af73ddf..0a02fa4 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -425,6 +425,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/datasets/{dataset_id}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Dataset Download */ + get: operations["dataset_download_api_v1_datasets__dataset_id__download_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/datasets/{dataset_id}/manifest": { parameters: { query?: never; @@ -835,6 +852,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/pa-library/datasets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Simulate Dataset */ + post: operations["simulate_dataset_api_v1_pa_library_datasets_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/pa-library/models": { parameters: { query?: never; @@ -1432,6 +1466,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/signal-generator/batches": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Generate Batch */ + post: operations["generate_batch_api_v1_signal_generator_batches_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/signal-generator/presets": { parameters: { query?: never; @@ -2764,6 +2815,21 @@ export interface components { */ version: "dataset-inspection-v1"; }; + /** DatasetCapture */ + DatasetCapture: { + /** Bandwidth Hz */ + bandwidth_hz: number; + /** Dataset Id */ + dataset_id: string; + /** Label */ + label: string; + /** N Samples */ + n_samples: number; + /** Preset Id */ + preset_id: string; + /** Sample Rate Hz */ + sample_rate_hz: number; + }; /** DatasetEvidence */ DatasetEvidence: { /** Dataset Id */ @@ -2798,6 +2864,8 @@ export interface components { }; /** DatasetManifest */ DatasetManifest: { + /** Captures */ + captures?: components["schemas"]["DatasetCapture"][]; /** Columns */ columns?: { [key: string]: string; @@ -2813,6 +2881,8 @@ export interface components { /** Notes */ notes?: string | null; origin: components["schemas"]["DatasetOrigin"]; + /** Parent Dataset Id */ + parent_dataset_id?: string | null; /** * Preprocessing Version * @default raw-v1 @@ -3717,6 +3787,11 @@ export interface components { /** Useful Symbol Us */ useful_symbol_us: number | null; }; + /** GeneratorBatchRequest */ + GeneratorBatchRequest: { + /** Configs */ + configs: components["schemas"]["GeneratorConfig"][]; + }; /** GeneratorConfig */ GeneratorConfig: { /** @@ -3795,6 +3870,11 @@ export interface components { * @default 256 */ fft_size: number; + /** + * Filter Enabled + * @default true + */ + filter_enabled: boolean; /** * Frequency Offset Hz * @default 0 @@ -4014,6 +4094,11 @@ export interface components { }; /** GeneratorPreset */ GeneratorPreset: { + /** + * Channel Count + * @default 1 + */ + channel_count: number; config: components["schemas"]["GeneratorConfig"]; /** Description */ description: string; @@ -4021,9 +4106,14 @@ export interface components { * Family * @enum {string} */ - family: "nr" | "wifi6" | "wifi7" | "wifi8" | "custom"; + family: "nr" | "wifi6" | "wifi7" | "custom"; /** Label */ label: string; + /** + * Numerology + * @default Custom + */ + numerology: string; /** Preset Id */ preset_id: string; }; @@ -5784,7 +5874,7 @@ export interface components { csrf_token?: string | null; /** * Version - * @default 2.2.10 + * @default 2.2.11 */ version: string; }; @@ -6553,6 +6643,17 @@ export interface components { */ status: "bit_exact" | "mismatch" | "not_run"; }; + /** VirtualPADatasetRequest */ + VirtualPADatasetRequest: { + /** Input Signal Ids */ + input_signal_ids: string[]; + /** Model Id */ + model_id: string; + /** Parameters */ + parameters?: { + [key: string]: number; + }; + }; /** VirtualPAModel */ VirtualPAModel: { /** @@ -6593,6 +6694,8 @@ export interface components { config: components["schemas"]["VirtualPARequest"]; /** Input Iq Sha256 */ input_iq_sha256: string; + /** Kernel Sha256 */ + kernel_sha256?: string | null; /** * Kind * @default simulated_pa_output @@ -7473,6 +7576,40 @@ export interface operations { }; }; }; + dataset_download_api_v1_datasets__dataset_id__download_get: { + parameters: { + query?: { + version?: string; + collection?: boolean; + }; + header?: never; + path: { + dataset_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_update_api_v1_datasets__dataset_id__manifest_post: { parameters: { query?: never; @@ -8296,6 +8433,39 @@ export interface operations { }; }; }; + simulate_dataset_api_v1_pa_library_datasets_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VirtualPADatasetRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GeneratorDatasetResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; models_api_v1_pa_library_models_get: { parameters: { query?: never; @@ -9374,6 +9544,39 @@ export interface operations { }; }; }; + generate_batch_api_v1_signal_generator_batches_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GeneratorBatchRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PAInputDataset"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_presets_api_v1_signal_generator_presets_get: { parameters: { query?: never; diff --git a/frontend/src/api/signalGenerator.ts b/frontend/src/api/signalGenerator.ts index cb95638..b6a75b1 100644 --- a/frontend/src/api/signalGenerator.ts +++ b/frontend/src/api/signalGenerator.ts @@ -14,3 +14,9 @@ export function useGenerateSignal() { return useMutation({ mutationFn: (config: GeneratorConfig) => api.post('/signal-generator/signals', config), onSuccess: () => void qc.invalidateQueries({ queryKey: ['pa-inputs'] }) }) } + +export function useGenerateBatch() { + const qc = useQueryClient() + return useMutation({ mutationFn: (configs: GeneratorConfig[]) => api.post('/signal-generator/batches', { configs }), + onSuccess: () => void qc.invalidateQueries({ queryKey: ['pa-inputs'] }) }) +} diff --git a/frontend/src/api/virtualPA.ts b/frontend/src/api/virtualPA.ts index 7ae1d75..f85546c 100644 --- a/frontend/src/api/virtualPA.ts +++ b/frontend/src/api/virtualPA.ts @@ -24,3 +24,10 @@ export function usePairedDataset() { api.post('/pa-library/simulations/' + encodeURIComponent(id) + '/dataset', request), onSuccess: () => void qc.invalidateQueries({ queryKey: keys.datasets }) }) } + +export function useSimulateDataset() { + const qc = useQueryClient() + return useMutation({ mutationFn: (request: Schemas['VirtualPADatasetRequest']) => + api.post('/pa-library/datasets', request), + onSuccess: () => void qc.invalidateQueries({ queryKey: keys.datasets }) }) +} diff --git a/frontend/src/components/PresetMatrix.tsx b/frontend/src/components/PresetMatrix.tsx new file mode 100644 index 0000000..433d1c6 --- /dev/null +++ b/frontend/src/components/PresetMatrix.tsx @@ -0,0 +1,59 @@ +import CheckIcon from '@mui/icons-material/Check' +import AddIcon from '@mui/icons-material/Add' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import ButtonBase from '@mui/material/ButtonBase' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { useState } from 'react' +import type { GeneratorPreset } from '@/api/signalGenerator' +import { t } from '@/i18n' +import { useStudioColors } from '@/theme' + +export function PresetMatrix({ presets, selected, disabled, toggle }: { + presets: GeneratorPreset[]; selected: string[]; disabled: boolean; toggle: (preset: GeneratorPreset) => void +}) { + const colors = useStudioColors() + const groups = [...new Set(presets.map(p => p.numerology ?? 'OFDM'))] + const [choice, setChoice] = useState(groups[0]) + const group = groups.includes(choice ?? '') ? choice : groups[0] + const visible = presets.filter(p => (p.numerology ?? 'OFDM') === group) + const channels = [...new Set(visible.map(p => p.channel_count ?? p.config.channel_subcarriers?.length ?? 1))].sort((a, b) => a-b) + return + {groups.length > 1 && + {groups.map(g => )} + } + {t('generator.matrixHelp')} + + {channels.map(count => { + const entries = visible.filter(p => (p.channel_count ?? p.config.channel_subcarriers?.length ?? 1) === count) + const bands = [...new Set(entries.map(p => p.config.bandwidth_hz))].sort((a,b) => (a ?? 0)-(b ?? 0)) + const orders = [...new Set(entries.map(p => p.config.channel_modulations?.[0] ?? 64))].sort((a,b) => a-b) + const label = t('generator.channelGroup', { count }) + return + {label} + + + {t('generator.bandwidthAxis')} → + QAM ↓{bands.map(b => {(b ?? 0) / 1e6})} + {orders.map(order => + {order === 2 ? 'BPSK' : order === 4 ? 'QPSK' : order} + {bands.map(band => { + const p = entries.find(e => e.config.bandwidth_hz === band && (e.config.channel_modulations?.[0] ?? 64) === order) + return {p ? = 16)} + aria-label={p.label + ' · ' + group} title={p.description} aria-pressed={selected.includes(p.preset_id)} + data-testid={'preset-' + p.preset_id} onClick={() => toggle(p)} sx={{ width: '100%', height: 28, border: 1, borderRadius: .75, + borderColor: selected.includes(p.preset_id) ? 'primary.main' : 'divider', + color: selected.includes(p.preset_id) ? 'primary.main' : 'text.secondary', bgcolor: selected.includes(p.preset_id) ? colors.selected : 'background.default', + '&:hover': { borderColor: 'primary.main', bgcolor: colors.selected }, '&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: 1 } }}> + {selected.includes(p.preset_id) ? : } + : } + })} + )} + + + + })} + + +} diff --git a/frontend/src/components/WorkflowProgress.tsx b/frontend/src/components/WorkflowProgress.tsx index 59084ec..f9f5a7b 100644 --- a/frontend/src/components/WorkflowProgress.tsx +++ b/frontend/src/components/WorkflowProgress.tsx @@ -29,27 +29,26 @@ export function WorkflowProgress() { const steps = [ { key: 'input', label: t('paFlow.input'), symbol: 'x[n]', done: supplied || !!state.inputId, route: supplied ? datasetRoute : '/signal-generator' }, { key: 'virtual', label: t('paFlow.virtual'), symbol: 'PA', done: supplied || !!state.modelId, route: supplied ? datasetRoute : '/pa-library' }, - { key: 'output', label: t('paFlow.output'), symbol: 'y[n]', done: supplied || !!state.simulationId, route: supplied ? datasetRoute : '/pa-library' }, - { key: 'paired', label: t('paFlow.paired'), symbol: '(x, y)', done: pair, route: datasetRoute }, + { key: 'output', label: t('paFlow.output'), symbol: 'y[n]', done: pair || !!state.simulationId, route: pair ? datasetRoute : '/pa-library' }, { key: 'pa', label: t('paFlow.trainPA'), symbol: 'PÂ', done: workflow.paDone, route: '/experiments/new?task=train_pa' + (state.datasetId ? '&dataset=' + encodeURIComponent(state.datasetId) : '') }, { key: 'dpd', label: t('paFlow.trainDPD'), symbol: 'DPD', done: workflow.dpdDone, route: '/experiments/new?task=train_dpd' + (state.datasetId ? '&dataset=' + encodeURIComponent(state.datasetId) : '') + (state.paRunId ? '&paRun=' + encodeURIComponent(state.paRunId) : '') }, ] const active = pathname === '/signal-generator' ? 0 : pathname === '/pa-library' ? (state.simulationId ? 2 : 1) - : pathname.startsWith('/datasets') ? 3 : new URLSearchParams(search).get('task')?.includes('dpd') ? 5 - : pathname.startsWith('/experiments') || pathname.startsWith('/runs') ? (state.dpdRunId ? 5 : 4) : -1 - const currentStep = steps[active] ?? steps.find(step => !step.done) ?? steps[5]! + : pathname.startsWith('/datasets') ? 2 : new URLSearchParams(search).get('task')?.includes('dpd') ? 4 + : pathname.startsWith('/experiments') || pathname.startsWith('/runs') ? (state.dpdRunId ? 4 : 3) : -1 + const currentStep = steps[active] ?? steps.find(step => !step.done) ?? steps[4]! const open = (index: number) => { setExpanded(false) const step = steps[index]! - if (index >= 4 && !pair) return + if (index >= 3 && !pair) return navigate(step.route) } const diagram = (large: boolean) => { const width = 990, height = large ? 350 : 86 - const positions = large ? [[90, 91], [300, 91], [510, 91], [735, 91], [280, 258], [680, 258]] - : [[60, 43], [206, 43], [353, 43], [500, 43], [719, 43], [920, 43]] + const positions = large ? [[100, 91], [490, 91], [880, 91], [280, 258], [680, 258]] + : [[70, 43], [290, 43], [510, 43], [715, 43], [920, 43]] return @@ -59,7 +58,7 @@ export function WorkflowProgress() { {t('paFlow.learning')} {steps.slice(1).map((_, index) => { const [x1, y1] = positions[index]!, [x2] = positions[index+1]! - const path = large && index === 3 ? 'M 778 91 H 955 V 167 H 190 V 258 H 235' + const path = large && index === 2 ? 'M 924 91 H 955 V 167 H 190 V 258 H 235' : 'M ' + (x1!+44) + ' ' + y1 + ' H ' + (x2!-45) return })} @@ -67,19 +66,18 @@ export function WorkflowProgress() { const [x, y] = positions[index]! const color = step.done ? colors.primary : colors.textSecondary const fill = step.done ? colors.selected : colors.surface - const enabled = index < 4 || pair + const enabled = index < 3 || pair return open(index)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(index) } }} style={{ cursor: enabled ? 'pointer' : 'default', outlineColor: colors.primary }}> {step.label + ': ' + t(step.done ? 'paFlow.complete' : 'paFlow.pending')} {active === index && } - {index === 1 || index === 4 ? : <> - {index === 3 && } - + } - {step.symbol} + {step.symbol} {step.done && } {step.label} @@ -92,7 +90,7 @@ export function WorkflowProgress() { {diagram(false)} - {currentStep.label} · {steps.filter(step => step.done).length}/6 + {currentStep.label} · {steps.filter(step => step.done).length}/{steps.length} diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index c6b9331..9e3fa88 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -1056,19 +1056,16 @@ "generator.family.nr": "5G NR", "generator.family.wifi6": "Wi-Fi 6", "generator.family.wifi7": "Wi-Fi 7", - "generator.family.wifi8": "Wi-Fi 8", "generator.family.custom": "Custom", "generator.familyHint.nr": "FR1 / FR2", "generator.familyHint.wifi6": "802.11ax", "generator.familyHint.wifi7": "802.11be", - "generator.familyHint.wifi8": "Experimental", "generator.familyHint.custom": "OFDM · QAM · tones", "generator.choose": "Choose a signal family", "generator.setup": "Signal setup", "generator.preset": "Waveform preset", "generator.scope": "Signal coverage & measurement definitions", "generator.scopeHelp": "Synthetic engineering stimuli with NR / WLAN numerology and generic pilots. Continuous uncoded payloads; no complete protocol frames or conformance certification.", - "generator.wifi8Scope": "Wi-Fi 8 is an experimental 802.11bn numerology profile. It uses generic OFDM payloads and pilots; draft-specific UHR features and complete packets are not implemented.", "generator.coverage.numerology": "Numerology preset", "generator.coverage.experimental": "Experimental profile", "generator.coverage.custom": "Custom waveform", @@ -1261,7 +1258,6 @@ "paFlow.input": "PA input", "paFlow.virtual": "Virtual PA", "paFlow.output": "PA output", - "paFlow.paired": "Paired dataset", "paFlow.trainPA": "PA training", "paFlow.trainDPD": "DPD training", "paFlow.complete": "Complete", @@ -1270,7 +1266,7 @@ "paFlow.trainingHelp": "The learned PA surrogate supplies the reference for DPD training.", "paFlow.existingTitle": "Choose an existing paired dataset", "paFlow.existingHelp": "This dataset already supplies paired PA input and output. Dataset making is complete: signal generation and Virtual PA simulation are bypassed. Continue directly with PA model training.", - "paFlow.generatedHelp": "Make x in Signal Generator, choose a Virtual PA and simulate y, then pair x and y. After dataset making, train the PA surrogate and DPD. Checkmarks follow your configuration and successful training runs.", + "paFlow.generatedHelp": "PA-Eingänge erzeugen, virtuellen PA wählen und Ausgänge simulieren. Studio speichert den Datensatz automatisch. Danach PA-Ersatzmodell und DPD trainieren; Häkchen zeigen abgeschlossene Schritte.", "paFlow.begin": "Begin with a signal or an existing paired dataset.", "spectrum.node.dpd_input": "DPD Input", "spectrum.node.pa_input": "PA Input", @@ -1451,5 +1447,24 @@ "generator.evmCarriers": "EVM by data subcarrier", "analyzer.fft": "FFT length", "analyzer.expandSpectrogram": "Expand spectrogram", - "analyzer.timeAxis": "Time (µs)" + "analyzer.timeAxis": "Time (µs)", + "generator.numerology": "Numerologie", + "generator.matrixHelp": "Mehrere Felder auswählen: Bandbreite horizontal, Modulation vertikal; jede Gruppe hat eine andere OFDMA-Kanalzahl.", + "generator.channelGroup": "{count} OFDMA-Kanäle", + "generator.bandwidthAxis": "Basisbandbreite (MHz)", + "generator.selectionCount": "{count} / 16 Voreinstellungen ausgewählt", + "generator.selectedPresets": "Auswahl · zum Bearbeiten oder Anzeigen anklicken", + "generator.editSelected": "Einstellungen der markierten Voreinstellung", + "generator.idealFilter": "Idealer PA-Eingangsfilter (empfohlen)", + "generator.filterHelp": "Steile Bandkanten mit 4 % Kosinus-Übergang. Gilt für diese Voreinstellung; abschaltbar. Kann EVM und Burst-Kanten verändern.", + "generator.totalSamples": "{count} I/Q-Samples insgesamt", + "generator.selectionLimit": "Bis zu 16 Voreinstellungen und insgesamt 4.000.000 I/Q-Samples. Im Web gilt zusätzlich das Speicherlimit.", + "generator.batchNext": "Dies sind nur PA-Eingänge. Wählen Sie einen virtuellen PA; eine Simulation erstellt je Voreinstellung eine CSV mit Ein- und Ausgang.", + "paLibrary.batch": "Alle {count} Voreinstellungen verwenden diesen PA, jeweils mit eigener Abtastrate und anfänglich leerem Speicherzustand.", + "paLibrary.autoDataset": "Die Simulation speichert den Datensatz und öffnet die Details. Standard: 60 % Training, 20 % Validierung, 20 % Test; Schutzabstände von 256 Samples.", + "datasets.capture": "Teildatensatz anzeigen", + "datasets.captureHelp": "Diagramme, Metadaten, Vorverarbeitung und Training verwenden die Auswahl mit eigener Abtastrate und Länge. Die ZIP enthält alle Originaldaten und das PA-Skript.", + "datasets.downloadCsv": "CSV herunterladen", + "datasets.downloadZip": "Alles herunterladen · ZIP", + "generator.removePreset": "Voreinstellung {name} entfernen" } diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 846792e..8fb11b3 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -1056,19 +1056,16 @@ "generator.family.nr": "5G NR", "generator.family.wifi6": "Wi-Fi 6", "generator.family.wifi7": "Wi-Fi 7", - "generator.family.wifi8": "Wi-Fi 8", "generator.family.custom": "Custom", "generator.familyHint.nr": "FR1 / FR2", "generator.familyHint.wifi6": "802.11ax", "generator.familyHint.wifi7": "802.11be", - "generator.familyHint.wifi8": "Experimental", "generator.familyHint.custom": "OFDM · QAM · tones", "generator.choose": "Choose a signal family", "generator.setup": "Signal setup", "generator.preset": "Waveform preset", "generator.scope": "Signal coverage & measurement definitions", "generator.scopeHelp": "Synthetic engineering stimuli with NR / WLAN numerology and generic pilots. Continuous uncoded payloads; no complete protocol frames or conformance certification.", - "generator.wifi8Scope": "Wi-Fi 8 is an experimental 802.11bn numerology profile. It uses generic OFDM payloads and pilots; draft-specific UHR features and complete packets are not implemented.", "generator.coverage.numerology": "Numerology preset", "generator.coverage.experimental": "Experimental profile", "generator.coverage.custom": "Custom waveform", @@ -1261,7 +1258,6 @@ "paFlow.input": "PA input", "paFlow.virtual": "Virtual PA", "paFlow.output": "PA output", - "paFlow.paired": "Paired dataset", "paFlow.trainPA": "PA training", "paFlow.trainDPD": "DPD training", "paFlow.complete": "Complete", @@ -1270,7 +1266,7 @@ "paFlow.trainingHelp": "The learned PA surrogate supplies the reference for DPD training.", "paFlow.existingTitle": "Choose an existing paired dataset", "paFlow.existingHelp": "This dataset already supplies paired PA input and output. Dataset making is complete: signal generation and Virtual PA simulation are bypassed. Continue directly with PA model training.", - "paFlow.generatedHelp": "Make x in Signal Generator, choose a Virtual PA and simulate y, then pair x and y. After dataset making, train the PA surrogate and DPD. Checkmarks follow your configuration and successful training runs.", + "paFlow.generatedHelp": "Make PA inputs in Signal Generator, choose a Virtual PA and simulate the outputs. Studio saves the dataset automatically. Then train the PA surrogate and DPD. Checkmarks follow your configuration and successful training runs.", "paFlow.begin": "Begin with a signal or an existing paired dataset.", "spectrum.node.dpd_input": "DPD Input", "spectrum.node.pa_input": "PA Input", @@ -1451,5 +1447,24 @@ "generator.evmCarriers": "EVM by data subcarrier", "analyzer.fft": "FFT length", "analyzer.expandSpectrogram": "Expand spectrogram", - "analyzer.timeAxis": "Time (µs)" + "analyzer.timeAxis": "Time (µs)", + "generator.numerology": "Numerology", + "generator.matrixHelp": "Pick multiple cells. Columns: baseband bandwidth. Rows: modulation. Each group has a different number of OFDMA channels.", + "generator.channelGroup": "{count} OFDMA channels", + "generator.bandwidthAxis": "Baseband bandwidth (MHz)", + "generator.selectionCount": "{count} / 16 presets selected", + "generator.selectedPresets": "Selected presets · click to edit or preview", + "generator.editSelected": "Settings for the highlighted preset", + "generator.idealFilter": "Ideal PA-input filter (recommended)", + "generator.filterHelp": "Sharp band edges with a 4% cosine transition. Applies to this preset; turn off for an unfiltered waveform. Filtering can change EVM and burst edges.", + "generator.totalSamples": "{count} I/Q samples across selected presets", + "generator.selectionLimit": "Select up to 16 presets and 4,000,000 total I/Q samples. Hosted workspace storage limits also apply.", + "generator.batchNext": "These are PA inputs only. Choose a Virtual PA next; one simulation creates a complete dataset with one input/output CSV per preset.", + "paLibrary.batch": "All {count} selected presets will use this PA. Each keeps its own sample rate and starts with zero memory state.", + "paLibrary.autoDataset": "Simulating also saves the complete dataset and opens its details. Default split: 60% training, 20% validation, 20% testing with 256-sample guards.", + "datasets.capture": "Visualize subdataset", + "datasets.captureHelp": "Charts, metadata, preprocessing and training use the selected subdataset. Each preset retains its own sample rate and length. The collection ZIP contains all original captures and the PA replay script.", + "datasets.downloadCsv": "Download CSV", + "datasets.downloadZip": "Download all · ZIP", + "generator.removePreset": "Remove preset {name}" } diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index 36987b8..21cf223 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -1056,19 +1056,16 @@ "generator.family.nr": "5G NR", "generator.family.wifi6": "Wi-Fi 6", "generator.family.wifi7": "Wi-Fi 7", - "generator.family.wifi8": "Wi-Fi 8", "generator.family.custom": "Custom", "generator.familyHint.nr": "FR1 / FR2", "generator.familyHint.wifi6": "802.11ax", "generator.familyHint.wifi7": "802.11be", - "generator.familyHint.wifi8": "Experimental", "generator.familyHint.custom": "OFDM · QAM · tones", "generator.choose": "Choose a signal family", "generator.setup": "Signal setup", "generator.preset": "Waveform preset", "generator.scope": "Signal coverage & measurement definitions", "generator.scopeHelp": "Synthetic engineering stimuli with NR / WLAN numerology and generic pilots. Continuous uncoded payloads; no complete protocol frames or conformance certification.", - "generator.wifi8Scope": "Wi-Fi 8 is an experimental 802.11bn numerology profile. It uses generic OFDM payloads and pilots; draft-specific UHR features and complete packets are not implemented.", "generator.coverage.numerology": "Numerology preset", "generator.coverage.experimental": "Experimental profile", "generator.coverage.custom": "Custom waveform", @@ -1261,7 +1258,6 @@ "paFlow.input": "PA input", "paFlow.virtual": "Virtual PA", "paFlow.output": "PA output", - "paFlow.paired": "Paired dataset", "paFlow.trainPA": "PA training", "paFlow.trainDPD": "DPD training", "paFlow.complete": "Complete", @@ -1270,7 +1266,7 @@ "paFlow.trainingHelp": "The learned PA surrogate supplies the reference for DPD training.", "paFlow.existingTitle": "Choose an existing paired dataset", "paFlow.existingHelp": "This dataset already supplies paired PA input and output. Dataset making is complete: signal generation and Virtual PA simulation are bypassed. Continue directly with PA model training.", - "paFlow.generatedHelp": "Make x in Signal Generator, choose a Virtual PA and simulate y, then pair x and y. After dataset making, train the PA surrogate and DPD. Checkmarks follow your configuration and successful training runs.", + "paFlow.generatedHelp": "Genere entradas PA, seleccione un PA virtual y simule las salidas. Studio guarda automáticamente los datos. Después entrene el modelo PA y el DPD; las marcas indican pasos completados.", "paFlow.begin": "Begin with a signal or an existing paired dataset.", "spectrum.node.dpd_input": "DPD Input", "spectrum.node.pa_input": "PA Input", @@ -1451,5 +1447,24 @@ "generator.evmCarriers": "EVM by data subcarrier", "analyzer.fft": "FFT length", "analyzer.expandSpectrogram": "Expand spectrogram", - "analyzer.timeAxis": "Time (µs)" + "analyzer.timeAxis": "Time (µs)", + "generator.numerology": "Numerología", + "generator.matrixHelp": "Seleccione varias celdas: ancho de banda horizontal, modulación vertical; cada grupo tiene un número distinto de canales OFDMA.", + "generator.channelGroup": "{count} canales OFDMA", + "generator.bandwidthAxis": "Ancho de banda base (MHz)", + "generator.selectionCount": "{count} / 16 preajustes seleccionados", + "generator.selectedPresets": "Selección · pulse para editar o visualizar", + "generator.editSelected": "Ajustes del preajuste resaltado", + "generator.idealFilter": "Filtro ideal de entrada PA (recomendado)", + "generator.filterHelp": "Bordes definidos con transición coseno del 4 %. Se aplica a este preajuste y puede desactivarse. Puede cambiar el EVM y los bordes de ráfaga.", + "generator.totalSamples": "{count} muestras I/Q en total", + "generator.selectionLimit": "Hasta 16 preajustes y 4.000.000 de muestras I/Q en total. También se aplica la cuota de almacenamiento web.", + "generator.batchNext": "Estas señales son entradas PA. Seleccione un PA virtual; una simulación crea un CSV de entrada/salida por preajuste.", + "paLibrary.batch": "Los {count} preajustes usarán este PA, cada uno con su propia frecuencia de muestreo y memoria inicial nula.", + "paLibrary.autoDataset": "La simulación guarda los datos y abre sus detalles. División: 60 % entrenamiento, 20 % validación, 20 % prueba; guardas de 256 muestras.", + "datasets.capture": "Visualizar subconjunto", + "datasets.captureHelp": "Gráficas, metadatos, preprocesamiento y entrenamiento usan el subconjunto seleccionado con su frecuencia y longitud. El ZIP contiene todas las capturas originales y el script PA.", + "datasets.downloadCsv": "Descargar CSV", + "datasets.downloadZip": "Descargar todo · ZIP", + "generator.removePreset": "Eliminar preajuste {name}" } diff --git a/frontend/src/i18n/fr.json b/frontend/src/i18n/fr.json index e1be6bf..25d6408 100644 --- a/frontend/src/i18n/fr.json +++ b/frontend/src/i18n/fr.json @@ -1056,19 +1056,16 @@ "generator.family.nr": "5G NR", "generator.family.wifi6": "Wi-Fi 6", "generator.family.wifi7": "Wi-Fi 7", - "generator.family.wifi8": "Wi-Fi 8", "generator.family.custom": "Custom", "generator.familyHint.nr": "FR1 / FR2", "generator.familyHint.wifi6": "802.11ax", "generator.familyHint.wifi7": "802.11be", - "generator.familyHint.wifi8": "Experimental", "generator.familyHint.custom": "OFDM · QAM · tones", "generator.choose": "Choose a signal family", "generator.setup": "Signal setup", "generator.preset": "Waveform preset", "generator.scope": "Signal coverage & measurement definitions", "generator.scopeHelp": "Synthetic engineering stimuli with NR / WLAN numerology and generic pilots. Continuous uncoded payloads; no complete protocol frames or conformance certification.", - "generator.wifi8Scope": "Wi-Fi 8 is an experimental 802.11bn numerology profile. It uses generic OFDM payloads and pilots; draft-specific UHR features and complete packets are not implemented.", "generator.coverage.numerology": "Numerology preset", "generator.coverage.experimental": "Experimental profile", "generator.coverage.custom": "Custom waveform", @@ -1261,7 +1258,6 @@ "paFlow.input": "PA input", "paFlow.virtual": "Virtual PA", "paFlow.output": "PA output", - "paFlow.paired": "Paired dataset", "paFlow.trainPA": "PA training", "paFlow.trainDPD": "DPD training", "paFlow.complete": "Complete", @@ -1270,7 +1266,7 @@ "paFlow.trainingHelp": "The learned PA surrogate supplies the reference for DPD training.", "paFlow.existingTitle": "Choose an existing paired dataset", "paFlow.existingHelp": "This dataset already supplies paired PA input and output. Dataset making is complete: signal generation and Virtual PA simulation are bypassed. Continue directly with PA model training.", - "paFlow.generatedHelp": "Make x in Signal Generator, choose a Virtual PA and simulate y, then pair x and y. After dataset making, train the PA surrogate and DPD. Checkmarks follow your configuration and successful training runs.", + "paFlow.generatedHelp": "Générez les entrées PA, choisissez un PA virtuel et simulez les sorties. Studio enregistre automatiquement les données. Entraînez ensuite le modèle PA et le DPD ; les coches indiquent les étapes terminées.", "paFlow.begin": "Begin with a signal or an existing paired dataset.", "spectrum.node.dpd_input": "DPD Input", "spectrum.node.pa_input": "PA Input", @@ -1451,5 +1447,24 @@ "generator.evmCarriers": "EVM by data subcarrier", "analyzer.fft": "FFT length", "analyzer.expandSpectrogram": "Expand spectrogram", - "analyzer.timeAxis": "Time (µs)" + "analyzer.timeAxis": "Time (µs)", + "generator.numerology": "Numérologie", + "generator.matrixHelp": "Sélectionnez plusieurs cases : bande passante en abscisse, modulation en ordonnée ; chaque groupe possède un nombre de canaux OFDMA différent.", + "generator.channelGroup": "{count} canaux OFDMA", + "generator.bandwidthAxis": "Bande passante de base (MHz)", + "generator.selectionCount": "{count} / 16 préréglages sélectionnés", + "generator.selectedPresets": "Sélection · cliquer pour modifier ou visualiser", + "generator.editSelected": "Paramètres du préréglage surligné", + "generator.idealFilter": "Filtre idéal en entrée du PA (recommandé)", + "generator.filterHelp": "Bords nets avec une transition cosinus de 4 %. S’applique à ce préréglage et peut être désactivé. Peut modifier l’EVM et les bords des rafales.", + "generator.totalSamples": "{count} échantillons I/Q au total", + "generator.selectionLimit": "Jusqu’à 16 préréglages et 4 000 000 d’échantillons I/Q au total. Le quota de stockage web s’applique aussi.", + "generator.batchNext": "Ces signaux sont des entrées PA. Choisissez un PA virtuel ; une simulation crée un CSV entrée/sortie par préréglage.", + "paLibrary.batch": "Les {count} préréglages utilisent ce PA, chacun à sa fréquence d’échantillonnage et avec un état mémoire initial nul.", + "paLibrary.autoDataset": "La simulation enregistre le jeu de données et ouvre ses détails. Répartition : 60 % entraînement, 20 % validation, 20 % test ; gardes de 256 échantillons.", + "datasets.capture": "Visualiser un sous-ensemble", + "datasets.captureHelp": "Graphiques, métadonnées, prétraitement et entraînement utilisent le sous-ensemble choisi, avec sa fréquence et sa longueur. Le ZIP contient toutes les captures originales et le script PA.", + "datasets.downloadCsv": "Télécharger CSV", + "datasets.downloadZip": "Tout télécharger · ZIP", + "generator.removePreset": "Retirer le préréglage {name}" } diff --git a/frontend/src/i18n/it.json b/frontend/src/i18n/it.json index 0467299..16f678f 100644 --- a/frontend/src/i18n/it.json +++ b/frontend/src/i18n/it.json @@ -1056,19 +1056,16 @@ "generator.family.nr": "5G NR", "generator.family.wifi6": "Wi-Fi 6", "generator.family.wifi7": "Wi-Fi 7", - "generator.family.wifi8": "Wi-Fi 8", "generator.family.custom": "Custom", "generator.familyHint.nr": "FR1 / FR2", "generator.familyHint.wifi6": "802.11ax", "generator.familyHint.wifi7": "802.11be", - "generator.familyHint.wifi8": "Experimental", "generator.familyHint.custom": "OFDM · QAM · tones", "generator.choose": "Choose a signal family", "generator.setup": "Signal setup", "generator.preset": "Waveform preset", "generator.scope": "Signal coverage & measurement definitions", "generator.scopeHelp": "Synthetic engineering stimuli with NR / WLAN numerology and generic pilots. Continuous uncoded payloads; no complete protocol frames or conformance certification.", - "generator.wifi8Scope": "Wi-Fi 8 is an experimental 802.11bn numerology profile. It uses generic OFDM payloads and pilots; draft-specific UHR features and complete packets are not implemented.", "generator.coverage.numerology": "Numerology preset", "generator.coverage.experimental": "Experimental profile", "generator.coverage.custom": "Custom waveform", @@ -1261,7 +1258,6 @@ "paFlow.input": "PA input", "paFlow.virtual": "Virtual PA", "paFlow.output": "PA output", - "paFlow.paired": "Paired dataset", "paFlow.trainPA": "PA training", "paFlow.trainDPD": "DPD training", "paFlow.complete": "Complete", @@ -1270,7 +1266,7 @@ "paFlow.trainingHelp": "The learned PA surrogate supplies the reference for DPD training.", "paFlow.existingTitle": "Choose an existing paired dataset", "paFlow.existingHelp": "This dataset already supplies paired PA input and output. Dataset making is complete: signal generation and Virtual PA simulation are bypassed. Continue directly with PA model training.", - "paFlow.generatedHelp": "Make x in Signal Generator, choose a Virtual PA and simulate y, then pair x and y. After dataset making, train the PA surrogate and DPD. Checkmarks follow your configuration and successful training runs.", + "paFlow.generatedHelp": "Genera gli ingressi PA, scegli un PA virtuale e simula le uscite. Studio salva automaticamente i dati. Quindi addestra il modello PA e il DPD; le spunte indicano i passaggi completati.", "paFlow.begin": "Begin with a signal or an existing paired dataset.", "spectrum.node.dpd_input": "DPD Input", "spectrum.node.pa_input": "PA Input", @@ -1451,5 +1447,24 @@ "generator.evmCarriers": "EVM by data subcarrier", "analyzer.fft": "FFT length", "analyzer.expandSpectrogram": "Expand spectrogram", - "analyzer.timeAxis": "Time (µs)" + "analyzer.timeAxis": "Time (µs)", + "generator.numerology": "Numerologia", + "generator.matrixHelp": "Seleziona più celle: banda sull’asse orizzontale, modulazione su quello verticale; ogni gruppo ha un diverso numero di canali OFDMA.", + "generator.channelGroup": "{count} canali OFDMA", + "generator.bandwidthAxis": "Larghezza di banda base (MHz)", + "generator.selectionCount": "{count} / 16 preset selezionati", + "generator.selectedPresets": "Selezione · clicca per modificare o visualizzare", + "generator.editSelected": "Impostazioni del preset evidenziato", + "generator.idealFilter": "Filtro ideale in ingresso al PA (consigliato)", + "generator.filterHelp": "Bordi netti con transizione coseno del 4 %. Si applica a questo preset ed è disattivabile. Può modificare EVM e bordi delle raffiche.", + "generator.totalSamples": "{count} campioni I/Q totali", + "generator.selectionLimit": "Fino a 16 preset e 4.000.000 di campioni I/Q totali. Si applica anche la quota di archiviazione web.", + "generator.batchNext": "Questi segnali sono ingressi PA. Scegli un PA virtuale: una simulazione crea un CSV ingresso/uscita per preset.", + "paLibrary.batch": "Tutti i {count} preset usano questo PA, ciascuno con la propria frequenza di campionamento e memoria iniziale nulla.", + "paLibrary.autoDataset": "La simulazione salva i dati e ne apre i dettagli. Suddivisione: 60 % addestramento, 20 % validazione, 20 % test; intervalli di guardia di 256 campioni.", + "datasets.capture": "Visualizza sottoinsieme", + "datasets.captureHelp": "Grafici, metadati, preelaborazione e addestramento usano il sottoinsieme scelto con frequenza e lunghezza proprie. Lo ZIP contiene tutte le acquisizioni originali e lo script PA.", + "datasets.downloadCsv": "Scarica CSV", + "datasets.downloadZip": "Scarica tutto · ZIP", + "generator.removePreset": "Rimuovi preset {name}" } diff --git a/frontend/src/i18n/ja.json b/frontend/src/i18n/ja.json index 30d6573..c3ccb37 100644 --- a/frontend/src/i18n/ja.json +++ b/frontend/src/i18n/ja.json @@ -1056,19 +1056,16 @@ "generator.family.nr": "5G NR", "generator.family.wifi6": "Wi-Fi 6", "generator.family.wifi7": "Wi-Fi 7", - "generator.family.wifi8": "Wi-Fi 8", "generator.family.custom": "Custom", "generator.familyHint.nr": "FR1 / FR2", "generator.familyHint.wifi6": "802.11ax", "generator.familyHint.wifi7": "802.11be", - "generator.familyHint.wifi8": "Experimental", "generator.familyHint.custom": "OFDM · QAM · tones", "generator.choose": "Choose a signal family", "generator.setup": "Signal setup", "generator.preset": "Waveform preset", "generator.scope": "Signal coverage & measurement definitions", "generator.scopeHelp": "Synthetic engineering stimuli with NR / WLAN numerology and generic pilots. Continuous uncoded payloads; no complete protocol frames or conformance certification.", - "generator.wifi8Scope": "Wi-Fi 8 is an experimental 802.11bn numerology profile. It uses generic OFDM payloads and pilots; draft-specific UHR features and complete packets are not implemented.", "generator.coverage.numerology": "Numerology preset", "generator.coverage.experimental": "Experimental profile", "generator.coverage.custom": "Custom waveform", @@ -1261,7 +1258,6 @@ "paFlow.input": "PA input", "paFlow.virtual": "Virtual PA", "paFlow.output": "PA output", - "paFlow.paired": "Paired dataset", "paFlow.trainPA": "PA training", "paFlow.trainDPD": "DPD training", "paFlow.complete": "Complete", @@ -1270,7 +1266,7 @@ "paFlow.trainingHelp": "The learned PA surrogate supplies the reference for DPD training.", "paFlow.existingTitle": "Choose an existing paired dataset", "paFlow.existingHelp": "This dataset already supplies paired PA input and output. Dataset making is complete: signal generation and Virtual PA simulation are bypassed. Continue directly with PA model training.", - "paFlow.generatedHelp": "Make x in Signal Generator, choose a Virtual PA and simulate y, then pair x and y. After dataset making, train the PA surrogate and DPD. Checkmarks follow your configuration and successful training runs.", + "paFlow.generatedHelp": "PA 入力を生成し、仮想 PA を選んで出力をシミュレーションします。Studio がデータセットを自動保存します。その後 PA モデルと DPD を学習します。チェックは完了した手順を示します。", "paFlow.begin": "Begin with a signal or an existing paired dataset.", "spectrum.node.dpd_input": "DPD Input", "spectrum.node.pa_input": "PA Input", @@ -1451,5 +1447,24 @@ "generator.evmCarriers": "EVM by data subcarrier", "analyzer.fft": "FFT length", "analyzer.expandSpectrogram": "Expand spectrogram", - "analyzer.timeAxis": "Time (µs)" + "analyzer.timeAxis": "Time (µs)", + "generator.numerology": "ニューメロロジー", + "generator.matrixHelp": "複数のセルを選択できます。横軸は帯域幅、縦軸は変調方式です。各グループは OFDMA チャネル数が異なります。", + "generator.channelGroup": "OFDMA {count} チャネル", + "generator.bandwidthAxis": "ベースバンド帯域幅(MHz)", + "generator.selectionCount": "{count} / 16 プリセットを選択中", + "generator.selectedPresets": "選択したプリセット · クリックして編集・表示", + "generator.editSelected": "強調表示されたプリセットの設定", + "generator.idealFilter": "理想 PA 入力フィルター(推奨)", + "generator.filterHelp": "4% のコサイン遷移で帯域端を急峻にします。このプリセットに適用され、無効化できます。EVM やバースト端が変化する場合があります。", + "generator.totalSamples": "選択した全プリセットで {count} I/Q サンプル", + "generator.selectionLimit": "最大 16 プリセット、合計 4,000,000 I/Q サンプル。Web ワークスペースの保存容量制限も適用されます。", + "generator.batchNext": "これらは PA 入力です。次に仮想 PA を選ぶと、1 回のシミュレーションで各プリセットの入出力 CSV を含むデータセットを作成します。", + "paLibrary.batch": "選択した {count} プリセットすべてにこの PA を使用します。各信号固有のサンプルレートで、メモリ状態をゼロから開始します。", + "paLibrary.autoDataset": "シミュレーション後にデータセットを保存して詳細を開きます。既定の分割は学習 60%、検証 20%、テスト 20%、ガード間隔は 256 サンプルです。", + "datasets.capture": "サブデータセットを表示", + "datasets.captureHelp": "グラフ、メタデータ、前処理、学習には選択したサブセット固有のサンプルレートと長さを使用します。ZIP には全元データと PA 再現スクリプトを含みます。", + "datasets.downloadCsv": "CSV をダウンロード", + "datasets.downloadZip": "すべてダウンロード · ZIP", + "generator.removePreset": "プリセット {name} を削除" } diff --git a/frontend/src/i18n/ko.json b/frontend/src/i18n/ko.json index 10e3ebc..08bfb7c 100644 --- a/frontend/src/i18n/ko.json +++ b/frontend/src/i18n/ko.json @@ -1056,19 +1056,16 @@ "generator.family.nr": "5G NR", "generator.family.wifi6": "Wi-Fi 6", "generator.family.wifi7": "Wi-Fi 7", - "generator.family.wifi8": "Wi-Fi 8", "generator.family.custom": "Custom", "generator.familyHint.nr": "FR1 / FR2", "generator.familyHint.wifi6": "802.11ax", "generator.familyHint.wifi7": "802.11be", - "generator.familyHint.wifi8": "Experimental", "generator.familyHint.custom": "OFDM · QAM · tones", "generator.choose": "Choose a signal family", "generator.setup": "Signal setup", "generator.preset": "Waveform preset", "generator.scope": "Signal coverage & measurement definitions", "generator.scopeHelp": "Synthetic engineering stimuli with NR / WLAN numerology and generic pilots. Continuous uncoded payloads; no complete protocol frames or conformance certification.", - "generator.wifi8Scope": "Wi-Fi 8 is an experimental 802.11bn numerology profile. It uses generic OFDM payloads and pilots; draft-specific UHR features and complete packets are not implemented.", "generator.coverage.numerology": "Numerology preset", "generator.coverage.experimental": "Experimental profile", "generator.coverage.custom": "Custom waveform", @@ -1261,7 +1258,6 @@ "paFlow.input": "PA input", "paFlow.virtual": "Virtual PA", "paFlow.output": "PA output", - "paFlow.paired": "Paired dataset", "paFlow.trainPA": "PA training", "paFlow.trainDPD": "DPD training", "paFlow.complete": "Complete", @@ -1270,7 +1266,7 @@ "paFlow.trainingHelp": "The learned PA surrogate supplies the reference for DPD training.", "paFlow.existingTitle": "Choose an existing paired dataset", "paFlow.existingHelp": "This dataset already supplies paired PA input and output. Dataset making is complete: signal generation and Virtual PA simulation are bypassed. Continue directly with PA model training.", - "paFlow.generatedHelp": "Make x in Signal Generator, choose a Virtual PA and simulate y, then pair x and y. After dataset making, train the PA surrogate and DPD. Checkmarks follow your configuration and successful training runs.", + "paFlow.generatedHelp": "PA 입력을 생성하고 가상 PA를 선택하여 출력을 시뮬레이션하세요. Studio가 데이터셋을 자동 저장합니다. 이어서 PA 모델과 DPD를 학습합니다. 체크 표시는 완료된 단계를 나타냅니다。", "paFlow.begin": "Begin with a signal or an existing paired dataset.", "spectrum.node.dpd_input": "DPD Input", "spectrum.node.pa_input": "PA Input", @@ -1451,5 +1447,24 @@ "generator.evmCarriers": "EVM by data subcarrier", "analyzer.fft": "FFT length", "analyzer.expandSpectrogram": "Expand spectrogram", - "analyzer.timeAxis": "Time (µs)" + "analyzer.timeAxis": "Time (µs)", + "generator.numerology": "뉴머롤로지", + "generator.matrixHelp": "여러 셀을 선택하세요. 가로축은 대역폭, 세로축은 변조이며 각 그룹의 OFDMA 채널 수가 다릅니다.", + "generator.channelGroup": "OFDMA 채널 {count}개", + "generator.bandwidthAxis": "기저대역 대역폭 (MHz)", + "generator.selectionCount": "프리셋 {count} / 16개 선택됨", + "generator.selectedPresets": "선택한 프리셋 · 클릭하여 편집 또는 미리보기", + "generator.editSelected": "강조된 프리셋의 설정", + "generator.idealFilter": "이상적인 PA 입력 필터 (권장)", + "generator.filterHelp": "4% 코사인 전이로 대역 경계를 가파르게 합니다. 이 프리셋에 적용되며 끌 수 있습니다. EVM과 버스트 경계가 바뀔 수 있습니다.", + "generator.totalSamples": "선택한 프리셋의 총 I/Q 샘플 {count}개", + "generator.selectionLimit": "최대 16개 프리셋, 총 4,000,000개 I/Q 샘플. 웹 작업 공간 저장 한도도 적용됩니다.", + "generator.batchNext": "이 신호들은 PA 입력입니다. 가상 PA를 선택하면 한 번의 시뮬레이션으로 프리셋별 입출력 CSV가 포함된 데이터셋을 만듭니다.", + "paLibrary.batch": "선택한 프리셋 {count}개에 모두 이 PA를 사용합니다. 각 신호의 샘플레이트를 유지하며 메모리 상태는 각각 0에서 시작합니다.", + "paLibrary.autoDataset": "시뮬레이션 후 데이터셋을 저장하고 상세 페이지를 엽니다. 기본 분할은 학습 60%, 검증 20%, 테스트 20%이며 보호 간격은 256개 샘플입니다.", + "datasets.capture": "하위 데이터셋 시각화", + "datasets.captureHelp": "그래프, 메타데이터, 전처리 및 학습은 선택한 하위 데이터셋의 샘플레이트와 길이를 사용합니다. ZIP에는 모든 원본 캡처와 PA 재현 스크립트가 포함됩니다.", + "datasets.downloadCsv": "CSV 다운로드", + "datasets.downloadZip": "전체 다운로드 · ZIP", + "generator.removePreset": "프리셋 {name} 제거" } diff --git a/frontend/src/i18n/nl.json b/frontend/src/i18n/nl.json index cc9190a..e16a551 100644 --- a/frontend/src/i18n/nl.json +++ b/frontend/src/i18n/nl.json @@ -1056,19 +1056,16 @@ "generator.family.nr": "5G NR", "generator.family.wifi6": "Wi-Fi 6", "generator.family.wifi7": "Wi-Fi 7", - "generator.family.wifi8": "Wi-Fi 8", "generator.family.custom": "Custom", "generator.familyHint.nr": "FR1 / FR2", "generator.familyHint.wifi6": "802.11ax", "generator.familyHint.wifi7": "802.11be", - "generator.familyHint.wifi8": "Experimental", "generator.familyHint.custom": "OFDM · QAM · tones", "generator.choose": "Choose a signal family", "generator.setup": "Signal setup", "generator.preset": "Waveform preset", "generator.scope": "Signal coverage & measurement definitions", "generator.scopeHelp": "Synthetic engineering stimuli with NR / WLAN numerology and generic pilots. Continuous uncoded payloads; no complete protocol frames or conformance certification.", - "generator.wifi8Scope": "Wi-Fi 8 is an experimental 802.11bn numerology profile. It uses generic OFDM payloads and pilots; draft-specific UHR features and complete packets are not implemented.", "generator.coverage.numerology": "Numerology preset", "generator.coverage.experimental": "Experimental profile", "generator.coverage.custom": "Custom waveform", @@ -1261,7 +1258,6 @@ "paFlow.input": "PA input", "paFlow.virtual": "Virtual PA", "paFlow.output": "PA output", - "paFlow.paired": "Paired dataset", "paFlow.trainPA": "PA training", "paFlow.trainDPD": "DPD training", "paFlow.complete": "Complete", @@ -1270,7 +1266,7 @@ "paFlow.trainingHelp": "The learned PA surrogate supplies the reference for DPD training.", "paFlow.existingTitle": "Choose an existing paired dataset", "paFlow.existingHelp": "This dataset already supplies paired PA input and output. Dataset making is complete: signal generation and Virtual PA simulation are bypassed. Continue directly with PA model training.", - "paFlow.generatedHelp": "Make x in Signal Generator, choose a Virtual PA and simulate y, then pair x and y. After dataset making, train the PA surrogate and DPD. Checkmarks follow your configuration and successful training runs.", + "paFlow.generatedHelp": "Maak PA-ingangen, kies een virtuele PA en simuleer de uitgangen. Studio slaat de dataset automatisch op. Train daarna het PA-model en DPD; vinkjes tonen voltooide stappen.", "paFlow.begin": "Begin with a signal or an existing paired dataset.", "spectrum.node.dpd_input": "DPD Input", "spectrum.node.pa_input": "PA Input", @@ -1451,5 +1447,24 @@ "generator.evmCarriers": "EVM by data subcarrier", "analyzer.fft": "FFT length", "analyzer.expandSpectrogram": "Expand spectrogram", - "analyzer.timeAxis": "Time (µs)" + "analyzer.timeAxis": "Time (µs)", + "generator.numerology": "Numerologie", + "generator.matrixHelp": "Selecteer meerdere vakken: bandbreedte horizontaal, modulatie verticaal; elke groep heeft een ander aantal OFDMA-kanalen.", + "generator.channelGroup": "{count} OFDMA-kanalen", + "generator.bandwidthAxis": "Basisbandbreedte (MHz)", + "generator.selectionCount": "{count} / 16 presets geselecteerd", + "generator.selectedPresets": "Selectie · klik om te bewerken of bekijken", + "generator.editSelected": "Instellingen van de gemarkeerde preset", + "generator.idealFilter": "Ideaal PA-ingangsfilter (aanbevolen)", + "generator.filterHelp": "Steile bandranden met een cosinusovergang van 4%. Geldt voor deze preset en kan worden uitgeschakeld. Kan EVM en burstranden veranderen.", + "generator.totalSamples": "In totaal {count} I/Q-samples", + "generator.selectionLimit": "Maximaal 16 presets en 4.000.000 I/Q-samples in totaal. Ook de opslaglimiet van de webwerkruimte geldt.", + "generator.batchNext": "Dit zijn PA-ingangen. Kies een virtuele PA; één simulatie maakt per preset een CSV met ingang en uitgang.", + "paLibrary.batch": "Alle {count} presets gebruiken deze PA, elk met een eigen samplefrequentie en begintoestand zonder geheugen.", + "paLibrary.autoDataset": "De simulatie slaat de dataset op en opent de details. Verdeling: 60% training, 20% validatie, 20% test; bewakingsintervallen van 256 samples.", + "datasets.capture": "Subdataset bekijken", + "datasets.captureHelp": "Grafieken, metadata, voorbewerking en training gebruiken de gekozen subdataset met eigen samplefrequentie en lengte. De ZIP bevat alle oorspronkelijke opnamen en het PA-script.", + "datasets.downloadCsv": "CSV downloaden", + "datasets.downloadZip": "Alles downloaden · ZIP", + "generator.removePreset": "Voorinstelling {name} verwijderen" } diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index 4565db1..fe2c2e1 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -1056,19 +1056,16 @@ "generator.family.nr": "5G NR", "generator.family.wifi6": "Wi-Fi 6", "generator.family.wifi7": "Wi-Fi 7", - "generator.family.wifi8": "Wi-Fi 8", "generator.family.custom": "自定义", "generator.familyHint.nr": "FR1 / FR2", "generator.familyHint.wifi6": "802.11ax", "generator.familyHint.wifi7": "802.11be", - "generator.familyHint.wifi8": "实验性", "generator.familyHint.custom": "OFDM · QAM · 单音", "generator.choose": "选择信号类型", "generator.setup": "信号设置", "generator.preset": "波形预设", "generator.scope": "信号覆盖范围与测量定义", "generator.scopeHelp": "使用 NR / WLAN 数字参数与通用导频的合成工程测试信号。生成连续未编码载荷,不包含完整协议帧,不作为标准一致性认证波形。", - "generator.wifi8Scope": "Wi-Fi 8 为实验性 802.11bn 数字参数预设,使用通用 OFDM 载荷与导频;不包含草案特有的 UHR 功能及完整协议数据包。", "generator.coverage.numerology": "数字参数预设", "generator.coverage.experimental": "实验性预设", "generator.coverage.custom": "自定义波形", @@ -1261,7 +1258,6 @@ "paFlow.input": "PA 输入", "paFlow.virtual": "虚拟 PA", "paFlow.output": "PA 输出", - "paFlow.paired": "配对数据集", "paFlow.trainPA": "PA 模型训练", "paFlow.trainDPD": "DPD 模型训练", "paFlow.complete": "已完成", @@ -1270,7 +1266,7 @@ "paFlow.trainingHelp": "学习得到的 PA 代理模型为 DPD 训练提供参考。", "paFlow.existingTitle": "选择已有的配对数据集", "paFlow.existingHelp": "此数据集已提供配对的 PA 输入与输出。数据集制作阶段已完成,因此跳过信号生成和虚拟 PA 仿真,直接进入 PA 模型训练。", - "paFlow.generatedHelp": "先在 Signal Generator 生成 x,选择虚拟 PA 并仿真 y,再将 x 与 y 配对。数据集制作完成后,训练 PA 代理模型与 DPD。勾选状态跟随当前配置及实际成功的训练任务。", + "paFlow.generatedHelp": "在 Signal Generator 生成 PA 输入,选择 Virtual PA 并仿真输出,Studio 会自动保存完整数据集。随后训练 PA 代理模型和 DPD。勾选状态跟随当前配置及成功的训练任务。", "paFlow.begin": "从生成信号或已有配对数据集开始。", "spectrum.node.dpd_input": "DPD 输入", "spectrum.node.pa_input": "PA 输入", @@ -1451,5 +1447,24 @@ "generator.evmCarriers": "逐数据子载波 EVM", "analyzer.fft": "FFT 点数", "analyzer.expandSpectrogram": "展开时频图", - "analyzer.timeAxis": "时间 (µs)" + "analyzer.timeAxis": "时间 (µs)", + "generator.numerology": "信号参数体系", + "generator.matrixHelp": "可选择多个点。横轴:基带带宽;纵轴:调制阶数。每组对应不同的 OFDMA 信道数。", + "generator.channelGroup": "{count} 个 OFDMA 信道", + "generator.bandwidthAxis": "基带带宽(MHz)", + "generator.selectionCount": "已选择 {count} / 16 个预设", + "generator.selectedPresets": "已选预设 · 点击编辑或预览", + "generator.editSelected": "当前高亮预设的设置", + "generator.idealFilter": "理想 PA 输入滤波器(推荐)", + "generator.filterHelp": "采用 4% 余弦过渡带,使频谱边缘更陡峭。仅应用于当前预设;可关闭以获得未滤波信号。滤波可能改变 EVM 和突发边缘。", + "generator.totalSamples": "全部所选预设共 {count} 个 I/Q 样本", + "generator.selectionLimit": "最多选择 16 个预设、总计 4,000,000 个 I/Q 样本。网页版同时受临时工作区存储配额限制。", + "generator.batchNext": "这些是 PA 输入信号。下一步选择 Virtual PA;一次仿真即可创建完整数据集,每个预设各有一份包含输入和输出的 CSV。", + "paLibrary.batch": "所选 {count} 个预设都将使用此 PA。每个信号保留自身采样率,独立从零记忆状态开始仿真。", + "paLibrary.autoDataset": "仿真后自动保存完整数据集并打开详情。默认划分:60% 训练、20% 验证、20% 测试,保护间隔为 256 个样本。", + "datasets.capture": "可视化子数据集", + "datasets.captureHelp": "图表、元数据、预处理与训练使用当前子数据集。每个预设保留自身采样率和长度。ZIP 下载包含所有原始子集和 PA 复现脚本。", + "datasets.downloadCsv": "下载 CSV", + "datasets.downloadZip": "下载全部 · ZIP", + "generator.removePreset": "移除预设 {name}" } diff --git a/frontend/src/pages/DatasetDetailPage.test.tsx b/frontend/src/pages/DatasetDetailPage.test.tsx index 8f3a6b3..adb9093 100644 --- a/frontend/src/pages/DatasetDetailPage.test.tsx +++ b/frontend/src/pages/DatasetDetailPage.test.tsx @@ -117,3 +117,25 @@ test('a QAM label without a bound demodulator stays a raw I/Q plot with a reason expect(screen.queryByRole('button', { name: 'Symbols' })).not.toBeInTheDocument() expect(screen.getByTitle(/No dataset-specific demodulator is bound/)).toBeInTheDocument() }) + +test('subdataset selection changes charts, metadata and training to its own sample rate', async () => { + const captures = [ + { dataset_id: 'mine', preset_id: 'nr-20', label: 'NR · 20 MHz', n_samples: 20000, sample_rate_hz: 122.88e6, bandwidth_hz: 20e6 }, + { dataset_id: 'second', preset_id: 'wifi7-80', label: 'Wi-Fi 7 · 80 MHz', n_samples: 32768, sample_rate_hz: 320e6, bandwidth_hz: 80e6 }, + ] + const { calls } = mockApi({ + 'GET /api/v1/datasets/mine': () => ({ ...dataset, captures }), + 'GET /api/v1/datasets/mine/analysis': () => inspection, + 'GET /api/v1/datasets/second': () => ({ ...dataset, dataset_id: 'second', display_name: 'Wi-Fi capture', parent_dataset_id: 'mine', n_samples: 32768, signal: { ...dataset.signal, sample_rate_hz: 320e6, bandwidth_hz: 80e6 } }), + 'GET /api/v1/datasets/second/analysis': () => ({ ...inspection, dataset_id: 'second', total_samples: 32768 }), + }) + renderWithProviders(, { route: '/datasets/mine', path: '/datasets/:datasetId' }) + expect(await screen.findByRole('button', { name: 'Download all · ZIP' })).toBeVisible() + expect(screen.getByRole('button', { name: 'Download CSV' })).toBeVisible() + await userEvent.click(screen.getByRole('combobox', { name: 'Visualize subdataset' })) + await userEvent.click(screen.getByRole('option', { name: /Wi-Fi 7 · 80 MHz/ })) + await screen.findByRole('heading', { name: 'Wi-Fi capture' }) + expect(screen.getByText('320 MS/s')).toBeVisible() + await waitFor(() => expect(calls.some(c => c.path === '/api/v1/datasets/second/analysis')).toBe(true)) + expect(screen.getByRole('link', { name: /Configure experiment/ })).toHaveAttribute('href', '/experiments/new?dataset=second&version=raw-v1') +}) diff --git a/frontend/src/pages/DatasetDetailPage.tsx b/frontend/src/pages/DatasetDetailPage.tsx index f869f56..55f6e67 100644 --- a/frontend/src/pages/DatasetDetailPage.tsx +++ b/frontend/src/pages/DatasetDetailPage.tsx @@ -1,3 +1,5 @@ +import DownloadIcon from '@mui/icons-material/Download' +import { downloadFile } from '@/api/client' import CheckCircleIcon from '@mui/icons-material/CheckCircle' import ArrowForwardIcon from '@mui/icons-material/ArrowForward' import { analyzerLink } from '@/api/signalAnalyzer' @@ -18,7 +20,8 @@ import TableHead from '@mui/material/TableHead' import TableRow from '@mui/material/TableRow' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' -import { useState } from 'react' +import { useEffect, useState } from 'react' +import { useStudioWorkflow } from '@/workflow/StudioWorkflow' import { Link as RouterLink, useParams, useSearchParams } from 'react-router' import { useDatasetAnalysis, useRunDoctor, versionNames } from '@/api/datasets' import { useDataset } from '@/api/hooks' @@ -101,12 +104,27 @@ function VersionsTable({ d }: { d: DatasetManifest }) { ) } +const NO_CAPTURES: NonNullable = [] + export function DatasetDetailPage() { - const { datasetId = '' } = useParams() + const { datasetId: routeId = '' } = useParams() + const entry = useDataset(routeId) + const collectionId = entry.data?.parent_dataset_id ?? routeId + const parent = useDataset(collectionId) + const { state: { datasetId: workflowDatasetId }, selectCapture } = useStudioWorkflow() + const [search, setSearch] = useSearchParams() + const captures = parent.data?.captures ?? NO_CAPTURES + const datasetId = captures.some(c => c.dataset_id === search.get('capture')) ? search.get('capture')! : captures.some(c => c.dataset_id === routeId) ? routeId : collectionId const ds = useDataset(datasetId) const doctor = useRunDoctor(datasetId) + const [downloadError, setDownloadError] = useState(null) + const [downloading, setDownloading] = useState(false) + const download = (id: string, version: string, collection: boolean) => { + setDownloading(true); setDownloadError(null) + void downloadFile(`/api/v1/datasets/${encodeURIComponent(id)}/download?version=${encodeURIComponent(version)}&collection=${collection}`) + .catch(setDownloadError).finally(() => setDownloading(false)) + } const [dialog, setDialog] = useState<'none' | 'manifest' | 'preprocess'>('none') - const [search, setSearch] = useSearchParams() const names = ds.data ? versionNames(ds.data) : ['raw-v1'] const selectedVersion = search.get('version') ?? 'raw-v1' const doctorVersion = names.includes(selectedVersion) ? selectedVersion : 'raw-v1' @@ -114,6 +132,9 @@ export function DatasetDetailPage() { const tab = ['overview', 'metadata', 'versions', 'doctor'].includes(search.get('tab') ?? '') ? search.get('tab')! : 'overview' const select = (key: string, value: string) => setSearch((old) => { const next = new URLSearchParams(old); next.set(key, value); return next }) const [created, setCreated] = useState(null) + useEffect(() => { + if (captures.some(c => c.dataset_id === workflowDatasetId)) selectCapture(datasetId, doctorVersion) + }, [captures, datasetId, doctorVersion, selectCapture, workflowDatasetId]) if (ds.isPending) return if (ds.isError) return void ds.refetch()} /> const d = ds.data @@ -126,6 +147,8 @@ export function DatasetDetailPage() { {t('inspection.step')}{datasetLabel(d)} + {captures.length > 1 && } variant="contained" disabled={downloading} onClick={() => download(collectionId, 'raw-v1', true)}>{t('datasets.downloadZip')}} + } variant="outlined" disabled={downloading} onClick={() => download(datasetId, doctorVersion, false)}>{t('datasets.downloadCsv')} select('version', e.target.value)} sx={{ minWidth: 125 }}>{names.map((v) => {v})} + {captures.length > 1 && + { + setCreated(null); setDialog('none'); setSearch(old => { const next = new URLSearchParams(old); next.set('capture', e.target.value); next.delete('version'); return next }) + }}>{captures.map(c => {c.label} · {formatNumber(c.n_samples)} I/Q · {c.sample_rate_hz / 1e6} MS/s)} + {t('datasets.captureHelp')} + } + {!!downloadError && } {missing.length > 0 && {t('datasets.detail.missing', { fields: missing.join(', ') })}} {d.origin === 'synthetic' && {t('datasetResearch.syntheticNotice')}} {d.simulation && {t('datasetResearch.generatorDetails')}{JSON.stringify(d.simulation, null, 2)}} diff --git a/frontend/src/pages/DatasetsPage.tsx b/frontend/src/pages/DatasetsPage.tsx index a4de0df..a448fba 100644 --- a/frontend/src/pages/DatasetsPage.tsx +++ b/frontend/src/pages/DatasetsPage.tsx @@ -83,7 +83,7 @@ export function DatasetsPage() { - {datasets.data.map((d) => ( + {datasets.data.filter(d => !d.parent_dataset_id).map((d) => ( @@ -115,7 +115,7 @@ export function DatasetsPage() { setChoosingExisting(false)} fullWidth maxWidth="sm" aria-labelledby="choose-paired-dataset"> {t('paFlow.existingTitle')} {t('paFlow.existingHelp')} - {datasets.data?.map(d => {datasetLabel(d)}{formatNumber(d.n_samples ?? 0)} I/Q)} diff --git a/frontend/src/pages/PALibraryPage.test.tsx b/frontend/src/pages/PALibraryPage.test.tsx index dceff4b..2373e47 100644 --- a/frontend/src/pages/PALibraryPage.test.tsx +++ b/frontend/src/pages/PALibraryPage.test.tsx @@ -14,35 +14,25 @@ const inputId = 'sg-' + 'a'.repeat(64), simulationId = 'vpa-' + 'b'.repeat(64) const model = fixture.data.find(m => m.model_id === 'rapp-am-pm')! const paired = { dataset_id: 'bench-pair', display_name: 'Bench measured pair', source: { kind: 'csv' }, n_samples: 32768, origin: 'measured', signal: { sample_rate_hz: 122.88e6 }, versions: [] } const input = { signal_id: inputId, name: 'NR input', n_samples: 32768, sample_rate_hz: 122.88e6, bandwidth_hz: 20e6, kind: 'pa_input' } -const analysis = { n_samples: 32768, duration_ms: .2667, input_rms: .2, output_rms: .38, rms_gain_db: 5.57, input_papr_db: 10, output_papr_db: 8, - time_us: [0, 1], input_envelope: [.1, .3], output_envelope: [.2, .5], am_input: [.1, .3], am_output: [.2, .5], am_pm_deg: [0, 5], - frequency_mhz: [-1, 1], input_psd: [-60, -60], output_psd: [-54, -54], states: {}, notes: [] } function Probe() { const loc = useLocation(); return {loc.pathname}{loc.search} } beforeEach(() => localStorage.clear()) function setup() { - let simulation = {} return mockApi({ 'GET /api/v1/system/capabilities': () => ({ workspace: 'virtual-pa-test', custom_dataset_imports: true }), 'GET /api/v1/pa-library/models': () => fixture.data, 'GET /api/v1/signal-generator/signals': () => [input], 'GET /api/v1/datasets': () => [paired], - 'POST /api/v1/pa-library/simulations': (_url, init) => { - simulation = { simulation_id: simulationId, config: JSON.parse(String(init.body)), model, analysis, - output_csv_url: '/output.csv', paired_csv_url: '/paired.csv', metadata_url: '/metadata.json' } - return simulation - }, - ['GET /api/v1/pa-library/simulations/' + simulationId]: () => simulation, - ['POST /api/v1/pa-library/simulations/' + simulationId + '/dataset']: (_url, init) => ({ - dataset: { ...paired, dataset_id: JSON.parse(String(init.body)).dataset_id, origin: 'synthetic' }, test_samples: 6452 }), + 'POST /api/v1/pa-library/datasets': () => ({ dataset: { ...paired, dataset_id: 'auto-created', simulation: { simulation_id: simulationId } }, test_samples: 6452 }), }) } -test('formula and slider fields agree; only explicit simulation and pairing advance the workflow', async () => { +test('formula controls stay synchronized and one simulation opens the completed dataset', async () => { const { calls } = setup() renderWithProviders(, { route: '/pa-library?input=' + inputId }) await screen.findByRole('heading', { name: model.name.en }) await waitFor(() => expect(screen.getByTestId('workflow-input')).toHaveAttribute('data-complete', 'true')) + expect(screen.queryByTestId('workflow-paired')).not.toBeInTheDocument() expect(screen.getByTestId('workflow-output')).toHaveAttribute('data-complete', 'false') expect(calls.some(c => c.method === 'POST')).toBe(false) const gain = screen.getByRole('spinbutton', { name: 'Small-signal gain' }) @@ -51,27 +41,27 @@ test('formula and slider fields agree; only explicit simulation and pairing adva for (const token of screen.getAllByTestId('equation-gain')) expect(token).toHaveAttribute('aria-pressed', 'true') fireEvent.change(gain, { target: { value: '2.5' } }) expect(screen.getByRole('slider', { name: 'Small-signal gain' })).toHaveValue('2.5') - await userEvent.click(screen.getByRole('button', { name: 'Simulate PA output' })) - await screen.findByTestId('pa-output-preview') - expect(calls.find(c => c.method === 'POST')?.body).toMatchObject({ input_signal_id: inputId, model_id: model.model_id, parameters: { gain: 2.5 } }) - await waitFor(() => expect(screen.getByTestId('workflow-output')).toHaveAttribute('data-complete', 'true')) - expect(screen.getByTestId('workflow-paired')).toHaveAttribute('data-complete', 'false') - expect(screen.getByText('Training 19,353 · validation 6,451 · testing 6,452 I/Q samples')).toBeVisible() - // A temporarily blank numeric field must invalidate the old output immediately. const saturation = screen.getByRole('spinbutton', { name: 'Saturation envelope' }) fireEvent.change(saturation, { target: { value: '' } }) - expect(screen.queryByTestId('pa-output-preview')).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Simulate PA output' })).toBeDisabled() - await waitFor(() => expect(screen.getByTestId('workflow-output')).toHaveAttribute('data-complete', 'false')) fireEvent.change(saturation, { target: { value: '.6' } }) await userEvent.click(screen.getByRole('button', { name: 'Simulate PA output' })) - await screen.findByTestId('pa-output-preview') - fireEvent.change(screen.getByLabelText('Dataset ID'), { target: { value: 'explicit-virtual-pair' } }) - await userEvent.click(screen.getByRole('button', { name: 'Create paired dataset & train PA' })) - await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/experiments/new?task=train_pa&dataset=explicit-virtual-pair')) - expect(screen.getByTestId('workflow-paired')).toHaveAttribute('data-complete', 'true') + expect(calls.find(c => c.method === 'POST')?.body).toMatchObject({ input_signal_ids: [inputId], model_id: model.model_id, parameters: { gain: 2.5, saturation: .6 } }) + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/datasets/auto-created')) + expect(screen.getByTestId('workflow-output')).toHaveAttribute('data-complete', 'true') expect(screen.getByTestId('workflow-pa')).toHaveAttribute('data-complete', 'false') - expect(calls.filter(c => c.path.endsWith('/dataset'))).toHaveLength(1) + expect(screen.queryByText('Make a complete training dataset')).not.toBeInTheDocument() + expect(calls.filter(c => c.method === 'POST')).toHaveLength(1) +}) + +test('the complete selected batch is simulated with a single request', async () => { + const ids = [inputId, 'sg-' + 'c'.repeat(64)] + localStorage.setItem('opendpd-workflow-v1:virtual-pa-test', JSON.stringify({ version: 1, origin: 'generated', inputId, inputIds: ids, parameters: {} })) + const { calls } = setup() + renderWithProviders(, { route: '/pa-library?input=' + inputId }) + await userEvent.click(await screen.findByRole('button', { name: 'Simulate PA output' })) + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/datasets/auto-created')) + expect(calls.find(c => c.method === 'POST')?.body).toMatchObject({ input_signal_ids: ids }) }) test('existing paired data skips dataset making and opens PA training directly', async () => { @@ -80,7 +70,7 @@ test('existing paired data skips dataset making and opens PA training directly', await userEvent.click(await screen.findByRole('button', { name: 'Use an existing dataset' })) await userEvent.click(await screen.findByRole('button', { name: /Bench measured pair/ })) await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/experiments/new?task=train_pa&dataset=bench-pair')) - for (const key of ['input', 'virtual', 'output', 'paired']) expect(screen.getByTestId('workflow-' + key)).toHaveAttribute('data-complete', 'true') + for (const key of ['input', 'virtual', 'output']) expect(screen.getByTestId('workflow-' + key)).toHaveAttribute('data-complete', 'true') expect(screen.getByTestId('workflow-pa')).toHaveAttribute('data-complete', 'false') expect(screen.getByTestId('workflow-dpd')).toHaveAttribute('data-complete', 'false') await userEvent.click(await screen.findByRole('button', { name: 'Expand' })) diff --git a/frontend/src/pages/PALibraryPage.tsx b/frontend/src/pages/PALibraryPage.tsx index 1c81e0c..8daa58e 100644 --- a/frontend/src/pages/PALibraryPage.tsx +++ b/frontend/src/pages/PALibraryPage.tsx @@ -1,15 +1,9 @@ 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' import RestartAltIcon from '@mui/icons-material/RestartAlt' import ArrowForwardIcon from '@mui/icons-material/ArrowForward' -import { analyzerLink } from '@/api/signalAnalyzer' -import Accordion from '@mui/material/Accordion' -import AccordionDetails from '@mui/material/AccordionDetails' -import AccordionSummary from '@mui/material/AccordionSummary' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' @@ -25,12 +19,9 @@ import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useEffect, useRef, useState } from 'react' import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router' -import { downloadFile } from '@/api/client' import { useCustomDatasetImports } from '@/api/hooks' -import { paDefaults, parameterKey, paText, usePAInputs, usePASimulation, usePairedDataset, useSimulatePA, useVirtualPAs, - type PAParameter, type PASimulation, type VirtualPA } from '@/api/virtualPA' -import { SpectrumPanels } from '@/components/SpectrumPanels' -import { PlotlyChart } from '@/components/PlotlyChart' +import { paDefaults, paText, usePAInputs, useSimulateDataset, useVirtualPAs, + type PAParameter, type VirtualPA } from '@/api/virtualPA' import { ErrorState, LoadingState } from '@/components/StateBlock' import { formatNumber, t } from '@/i18n' import { useStudioColors } from '@/theme' @@ -97,7 +88,7 @@ function Library({ models }: { models: VirtualPA[] }) { const colors = useStudioColors() const inputs = usePAInputs() const workflow = useStudioWorkflow() - const { selectInput, configurePA, simulated } = workflow + const { selectInput, configurePA } = workflow const [query] = useSearchParams() const navigate = useNavigate() const initialModel = models.find(m => m.model_id === workflow.state.modelId) ?? models.find(m => m.model_id === 'rapp-am-pm') ?? models[0]! @@ -108,28 +99,25 @@ function Library({ models }: { models: VirtualPA[] }) { 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() + const create = useSimulateDataset() const allowed = useCustomDatasetImports() const model = models.find(m => m.model_id === modelId)! const input = inputs.data?.find(entry => entry.signal_id === inputId) const valid = model.parameters.every(p => validParameter(p, values[p.key]!)) - const signature = parameterKey(values) useEffect(() => { if (input && valid) { selectInput(input.signal_id, input.name); configurePA(modelId, values) } }, [input, modelId, valid, selectInput, configurePA, values]) - const saved = usePASimulation(workflow.state.simulationId) - const candidate = simulate.data ?? saved.data - const preview = valid && candidate?.config.input_signal_id === inputId && candidate.config.model_id === modelId - && parameterKey(candidate.config.parameters ?? {}) === signature ? candidate : null - const selectModel = (entry: VirtualPA) => { setModelId(entry.model_id); setValues(paDefaults(entry)); setActive(entry.parameters[0]!.key); setError(null); simulate.reset(); create.reset() } + const batchIds = workflow.state.origin === 'generated' && workflow.state.inputId === inputId && workflow.state.inputIds?.length + ? workflow.state.inputIds : inputId ? [inputId] : [] + const selectModel = (entry: VirtualPA) => { setModelId(entry.model_id); setValues(paDefaults(entry)); setActive(entry.parameters[0]!.key); setError(null); create.reset() } const selectVariable = (key: string) => { setActive(key) } - const download = (url: string) => { setDownloading(true); setError(null); void downloadFile(url).catch(setError).finally(() => setDownloading(false)) } const run = () => { - if (!input || !valid) return + if (!input || !valid || !allowed) return setError(null) - simulate.mutate({ input_signal_id: input.signal_id, model_id: modelId, parameters: values }, { onSuccess: simulated }) + create.mutate({ input_signal_ids: batchIds, model_id: modelId, parameters: values }, { onSuccess: result => { + workflow.completeDataset(result.dataset.dataset_id, String(result.dataset.simulation?.simulation_id ?? '')) + navigate('/datasets/' + encodeURIComponent(result.dataset.dataset_id)) + } }) } return @@ -144,7 +132,7 @@ function Library({ models }: { models: VirtualPA[] }) { {t('paLibrary.chooseModel')} {CATEGORIES.map(category => {t(`paLibrary.category.${category}`)} - {models.filter(m => m.category === category).map(entry => {models.filter(m => m.category === category).map(entry => selectModel(entry)} sx={{ display: 'block', textAlign: 'left', p: 1.25, borderRadius: 1.5, border: '1px solid', borderColor: entry.model_id === modelId ? 'primary.main' : 'divider', bgcolor: entry.model_id === modelId ? colors.selected : 'transparent', '&:focus-visible': { outline: '2px solid ' + colors.primary, outlineOffset: 2 } }}> @@ -156,27 +144,29 @@ function Library({ models }: { models: VirtualPA[] }) { {t('paLibrary.feed')} {inputs.isPending ? : inputs.isError ? void inputs.refetch()} /> : - { setInputId(e.target.value); simulate.reset(); create.reset() }}> + { setInputId(e.target.value); create.reset() }}> {t('paLibrary.chooseInput')} {inputs.data.map(entry => {entry.name} · {formatNumber(entry.n_samples)} I/Q · {(entry.sample_rate_hz / 1e6).toFixed(2)} MHz · {entry.signal_id.slice(3, 11)})} } {input ? {t('paLibrary.inputSummary', { count: formatNumber(input.n_samples), duration: (1000 * input.n_samples / input.sample_rate_hz).toPrecision(5) })} : {t('paLibrary.noInput')}} + {batchIds.length > 1 && {t('paLibrary.batch', { count: batchIds.length })}} + {!allowed && {t('paLibrary.importDisabled')}} + {t('paLibrary.autoDataset')} - - + - {simulate.isPending && } - {candidate && !preview && !simulate.isPending && {t('paLibrary.stale')}} - {(simulate.isError || saved.isError || !!error) && } + {create.isPending && } + {(create.isError || !!error) && } {paText(model.name)}{paText(model.description)} @@ -187,100 +177,21 @@ function Library({ models }: { models: VirtualPA[] }) { {t('paLibrary.equationHelp')} - {t('paLibrary.parameters')} + {t('paLibrary.parameters')} {GROUPS.map(group => { const params = model.parameters.filter(p => (p.group ?? 'gain') === group) return params.length ? {t(`paLibrary.group.${group}`)} {params.map(p => - { workflow.invalidateOutput(); setValues(old => ({ ...old, [key]: value })); simulate.reset(); create.reset() }} /> + { workflow.invalidateOutput(); setValues(old => ({ ...old, [key]: value })); create.reset() }} /> )} : null })} - {preview && <> - - - - - - - - { - create.mutate({ id: preview.simulation_id, request }, { onSuccess: result => { - workflow.paired(result.dataset.dataset_id, preview.simulation_id) - navigate('/experiments/new?task=train_pa&dataset=' + encodeURIComponent(result.dataset.dataset_id)) - } }) - }} /> - - - } - -} - -function PairedDatasetForm({ result, allowed, pending, error, onCreate }: { result: PASimulation; allowed: boolean; pending: boolean; error: unknown; - onCreate: (request: { dataset_id: string; display_name: string; guard_samples: number; train_ratio: number; val_ratio: number }) => void }) { - const [id, setId] = useState('virtual-pa-' + result.simulation_id.slice(4, 16)) - const [name, setName] = useState(paText(result.model.name) + ' · synthetic pair') - const [guard, setGuard] = useState('256'), [train, setTrain] = useState('60'), [val, setVal] = useState('20') - const g = Number(guard), tr = Number(train) / 100, vr = Number(val) / 100 - const n = result.analysis.n_samples - const usable = n - 2 * g - const nTrain = Math.floor(usable * tr), nVal = Math.floor(usable * vr), nTest = usable - nTrain - nVal - const valid = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(id) && name.trim().length > 0 && name.length <= 180 - && guard.trim() !== '' && Number.isInteger(g) && g >= 0 && g <= 10000 && tr > 0 && vr > 0 && tr + vr < 1 - && 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')} - - setId(e.target.value)} /> - setName(e.target.value)} /> - }>{t('paLibrary.splits')} - {([{ label: 'paLibrary.guard', value: guard, set: setGuard }, { label: 'paLibrary.trainRatio', value: train, set: setTrain }, { label: 'paLibrary.valRatio', value: val, set: setVal }] as const).map(field => - field.set(e.target.value)} />)} - {t('paLibrary.splitHelp')} - - {valid ? t('paLibrary.splitCounts', { train: formatNumber(nTrain), val: formatNumber(nVal), test: formatNumber(nTest) }) : t('paLibrary.splitInvalid')} - {!allowed && {t('paLibrary.importDisabled')}} - {!!error && } } - -function PAOutputPlots({ result }: { result: PASimulation }) { - const colors = useStudioColors() - const a = result.analysis - const axis = (text: string) => ({ title: { text } }) - const pair = [colors.primary, colors.status.success] - const legend = { legend: { orientation: 'h' as const, x: 0, y: -.28 }, margin: { l: 60, r: 16, t: 42, b: 90 } } - const number = (v: number | null) => v == null ? '—' : v.toFixed(2) - return - {t('paLibrary.outputTitle')} - - {[[t('paLibrary.samples'), formatNumber(a.n_samples)], [t('paLibrary.rmsGain'), number(a.rms_gain_db) + ' dB'], - [t('paLibrary.inputPapr'), number(a.input_papr_db) + ' dB'], [t('paLibrary.outputPapr'), number(a.output_papr_db) + ' dB']].map(([label, value]) => - {label}{value})} - - - - - f * 1e6)} viewKey={result.simulation_id} height={300} traces={[ - { name: 'PA input x', role: 'input', signal_node: 'pa_input', psdDb: a.input_psd, color: pair[0] }, - { name: 'Virtual PA · synthetic', role: 'primary', signal_node: 'pa_output', psdDb: a.output_psd, color: pair[1] }, - ]} /> - - {Object.entries(a.states).map(([name, values]) => )} - - {t('paLibrary.plotHelp', { count: a.time_us.length, duration: a.duration_ms.toPrecision(5), input: a.input_rms.toPrecision(5), output: a.output_rms.toPrecision(5) })} - {a.notes.map(note => {note})} - -} diff --git a/frontend/src/pages/SignalGeneratorPage.test.tsx b/frontend/src/pages/SignalGeneratorPage.test.tsx index bc6ac1a..7c71236 100644 --- a/frontend/src/pages/SignalGeneratorPage.test.tsx +++ b/frontend/src/pages/SignalGeneratorPage.test.tsx @@ -18,36 +18,60 @@ const result = { } function setup() { - return mockApi({ + const signals: Record = {} + const routes: Parameters[0] = { 'GET /api/v1/signal-generator/presets': () => fixturePresets, 'GET /api/v1/system/capabilities': () => ({ custom_dataset_imports: true }), - 'POST /api/v1/signal-generator/signals': (_url, init) => ({ ...result, config: JSON.parse(String(init.body)) }), - [`POST /api/v1/signal-generator/signals/${result.signal_id}/dataset`]: (_url, init) => ({ dataset: { dataset_id: JSON.parse(String(init.body)).dataset_id }, test_samples: 26112 }), + 'POST /api/v1/signal-generator/batches': (_url, init) => { + const configs = JSON.parse(String(init.body)).configs as typeof config[] + return configs.map((c, i) => { + const id = 'sg-' + (i === 0 ? 'a' : 'b').repeat(64) + signals[id] = { ...result, signal_id: id, config: c } + return { signal_id: id, name: c.preset_id } + }) + }, 'POST /api/v1/signal-generator/validate': (_url, init) => JSON.parse(String(init.body)), - }) + } + for (const letter of ['a', 'b']) { + const id = 'sg-' + letter.repeat(64) + routes['GET /api/v1/signal-generator/signals/' + id] = () => signals[id] + } + return mockApi(routes) } function Probe() { const location = useLocation(); return {location.pathname}{location.search} } -test('explicit preview only, simple family selection, stale-export guard and duration conversion', async () => { +test('matrix selection keeps different preset lengths and disables stale exports', 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.findByRole('heading', { name: 'Signal setup' }) + expect(screen.queryByRole('combobox', { name: 'Preset' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Wi-Fi 8/ })).not.toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: 'Ideal PA-input filter (recommended)' })).toBeChecked() + await userEvent.click(screen.getByRole('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() - expect(screen.getByText('PA Input · PSD')).toBeVisible() - await userEvent.click(screen.getByRole('button', { name: /Wi-Fi 8/ })) - expect(screen.getByText(/Wi-Fi 8 is an experimental/)).toBeVisible() - expect(screen.getByRole('button', { name: 'Export I/Q + configuration' })).toBeDisabled() - expect(screen.getByText(/Parameters changed/)).toBeVisible() + expect(screen.getByRole('button', { name: 'Download PA input CSV' })).toBeEnabled() + await userEvent.click(screen.getByRole('button', { name: /02 ·.*Wi-Fi 6/ })) + await userEvent.click(screen.getByTestId('preset-wifi6-20')) + expect(screen.getByRole('button', { name: 'Download PA input CSV' })).toBeDisabled() await userEvent.click(screen.getByRole('button', { name: 'Elapsed time' })) fireEvent.change(screen.getByLabelText('Equivalent duration (ms)'), { target: { value: '.25' } }) - expect(screen.getByTestId('generator-length')).toHaveTextContent('80,000 I/Q samples') + expect(screen.getByTestId('generator-length')).toHaveTextContent('20,000 I/Q') + await userEvent.click(screen.getByRole('button', { name: 'Generate & preview' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Download PA input CSV' })).toBeEnabled()) + expect(calls.filter(c => c.path === '/api/v1/signal-generator/batches').at(-1)?.body).toMatchObject({ configs: [ + { preset_id: 'nr-20', n_samples: 30720, filter_enabled: true }, + { preset_id: 'wifi6-20', length_mode: 'duration', duration_ms: .25, filter_enabled: true }, + ] }) + await userEvent.click(screen.getByTestId('remove-preset-nr-20')) + expect(screen.getByRole('button', { name: 'Download PA input CSV' })).toBeDisabled() await userEvent.click(screen.getByRole('button', { name: 'Generate & preview' })) - await waitFor(() => expect(screen.getByRole('button', { name: 'Export I/Q + configuration' })).toBeEnabled()) - expect(calls.filter(c => c.path === '/api/v1/signal-generator/signals').at(-1)?.body).toMatchObject({ preset_id: 'wifi8-80', length_mode: 'duration', duration_ms: .25 }) + await waitFor(() => expect(screen.getByRole('button', { name: 'Download PA input CSV' })).toBeEnabled()) + expect(calls.filter(c => c.path === '/api/v1/signal-generator/batches').at(-1)?.body).toEqual({ configs: [expect.objectContaining({ preset_id: 'wifi6-20' })] }) + fireEvent.keyUp(screen.getByTestId('selected-preset-wifi6-20'), { key: 'Delete' }) + expect(screen.getByRole('button', { name: 'Generate & preview' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Download PA input CSV' })).toBeDisabled() }) test('advanced OFDMA channels and pilots reach the generator request', async () => { @@ -65,8 +89,8 @@ test('advanced OFDMA channels and pilots reach the generator request', async () await userEvent.click(screen.getByRole('option', { name: 'Explicit signed bin indices' })) fireEvent.change(screen.getByLabelText('Pilot carrier indices'), { target: { value: '-39, 39' } }) await userEvent.click(screen.getByRole('button', { name: 'Apply & regenerate' })) - await waitFor(() => expect(calls.filter(c => c.path === '/api/v1/signal-generator/signals')).toHaveLength(2)) - expect(calls.filter(c => c.path === '/api/v1/signal-generator/signals').at(-1)?.body).toMatchObject({ channel_subcarriers: [612, 52], channel_modulations: [64, 64], channel_power_db: [0, 0], pilot_mode: 'explicit', pilot_indices: [-39, 39] }) + await waitFor(() => expect(calls.filter(c => c.path === '/api/v1/signal-generator/batches')).toHaveLength(2)) + expect(calls.filter(c => c.path === '/api/v1/signal-generator/batches').at(-1)?.body).toMatchObject({ configs: [{ channel_subcarriers: [612, 52], channel_modulations: [64, 64], channel_power_db: [0, 0], pilot_mode: 'explicit', pilot_indices: [-39, 39] }] }) }) test('generated signal is input-only, with separate exports and an explicit Virtual PA step', async () => { @@ -75,7 +99,7 @@ test('generated signal is input-only, with separate exports and an explicit Virt 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) + expect(screen.getByText(/one input\/output CSV per preset/)).toBeVisible() expect(screen.getByRole('button', { name: 'Download PA input CSV' })).toBeEnabled() expect(screen.getByRole('button', { name: 'Download input metadata JSON' })).toBeEnabled() await userEvent.click(screen.getByRole('link', { name: 'Choose Virtual PA →' })) diff --git a/frontend/src/pages/SignalGeneratorPage.tsx b/frontend/src/pages/SignalGeneratorPage.tsx index c677f06..4169990 100644 --- a/frontend/src/pages/SignalGeneratorPage.tsx +++ b/frontend/src/pages/SignalGeneratorPage.tsx @@ -31,14 +31,15 @@ import { useEffect, useRef, useState } from 'react' import { Link as RouterLink } from 'react-router' import { api, downloadFile } from '@/api/client' import { analyzerLink } from '@/api/signalAnalyzer' -import { useGenerateSignal, useGeneratedSignal, useGeneratorPresets, type GeneratedSignal, type GeneratorConfig, type GeneratorPreset } from '@/api/signalGenerator' +import { useGenerateBatch, useGeneratedSignal, useGeneratorPresets, type GeneratedSignal, type GeneratorConfig, type GeneratorPreset } from '@/api/signalGenerator' import { useStudioWorkflow } from '@/workflow/StudioWorkflow' +import { PresetMatrix } from '@/components/PresetMatrix' import { SignalGeneratorPlots } from '@/components/SignalGeneratorPlots' import { ErrorState, LoadingState } from '@/components/StateBlock' import { formatNumber, t, type MessageKey } from '@/i18n' import { useStudioColors } from '@/theme' -const FAMILIES = ['nr', 'wifi6', 'wifi7', 'wifi8', 'custom'] as const +const FAMILIES = ['nr', 'wifi6', 'wifi7', 'custom'] as const const ORDERS = [2, 4, 16, 64, 256, 1024, 4096] const modulation = (m: number) => m === 2 ? 'BPSK' : m === 4 ? 'QPSK' : `${m}-QAM` function NumberField({ label, value, onChange, unit = '', help }: { label: MessageKey; value: number; onChange: (value: number) => void; unit?: string; help?: string }) { @@ -61,33 +62,62 @@ export function SignalGeneratorPage() { function Generator({ presets, saved }: { presets: GeneratorPreset[]; saved?: GeneratedSignal }) { const colors = useStudioColors() - const { selectInput } = useStudioWorkflow() - const generate = useGenerateSignal() - const [config, setConfig] = useState(() => (saved?.config ?? presets[0]!.config) as GeneratorConfig) + const workflow = useStudioWorkflow() + const generate = useGenerateBatch() + const initial = workflow.state.origin === 'generated' && workflow.state.inputConfigs?.length + ? workflow.state.inputConfigs : [saved?.config ?? presets[0]!.config] as GeneratorConfig[] + const [configs, setConfigs] = useState>(() => Object.fromEntries(initial.map(c => [c.preset_id, c]))) + const [activeId, setActiveId] = useState(initial[0]!.preset_id) + const config = configs[activeId] ?? initial[0]! + const [family, setFamily] = useState(presets.find(p => p.preset_id === activeId)?.family ?? 'custom') const [advanced, setAdvanced] = useState(false) const [error, setError] = useState(null) const [downloading, setDownloading] = useState(false) - const [pilotText, setPilotText] = useState('') - const preset = presets.find(p => p.preset_id === config.preset_id) - const family = preset?.family ?? 'custom' + const [pilotText, setPilotText] = useState((config.pilot_indices ?? []).join(', ')) + const selected = Object.keys(configs) + const selectedConfigs = Object.values(configs) + const [generatedIds, setGeneratedIds] = useState>(() => { + const ids = workflow.state.inputIds ?? (saved ? [saved.signal_id] : []) + return Object.fromEntries(initial.flatMap((c, i) => ids[i] ? [[c.preset_id, ids[i]!]] : [])) + }) + const [generatedKey, setGeneratedKey] = useState(Object.keys(generatedIds).length ? JSON.stringify(initial) : '') + const preview = useGeneratedSignal(generatedIds[activeId] ?? null) + const result = preview.data ?? (saved?.config.preset_id === activeId ? saved : undefined) + const stale = generatedKey !== JSON.stringify(selectedConfigs) 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) - useEffect(() => { - if (result && !stale) selectInput(result.signal_id, result.config.preset_id) - }, [result, stale, selectInput]) const count = config.length_mode === 'samples' ? config.n_samples : Math.floor(config.sample_rate_hz * config.duration_ms / 1000 + .5) const duration = count / config.sample_rate_hz * 1000 const spacing = config.sample_rate_hz / (config.fft_size * config.oversampling) - const validNumbers = Object.values(config).every(value => typeof value !== 'number' || Number.isFinite(value)) - && [...config.channel_subcarriers, ...config.channel_power_db, ...config.pilot_indices].every(Number.isFinite) + const counts = selectedConfigs.map(c => c.length_mode === 'samples' ? c.n_samples : Math.floor(c.sample_rate_hz * c.duration_ms / 1000 + .5)) + const totalSamples = counts.reduce((sum, n) => sum+n, 0) + const validLengths = counts.every(n => Number.isInteger(n) && n >= 256 && n <= 1_000_000) + const validNumbers = selectedConfigs.every(c => Object.values(c).every(value => typeof value !== 'number' || Number.isFinite(value)) + && [...c.channel_subcarriers, ...c.channel_power_db, ...c.pilot_indices].every(Number.isFinite)) + const setConfig = (update: GeneratorConfig | ((old: GeneratorConfig) => GeneratorConfig)) => setConfigs(old => ({ ...old, [activeId]: typeof update === 'function' ? update(old[activeId] ?? config) : update })) const change = (key: K, value: GeneratorConfig[K]) => setConfig(old => ({ ...old, [key]: value })) - const select = (entry: GeneratorPreset) => { - setConfig({ ...entry.config, seed: config.seed, length_mode: config.length_mode, n_samples: config.n_samples, duration_ms: config.duration_ms } as GeneratorConfig) - setPilotText((entry.config.pilot_indices ?? []).join(', ')); setError(null) + const activate = (id: string) => { setActiveId(id); setPilotText((configs[id]?.pilot_indices ?? []).join(', ')) } + const remove = (id: string) => { + const next = { ...configs }; delete next[id]; setConfigs(next) + if (id === activeId) { const remaining = Object.keys(next)[0]; if (remaining) activate(remaining) } + } + const toggle = (entry: GeneratorPreset) => { + if (configs[entry.preset_id]) remove(entry.preset_id) + else if (selected.length < 16) { + setConfigs(old => ({ ...old, [entry.preset_id]: entry.config as GeneratorConfig })) + setActiveId(entry.preset_id); setPilotText((entry.config.pilot_indices ?? []).join(', ')) + } + setError(null) } const numeric = (key: keyof GeneratorConfig, label: MessageKey, unit = '', scale = 1, help?: string) => change(key, value * scale as never)} help={help} /> + const run = () => { + setError(null) + generate.mutate(selectedConfigs, { onSuccess: entries => { + setGeneratedIds(Object.fromEntries(entries.map(e => [e.name, e.signal_id]))) + setGeneratedKey(JSON.stringify(selectedConfigs)) + workflow.selectInputs(entries.map(e => e.signal_id), selectedConfigs) + } }) + } const exportConfig = () => { const blob = URL.createObjectURL(new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' })) const link = document.createElement('a'); link.href = blob; link.download = 'signal-config.json'; link.click(); window.setTimeout(() => URL.revokeObjectURL(blob), 1000) @@ -96,48 +126,67 @@ function Generator({ presets, saved }: { presets: GeneratorPreset[]; saved?: Gen try { if (file.size > 65536) throw new Error(t('generator.configTooLarge')) const checked = await api.post('/signal-generator/validate', JSON.parse(await file.text())) - setConfig(checked); setPilotText(checked.pilot_indices.join(', ')); setAdvanced(true); setError(null) + if (!configs[checked.preset_id] && selected.length >= 16) throw new Error(t('generator.selectionLimit')) + setConfigs(old => ({ ...old, [checked.preset_id]: checked })); setActiveId(checked.preset_id) + setFamily(presets.find(p => p.preset_id === checked.preset_id)?.family ?? 'custom') + setPilotText(checked.pilot_indices.join(', ')); setAdvanced(true); setError(null) } catch (e) { setError(e) } } - return + const download = (url: string) => { setDownloading(true); void downloadFile(url).catch(setError).finally(() => setDownloading(false)) } + return {t('generator.title')}{t('generator.intro')} - - {FAMILIES.map((key, index) => { const match = presets.find(p => p.family === key); if (match) select(match) }} disabled={generate.isPending} aria-pressed={family === key} sx={{ textAlign: 'left', display: 'block', p: { xs: 1.5, lg: 2 }, border: 1, borderColor: family === key ? 'primary.main' : 'divider', borderRadius: 1.5, bgcolor: family === key ? colors.selected : 'background.paper', '&:hover': { borderColor: 'primary.main' }, '&.Mui-focusVisible': { outline: '3px solid', outlineColor: 'primary.main', outlineOffset: 2 } }}> - 0{index + 1} · {t(`generator.familyHint.${key}`)} - {t(`generator.family.${key}`)} + + {FAMILIES.map((key, index) => setFamily(key)} disabled={generate.isPending} aria-pressed={family === key} sx={{ textAlign: 'left', display: 'block', p: 1.5, border: 1, borderColor: family === key ? 'primary.main' : 'divider', borderRadius: 1.5, bgcolor: family === key ? colors.selected : 'background.paper', '&:hover': { borderColor: 'primary.main' }, '&.Mui-focusVisible': { outline: '3px solid', outlineColor: 'primary.main', outlineOffset: 2 } }}> + 0{index+1} · {t(`generator.familyHint.${key}`)} + {t(`generator.family.${key}`)} )} - {t(family === 'wifi8' ? 'generator.wifi8Scope' : 'generator.scopeHelp')} - {family === 'custom' && - {presets.filter(p => p.family === 'custom').map(entry => )} - } - - - {result && {t('generator.next')} - {t('paInput.help')} - - - - - - - } - - {t('generator.setup')} - { const entry = presets.find(p => p.preset_id === e.target.value); if (entry) select(entry) }}> - {presets.filter(p => p.family === family).map(p => {p.label})} - - {ofdm && } - { if (value) change('length_mode', value) }}>{t('generator.bySamples')}{t('generator.byDuration')} - {config.length_mode === 'samples' ? numeric('n_samples', 'generator.samples') : numeric('duration_ms', 'generator.duration', 'ms')} - {Number.isFinite(count) ? formatNumber(count) : '—'} {t('generator.samplesUnit')} · {Number.isFinite(duration) ? duration.toPrecision(5) : '—'} ms - {(count < 256 || count > 1_000_000) && {t('generator.lengthLimit')}} - - {generate.isPending && } - + + {t('generator.setup')} + {family === 'custom' ? + {presets.filter(p => p.family === 'custom').map(entry => )} + : p.family === family)} selected={selected} disabled={generate.isPending} toggle={toggle} />} + {t('generator.scopeHelp')} + + {selected.map(id => p.preset_id === id)?.label ?? id} color={activeId === id ? 'primary' : 'default'} variant={activeId === id ? 'filled' : 'outlined'} onClick={() => activate(id)} onDelete={() => remove(id)} data-testid={'selected-preset-' + id} + deleteIcon={ p.preset_id === id)?.label ?? id })} />} disabled={generate.isPending} />)} + + {!!selected.length && + {t('generator.editSelected')} · {config.sample_rate_hz / 1e6} MS/s · {config.bandwidth_hz / 1e6} MHz {ofdm && ` · ${(spacing / 1000).toPrecision(4)} kHz SCS`} + + { if (value) change('length_mode', value) }}>{t('generator.bySamples')}{t('generator.byDuration')} + {config.length_mode === 'samples' ? numeric('n_samples', 'generator.samples') : numeric('duration_ms', 'generator.duration', 'ms')} + {Number.isFinite(count) ? formatNumber(count) : '—'} I/Q · {Number.isFinite(duration) ? duration.toPrecision(5) : '—'} ms + + change('filter_enabled', checked)} />} /> + {t('generator.filterHelp')} + } + + + {t('generator.totalSamples', { count: formatNumber(totalSamples) })} + + {!validLengths && {t('generator.lengthLimit')}} + {(selected.length >= 16 || totalSamples > 4_000_000) && {t('generator.selectionLimit')}} + {generate.isPending && } + {(generate.isError || !!error) && } + + + {t('generator.next')} + {t('generator.batchNext')} + + + + + + + + {stale && !!Object.keys(generatedIds).length && {t('generator.stale')}} + + + setAdvanced(value)} disableGutters> }>{t('generator.advanced')} @@ -195,13 +244,12 @@ function Generator({ presets, saved }: { presets: GeneratorPreset[]; saved?: Gen {config.snr_db !== null && numeric('snr_db', 'generator.snr', 'dB')} change('clip_db', enabled ? 8 : null)} />} /> {config.clip_db !== null && numeric('clip_db', 'generator.clipLevel', 'dB')} - + - {(generate.isError || !!error) && } - - {result ? : {t(generate.isPending ? 'generator.generating' : 'generator.generate')}{t(generate.isError ? 'generator.checkParameters' : 'generator.firstPreview')}} + + {preview.isError ? void preview.refetch()} /> : result ? : preview.isFetching ? : {t('generator.firstPreview')}} } diff --git a/frontend/src/workflow/StudioWorkflow.tsx b/frontend/src/workflow/StudioWorkflow.tsx index 3f8a5f5..2ad2955 100644 --- a/frontend/src/workflow/StudioWorkflow.tsx +++ b/frontend/src/workflow/StudioWorkflow.tsx @@ -2,12 +2,15 @@ import { createContext, useContext, useEffect, useMemo, useState, type ReactNode import { useCapabilities, useRun, useRunConfig } from '@/api/hooks' import { WEB_MODE } from '@/api/client' import type { RunView } from '@/api/types' +import type { GeneratorConfig } from '@/api/signalGenerator' import { parameterKey, type PASimulation } from '@/api/virtualPA' import { LoadingState } from '@/components/StateBlock' export interface WorkflowState { version: 1 origin: 'generated' | 'existing' | null + inputIds?: string[] + inputConfigs?: GeneratorConfig[] inputId: string | null inputName: string modelId: string | null @@ -23,6 +26,9 @@ const empty = (): WorkflowState => ({ version: 1, origin: null, inputId: null, i interface WorkflowActions { selectInput: (id: string, name: string) => void + selectInputs: (ids: string[], configs: GeneratorConfig[]) => void + completeDataset: (id: string, simulationId: string) => void + selectCapture: (id: string, version: string) => void configurePA: (id: string, parameters: Record) => void simulated: (result: PASimulation) => void paired: (datasetId: string, simulationId: string) => void @@ -40,7 +46,7 @@ interface WorkflowContextValue extends WorkflowActions { } const noop = () => undefined const fallback: WorkflowContextValue = { state: empty(), paDone: false, dpdDone: false, - selectInput: noop, configurePA: noop, simulated: noop, paired: noop, selectDataset: noop, + selectInputs: noop, completeDataset: noop, selectCapture: noop, selectInput: noop, configurePA: noop, simulated: noop, paired: noop, selectDataset: noop, selectPAReference: noop, invalidateOutput: noop, resetPA: noop, trackRun: noop, reset: noop } const Context = createContext(fallback) export const useStudioWorkflow = () => useContext(Context) @@ -49,6 +55,8 @@ function read(key: string): WorkflowState { try { const value = JSON.parse((WEB_MODE ? sessionStorage : localStorage).getItem(key) ?? 'null') as WorkflowState | null if (!value || value.version !== 1 || !['generated', 'existing', null].includes(value.origin)) return empty() + if (value.inputIds && (!Array.isArray(value.inputIds) || value.inputIds.length > 16 || value.inputIds.some(id => !/^sg-[a-f0-9]{64}$/.test(id)))) return empty() + if (value.inputConfigs && (!Array.isArray(value.inputConfigs) || value.inputConfigs.length !== value.inputIds?.length)) return empty() if (value.inputId && !/^sg-[a-f0-9]{64}$/.test(value.inputId)) return empty() if (value.simulationId && !/^vpa-[a-f0-9]{64}$/.test(value.simulationId)) return empty() if (value.datasetId && !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(value.datasetId)) return empty() @@ -73,6 +81,10 @@ function ScopedWorkflow({ scope, children }: { scope: string; children: ReactNod && dpdConfig.data?.dataset.id === state.datasetId && (dpdConfig.data?.dataset.preprocessing_version ?? 'raw-v1') === state.datasetVersion && dpdConfig.data?.pa_reference?.run_id === state.paRunId const actions = useMemo(() => ({ + selectInputs: (ids, configs) => set(old => ({ ...empty(), origin: 'generated', inputId: ids[0] ?? null, + inputIds: ids, inputConfigs: configs, inputName: configs.map(c => c.preset_id).join(', '), modelId: old.modelId, parameters: old.parameters })), + selectCapture: (id, version) => set(old => old.datasetId === id && old.datasetVersion === version ? old : { ...old, datasetId: id, datasetVersion: version, paRunId: null, dpdRunId: null }), + completeDataset: (id, simulationId) => set(old => ({ ...old, datasetId: id, simulationId, datasetVersion: 'raw-v1', paRunId: null, dpdRunId: null })), selectInput: (id, name) => set(old => old.origin === 'generated' && old.inputId === id ? old : { ...empty(), origin: 'generated', inputId: id, inputName: name, modelId: old.modelId, parameters: old.parameters }), configurePA: (id, parameters) => set(old => old.origin === 'generated' && old.modelId === id && parameterKey(old.parameters) === parameterKey(parameters) ? old @@ -88,7 +100,7 @@ function ScopedWorkflow({ scope, children }: { scope: string; children: ReactNod selectPAReference: id => set(old => old.paRunId === id ? old : { ...old, paRunId: id, dpdRunId: null }), invalidateOutput: () => set(old => !old.simulationId && !old.datasetId && !old.paRunId && !old.dpdRunId ? old : { ...old, simulationId: null, datasetId: null, paRunId: null, dpdRunId: null }), - resetPA: () => set(old => ({ ...empty(), origin: old.inputId ? 'generated' : null, inputId: old.inputId, inputName: old.inputName })), + resetPA: () => set(old => ({ ...empty(), origin: old.inputId ? 'generated' : null, inputId: old.inputId, inputIds: old.inputIds, inputConfigs: old.inputConfigs, inputName: old.inputName })), trackRun: (run, version, paRunId) => set(old => { if (!run.dataset_id) return old const base = old.datasetId === run.dataset_id && old.datasetVersion === version ? old diff --git a/mkdocs.yml b/mkdocs.yml index 5c9e74b..f341a52 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ nav: - Installation: install.md - First Studio experiment: tutorials/gui-quickstart.md - Signal Generator: guides/signal-generator.md + - Signal preset reference: guides/signal-presets.md - Signal Analyzer: guides/signal-analyzer.md - Virtual PA Library: guides/virtual-pa-library.md - ILC and ILA DPD: guides/ilc-dpd.md diff --git a/opendpd/__init__.py b/opendpd/__init__.py index 18896e3..81c72b0 100644 --- a/opendpd/__init__.py +++ b/opendpd/__init__.py @@ -8,7 +8,7 @@ Website: https://www.tudemi.com """ -__version__ = "2.2.10" +__version__ = "2.2.11" __author__ = "Yizhuo Wu, Ang Li, Chang Gao" __license__ = "Apache-2.0" __email__ = "chang.gao@tudelft.nl" diff --git a/opendpd/core/virtual_pa.py b/opendpd/core/virtual_pa.py index ad0593e..e5d2304 100644 --- a/opendpd/core/virtual_pa.py +++ b/opendpd/core/virtual_pa.py @@ -7,7 +7,7 @@ import math import numpy as np -from scipy.signal import lfilter, welch +from scipy.signal import welch from opendpd.schemas.virtual_pa import PALocalizedText, PAParameter, VirtualPAModel, PAAnalysis @@ -236,74 +236,10 @@ def resolve(model_id, supplied): return model, values -def _relax(target, tau_us, fs): - b = -np.expm1(-1 / (fs * tau_us * 1e-6)) - return lfilter([b], [1, -(1-b)], target) - - -def _rapp(x, gain, saturation, smoothness): - return gain * x / np.power(1 + np.power(gain * np.abs(x) / saturation, 2*smoothness), 1/(2*smoothness)) - - def simulate(x, fs, model_id, parameters): - """Causal, cold-start complex-envelope output, exactly one output per input.""" - _, p = resolve(model_id, parameters) - x = np.asarray(x, dtype=np.complex128) - if x.ndim != 1 or not len(x) or len(x) > 1_000_000 or not np.isfinite(x).all(): - raise ValueError("Use a finite one-dimensional PA input with at most 1,000,000 samples.") - if not math.isfinite(fs) or fs <= 0: - raise ValueError("The input sample rate must be positive.") - r = np.abs(x) - states = {} - if model_id == "linear-reference": - y = p["gain"] * x * np.exp(1j*np.deg2rad(p["phase_deg"])) - elif model_id == "saleh-twta": - y = p["gain"]*x / (1+p["compression"]*r*r) * np.exp(1j*p["phase"]*r*r/(1+p["phase_scale"]*r*r)) - elif model_id in ("memory-polynomial", "generalized-memory"): - u = p["gain"]*x + p["cubic"]*x*r*r + p["quintic"]*x*r**4 - taps = np.arange(1, int(p["depth"])+1) - weights = p["decay"]**(taps-1) - weights /= weights.sum() - phase = np.exp(1j*taps*np.deg2rad(p["memory_phase"])) - y = lfilter(np.r_[1, p["memory"]*weights*phase], [1], u) - if model_id == "generalized-memory": - q = lfilter(np.r_[0, weights], [1], r*r) - y += p["cross_memory"]*x*q - states["delayed_power"] = q - else: - y = _rapp(x, p["gain"], p["saturation"], p["smoothness"]) - if model_id == "rapp-am-pm": - y *= np.exp(1j*p["phase"]*r*r / (r*r+p["phase_scale"]**2)) - elif model_id == "gan-trap-thermal": - power = r*r / (r*r+(p["saturation"]/p["gain"])**2) - temperature = p["ambient_c"] + p["heating_c"]*_relax(power, p["thermal_us"], fs) - release = p["release_us"]*np.exp(p["activation_ev"]/8.617333262e-5 * - (1/(temperature+273.15) - 1/298.15)) - charge_b = -np.expm1(-1/(fs*p["capture_us"]*1e-6)) - release_b = -np.expm1(-1/(fs*release*1e-6)) - trap = np.empty(len(x)) - state = 0. - for n, target in enumerate(power): - b = charge_b if target >= state else release_b[n] - state += b*(target-state) - trap[n] = state - supply = 1-p["ir_drop"]*_relax(power, p["bias_us"], fs) - y *= (1-p["trap_strength"]*trap)*supply * np.exp( - -p["thermal_gain"]*(temperature-25) + 1j*p["trap_phase"]*trap) - states = {"trap_occupancy": trap, "effective_temperature_c": temperature, "supply_fraction": supply} - elif model_id == "doherty-two-path": - peaker = np.divide(x, r, out=np.zeros_like(x), where=r>0)*np.maximum(r-p["knee"], 0) - y += p["peaker"]*_rapp(peaker, p["gain"], p["saturation"], p["smoothness"]) * np.exp(1j*np.deg2rad(p["phase_deg"])) - elif model_id == "envelope-tracking": - demand = np.minimum(p["gain"]*r/p["saturation"], 1) - power = r*r/(r*r+(p["saturation"]/p["gain"])**2) - supply = np.clip(p["supply_floor"] + p["supply_span"]*_relax(demand, p["tracking_us"], fs) - - p["ir_drop"]*_relax(power, p["bias_us"], fs), p["supply_floor"], 1.5) - y = _rapp(x, p["gain"], p["saturation"]*supply, p["smoothness"]) * np.exp(1j*p["phase"]*(supply-1)) - states = {"supply_fraction": supply, "envelope_demand": demand} - if not np.isfinite(y).all() or np.max(np.abs(y)) > np.finfo(np.float32).max: - raise ValueError("These parameters exceed the finite float32 output range.") - return y, states + from opendpd.core.virtual_pa_kernel import simulate_resolved + _, values = resolve(model_id, parameters) + return simulate_resolved(x, fs, model_id, values) def analyze(x, y, fs, states, parameters): diff --git a/opendpd/core/virtual_pa_kernel.py b/opendpd/core/virtual_pa_kernel.py new file mode 100644 index 0000000..4450f51 --- /dev/null +++ b/opendpd/core/virtual_pa_kernel.py @@ -0,0 +1,74 @@ +"""Portable complex-envelope PA equations used by simulation and dataset exports.""" +import math + +import numpy as np +from scipy.signal import lfilter + + +def _relax(target, tau_us, fs): + b = -np.expm1(-1 / (fs * tau_us * 1e-6)) + return lfilter([b], [1, -(1-b)], target) + + +def _rapp(x, gain, saturation, smoothness): + return gain * x / np.power(1 + np.power(gain * np.abs(x) / saturation, 2*smoothness), 1/(2*smoothness)) + + +def simulate_resolved(x, fs, model_id, p): + """Causal, cold-start complex-envelope output, exactly one output per input.""" + x = np.asarray(x, dtype=np.complex128) + if x.ndim != 1 or not len(x) or len(x) > 1_000_000 or not np.isfinite(x).all(): + raise ValueError("Use a finite one-dimensional PA input with at most 1,000,000 samples.") + if not math.isfinite(fs) or fs <= 0: + raise ValueError("The input sample rate must be positive.") + r = np.abs(x) + states = {} + if model_id == "linear-reference": + y = p["gain"] * x * np.exp(1j*np.deg2rad(p["phase_deg"])) + elif model_id == "saleh-twta": + y = p["gain"]*x / (1+p["compression"]*r*r) * np.exp(1j*p["phase"]*r*r/(1+p["phase_scale"]*r*r)) + elif model_id in ("memory-polynomial", "generalized-memory"): + u = p["gain"]*x + p["cubic"]*x*r*r + p["quintic"]*x*r**4 + taps = np.arange(1, int(p["depth"])+1) + weights = p["decay"]**(taps-1) + weights /= weights.sum() + phase = np.exp(1j*taps*np.deg2rad(p["memory_phase"])) + y = lfilter(np.r_[1, p["memory"]*weights*phase], [1], u) + if model_id == "generalized-memory": + q = lfilter(np.r_[0, weights], [1], r*r) + y += p["cross_memory"]*x*q + states["delayed_power"] = q + else: + y = _rapp(x, p["gain"], p["saturation"], p["smoothness"]) + if model_id == "rapp-am-pm": + y *= np.exp(1j*p["phase"]*r*r / (r*r+p["phase_scale"]**2)) + elif model_id == "gan-trap-thermal": + power = r*r / (r*r+(p["saturation"]/p["gain"])**2) + temperature = p["ambient_c"] + p["heating_c"]*_relax(power, p["thermal_us"], fs) + release = p["release_us"]*np.exp(p["activation_ev"]/8.617333262e-5 * + (1/(temperature+273.15) - 1/298.15)) + charge_b = -np.expm1(-1/(fs*p["capture_us"]*1e-6)) + release_b = -np.expm1(-1/(fs*release*1e-6)) + trap = np.empty(len(x)) + state = 0. + for n, target in enumerate(power): + b = charge_b if target >= state else release_b[n] + state += b*(target-state) + trap[n] = state + supply = 1-p["ir_drop"]*_relax(power, p["bias_us"], fs) + y *= (1-p["trap_strength"]*trap)*supply * np.exp( + -p["thermal_gain"]*(temperature-25) + 1j*p["trap_phase"]*trap) + states = {"trap_occupancy": trap, "effective_temperature_c": temperature, "supply_fraction": supply} + elif model_id == "doherty-two-path": + peaker = np.divide(x, r, out=np.zeros_like(x), where=r>0)*np.maximum(r-p["knee"], 0) + y += p["peaker"]*_rapp(peaker, p["gain"], p["saturation"], p["smoothness"]) * np.exp(1j*np.deg2rad(p["phase_deg"])) + elif model_id == "envelope-tracking": + demand = np.minimum(p["gain"]*r/p["saturation"], 1) + power = r*r/(r*r+(p["saturation"]/p["gain"])**2) + supply = np.clip(p["supply_floor"] + p["supply_span"]*_relax(demand, p["tracking_us"], fs) + - p["ir_drop"]*_relax(power, p["bias_us"], fs), p["supply_floor"], 1.5) + y = _rapp(x, p["gain"], p["saturation"]*supply, p["smoothness"]) * np.exp(1j*p["phase"]*(supply-1)) + states = {"supply_fraction": supply, "envelope_demand": demand} + if not np.isfinite(y).all() or np.max(np.abs(y)) > np.finfo(np.float32).max: + raise ValueError("These parameters exceed the finite float32 output range.") + return y, states diff --git a/opendpd/core/waveforms/generator.py b/opendpd/core/waveforms/generator.py index c850883..72e1889 100644 --- a/opendpd/core/waveforms/generator.py +++ b/opendpd/core/waveforms/generator.py @@ -181,6 +181,22 @@ def synthesize(config: GeneratorConfig): if config.snr_db is not None: noise_rms = config.rms * 10**(-config.snr_db/20) x += noise_rms/np.sqrt(2) * (noise.standard_normal(n) + 1j*noise.standard_normal(n)) + if config.filter_enabled: + # A periodic-record zero-phase low-pass retains the exact requested length. + f = np.fft.fftfreq(n, 1 / config.sample_rate_hz) - config.frequency_offset_hz + f = (f + config.sample_rate_hz / 2) % config.sample_rate_hz - config.sample_rate_hz / 2 + edge = np.abs(f) / (config.bandwidth_hz / 2) + transition = np.clip((edge - .96) / .04, 0, 1) + response = .5 * (1 + np.cos(np.pi * transition)) + response[edge >= 1] = 0 + before = np.mean(np.abs(x)**2) + x = np.fft.ifft(np.fft.fft(x) * response) + after = np.mean(np.abs(x)**2) + if after <= before * 1e-20: + raise ValueError("The configured signal lies outside the baseband filter. Adjust bandwidth or disable filtering.") + correction = np.sqrt(before / after) + x *= correction + scale *= correction x = x.astype(np.complex64) recovered, references = [], [] evm_percent = None @@ -255,8 +271,8 @@ def synthesize(config: GeneratorConfig): notes.append("Payload bits repeat deterministically, most-significant bit first, with Gray-labeled modulation. PRBS uses an all-ones initial state; the random seed still controls pilots and impairments.") if trailing: notes.append(f"Exact requested length retained: {trailing} samples from the final incomplete symbol. No extra samples are exported.") - if config.preset_id.startswith("wifi8"): - notes.append("Wi-Fi 8 / IEEE 802.11bn is an experimental numerology profile; no draft-specific UHR features are implemented.") + if config.filter_enabled: + notes.append("Ideal PA-input filter: zero-phase periodic-record FFT low-pass, cosine transition from 96% to 100% of the nominal half-bandwidth, centered on the frequency offset. Applied after impairments; RMS preserved. Filtering can change EVM, peaks and burst edges. Welch window leakage is not the stop-band floor.") analysis = GeneratorAnalysis(sample_count=n, duration_ms=n/config.sample_rate_hz*1000, sample_rate_hz=config.sample_rate_hz, subcarrier_spacing_hz=spacing, useful_symbol_us=(1e6/spacing if spacing else None), cp_lengths_samples=cp_lengths, diff --git a/opendpd/core/waveforms/generator_presets.py b/opendpd/core/waveforms/generator_presets.py index a65cc0b..03cf384 100644 --- a/opendpd/core/waveforms/generator_presets.py +++ b/opendpd/core/waveforms/generator_presets.py @@ -1,35 +1,73 @@ -"""Engineering stimuli with documented NR / WLAN numerologies, not encoded packets.""" +"""Uncoded engineering stimuli with traceable NR / WLAN numerology tables.""" +from functools import lru_cache +import math + from opendpd.schemas.signal_generator import GeneratorConfig, GeneratorPreset +# TS 38.104 V18.9.0 tables 5.3.2-1 / 5.3.2-2. See docs/guides/signal-presets.md. +_NR_BANDWIDTHS = (3, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100) +_NR_RBS = { + 15: (15, 25, 52, 79, 106, 133, 160, 188, 216, 242, 270, 0, 0, 0, 0, 0), + 30: (0, 11, 24, 38, 51, 65, 78, 92, 106, 119, 133, 162, 189, 217, 245, 273), + 60: (0, 0, 11, 18, 24, 31, 38, 44, 51, 58, 65, 79, 93, 107, 121, 135), +} +_WLAN_TONES = {20: (242, 106, 52, 26), 40: (484, 242, 106, 52), + 80: (996, 484, 242, 106), 160: (1992, 996, 484, 242), + 320: (3984, 1992, 996, 484)} -def presets() -> list[GeneratorPreset]: + +@lru_cache(maxsize=1) +def _catalog() -> tuple[GeneratorPreset, ...]: items = [] - def add(identifier, family, label, description, **values): + def add(identifier, family, label, description, numerology="Custom", **values): + rate = values.get("sample_rate_hz", 80e6) + values.setdefault("n_samples", max(16384, min(196608, round(rate * .00025)))) + config = GeneratorConfig(preset_id=identifier, **values) items.append(GeneratorPreset(preset_id=identifier, family=family, label=label, - description=description, config=GeneratorConfig(preset_id=identifier, **values))) - - for bandwidth, rate, fft, carriers in [(20, 30.72, 1024, 612), (100, 122.88, 4096, 3276)]: - add(f"nr-{bandwidth}", "nr", f"FR1 · {bandwidth} MHz", "30 kHz SCS · normal CP · 64-QAM", - sample_rate_hz=rate*4e6, bandwidth_hz=bandwidth*1e6, fft_size=fft, - channel_subcarriers=[carriers], cp_mode="nr_normal", pilot_spacing=12) - add("nr-fr2", "nr", "FR2 · 100 MHz", "120 kHz SCS · normal CP · 64-QAM", - sample_rate_hz=491.52e6, bandwidth_hz=100e6, carrier_frequency_hz=28e9, - fft_size=1024, channel_subcarriers=[792], cp_mode="nr_normal", pilot_spacing=12) - for family, order, bandwidths in [("wifi6", 1024, (20, 40, 80, 160)), ("wifi7", 4096, (20, 40, 80, 160, 320)), ("wifi8", 4096, (80, 160, 320))]: + description=description, numerology=numerology, + channel_count=len(config.channel_subcarriers), config=config)) + + nr_tables = [("FR1", scs, tuple(zip(_NR_BANDWIDTHS, rbs))) for scs, rbs in _NR_RBS.items()] + nr_tables += [("FR2-1", 60, ((50, 66), (100, 132), (200, 264))), + ("FR2-1", 120, ((50, 32), (100, 66), (200, 132), (400, 264)))] + for region, scs, bands in nr_tables: + for bandwidth, rbs in bands: + if not rbs: + continue + fft = 2**math.ceil(math.log2(bandwidth * 1000 / scs)) + for count in (1, 2, 4, 8): + for order in (4, 16, 64, 256, 1024): + identifier = f"nr-{region.lower()}-{scs}-{bandwidth}-q{order}-c{count}" + if region == "FR1" and scs == 30 and count == 1 and order == 64 and bandwidth in (20, 100): + identifier = f"nr-{bandwidth}" + if region == "FR2-1" and scs == 120 and bandwidth == 100 and count == 1 and order == 64: + identifier = "nr-fr2" + add(identifier, "nr", f"{region} · {bandwidth} MHz · {order}-QAM · {count} ch", + f"{scs} kHz SCS · normal CP · {rbs // count} RB per channel · uncoded payload", + numerology=f"{region} · {scs} kHz", sample_rate_hz=fft*scs*4000, + bandwidth_hz=bandwidth*1e6, carrier_frequency_hz=3.5e9 if region == "FR1" else 28e9, + fft_size=fft, channel_subcarriers=[rbs//count*12]*count, + channel_modulations=[order]*count, channel_power_db=[0.]*count, + cp_mode="nr_normal", pilot_spacing=12) + for family, bandwidths, orders in [("wifi6", (20, 40, 80, 160), (2, 4, 16, 64, 256, 1024)), + ("wifi7", (20, 40, 80, 160, 320), (2, 4, 16, 64, 256, 1024, 4096))]: for bandwidth in bandwidths: - # Continuous payload numerology. Multiple 996-tone allocations at 160/320 MHz - # have generic gaps and pilots; no standard RU bitmap or signaling is claimed. - blocks = max(1, bandwidth // 80) - carriers = {20: 242, 40: 484}.get(bandwidth, 996) - add(f"{family}-{bandwidth}", family, f"{bandwidth} MHz · {order}-QAM", - "78.125 kHz SCS · 0.8 µs guard interval · continuous OFDM payload", - sample_rate_hz=bandwidth*4e6, bandwidth_hz=bandwidth*1e6, - carrier_frequency_hz=5.8e9 if family == "wifi6" else 6.1e9, - fft_size=bandwidth*256//20, cp_samples=bandwidth*16//20, - channel_subcarriers=[carriers]*blocks, channel_modulations=[order]*blocks, - channel_power_db=[0.]*blocks, channel_gap_bins=4 if blocks > 1 else 0, - pilot_spacing=16) + for count, tones in zip((1, 2, 4, 8), _WLAN_TONES[bandwidth]): + for order in orders: + identifier = f"{family}-{bandwidth}-q{order}-c{count}" + if count == 1 and order == orders[-1]: + identifier = f"{family}-{bandwidth}" + # RU-sized allocations use generic placement/pilots, not packet RU bitmaps. + add(identifier, family, f"{bandwidth} MHz · {order}-QAM · {count} ch", + f"78.125 kHz SCS · 0.8 µs guard · {tones} tones per channel · uncoded payload", + numerology="HE · 78.125 kHz" if family == "wifi6" else "EHT · 78.125 kHz", + sample_rate_hz=bandwidth*4e6, bandwidth_hz=bandwidth*1e6, + carrier_frequency_hz=5.8e9 if family == "wifi6" else 6.1e9, + fft_size=bandwidth*256//20, cp_samples=bandwidth*16//20, + channel_subcarriers=[tones]*count, channel_modulations=[order]*count, + channel_power_db=[0.]*count, channel_gap_bins=4 if count > 1 else 0, + pilot_spacing=16) add("custom-ofdm", "custom", "Custom OFDM / OFDMA", "Independent channel allocations, pilots and modulation") add("custom-qam", "custom", "Single-carrier QAM / PSK", "Root-raised-cosine pulse shaping", waveform="qam", sample_rate_hz=80e6, bandwidth_hz=20e6) @@ -42,7 +80,13 @@ def add(identifier, family, label, description, **values): add("custom-noise", "custom", "Band-limited noise", "Complex Gaussian noise for spectral loading", waveform="noise") add("custom-dft-ofdm", "custom", "DFT-spread OFDM", "Single-carrier-like envelope · uncoded uplink stimulus", dft_spreading=True, pilot_mode="none") - return items + # A familiar starting point is first; the UI sorts matrix axes numerically. + items.sort(key=lambda p: p.preset_id != "nr-20") + return tuple(items) + + +def presets() -> list[GeneratorPreset]: + return list(_catalog()) def coverage(config: GeneratorConfig) -> str: @@ -53,4 +97,4 @@ def coverage(config: GeneratorConfig) -> str: "channel_subcarriers", "channel_gap_bins", "cp_mode", "cp_samples", "dc_null", "dft_spreading") if any(getattr(config, k) != getattr(preset.config, k) for k in keys): return "custom" - return "experimental" if preset.family == "wifi8" else "numerology" + return "numerology" diff --git a/opendpd/schemas/dataset.py b/opendpd/schemas/dataset.py index 8209ba5..15ff551 100644 --- a/opendpd/schemas/dataset.py +++ b/opendpd/schemas/dataset.py @@ -114,6 +114,15 @@ class DatasetVersion(StrictModel): sha256: Optional[Sha256] = None +class DatasetCapture(StrictModel): + dataset_id: Slug + preset_id: str = Field(max_length=64) + label: str = Field(max_length=180) + n_samples: int = Field(gt=0) + sample_rate_hz: float = Field(gt=0, allow_inf_nan=False) + bandwidth_hz: float = Field(gt=0, allow_inf_nan=False) + + class DatasetManifest(StrictModel): schema_version: int = SCHEMA_VERSION dataset_id: Slug @@ -125,6 +134,8 @@ class DatasetManifest(StrictModel): n_samples: Optional[int] = Field(default=None, ge=0) columns: Optional[Dict[str, str]] = None # logical name -> column in the source file split: SplitSpec + captures: List[DatasetCapture] = Field(default_factory=list, max_length=16) + parent_dataset_id: Optional[Slug] = None preprocessing_version: str = "raw-v1" raw_sha256: Optional[Sha256] = None versions: List[DatasetVersion] = Field(default_factory=list) # empty for built-ins: raw/ is the split dir diff --git a/opendpd/schemas/examples.py b/opendpd/schemas/examples.py index ff7f9b2..c0e08ff 100644 --- a/opendpd/schemas/examples.py +++ b/opendpd/schemas/examples.py @@ -664,7 +664,9 @@ def all_examples() -> Dict[str, object]: from opendpd.core.waveforms.generator_presets import presets from opendpd.core.virtual_pa import catalog return { - "generator_presets": presets(), + "generator_presets": [p for p in presets() if p.family == "custom" or p.preset_id in { + "nr-20", "nr-100", "nr-fr2", "nr-fr1-30-20-q16-c1", "nr-fr1-30-100-q16-c1", + "nr-fr1-30-20-q64-c2", "nr-fr1-30-100-q64-c2", "wifi6-20", "wifi6-80", "wifi7-20", "wifi7-320"}], "virtual_pa_models": catalog(), "metric_profile_legacy": legacy_metric_profile(), "metric_profile_general": general_metric_profile(), diff --git a/opendpd/schemas/signal_generator.py b/opendpd/schemas/signal_generator.py index 7653294..55267c9 100644 --- a/opendpd/schemas/signal_generator.py +++ b/opendpd/schemas/signal_generator.py @@ -60,6 +60,7 @@ class GeneratorConfig(StrictModel): dc_i: float = Field(default=0, ge=-1, le=1, allow_inf_nan=False) dc_q: float = Field(default=0, ge=-1, le=1, allow_inf_nan=False) snr_db: float | None = Field(default=None, ge=0, le=100, allow_inf_nan=False) + filter_enabled: bool = True clip_db: float | None = Field(default=None, ge=0, le=30, allow_inf_nan=False) @property @@ -130,11 +131,25 @@ def _valid(self): return self +class GeneratorBatchRequest(StrictModel): + configs: list[GeneratorConfig] = Field(min_length=1, max_length=16) + + @model_validator(mode="after") + def _bounded(self): + if sum(c.sample_count for c in self.configs) > 4_000_000: + raise ValueError("Select at most 4,000,000 samples across all presets.") + if len({c.preset_id for c in self.configs}) != len(self.configs): + raise ValueError("Select each preset only once per dataset.") + return self + + class GeneratorPreset(StrictModel): preset_id: str - family: Literal["nr", "wifi6", "wifi7", "wifi8", "custom"] + family: Literal["nr", "wifi6", "wifi7", "custom"] label: str description: str + numerology: str = "Custom" + channel_count: int = 1 config: GeneratorConfig diff --git a/opendpd/schemas/virtual_pa.py b/opendpd/schemas/virtual_pa.py index c6262cb..59eb260 100644 --- a/opendpd/schemas/virtual_pa.py +++ b/opendpd/schemas/virtual_pa.py @@ -87,6 +87,7 @@ class VirtualPASimulation(StrictModel): model: VirtualPAModel input_iq_sha256: Sha256 output_iq_sha256: Sha256 + kernel_sha256: Sha256 | None = None simulator_source_sha256: Sha256 sample_rate_hz: float analysis: PAAnalysis @@ -107,3 +108,15 @@ def _split(self): if self.train_ratio + self.val_ratio >= 1: raise ValueError("Leave a nonzero fraction for the test split.") return self + + +class VirtualPADatasetRequest(StrictModel): + input_signal_ids: list[Annotated[str, Field(pattern=r"^sg-[a-f0-9]{64}$")]] = Field(min_length=1, max_length=16) + model_id: Slug + parameters: dict[str, Annotated[float, Field(strict=True, allow_inf_nan=False)]] = Field(default_factory=dict, max_length=32) + + @model_validator(mode="after") + def _unique(self): + if len(set(self.input_signal_ids)) != len(self.input_signal_ids): + raise ValueError("Select each PA input only once.") + return self diff --git a/opendpd/server/security.py b/opendpd/server/security.py index f8ef27e..aba9780 100644 --- a/opendpd/server/security.py +++ b/opendpd/server/security.py @@ -124,7 +124,7 @@ class DatasetImportBoundary: PATHS = { "/api/v1/datasets/upload", "/api/v1/datasets/inspect", "/api/v1/datasets/import", "/api/v1/datasets/csv", "/api/v1/datasets/csv/preview", "/api/v1/imports", - "/api/v1/datasets/synthetic", + "/api/v1/datasets/synthetic", "/api/v1/pa-library/datasets", } def __init__(self, app, enabled: bool = False): diff --git a/opendpd/server/signal_generator_routes.py b/opendpd/server/signal_generator_routes.py index af6ba58..55ff1c1 100644 --- a/opendpd/server/signal_generator_routes.py +++ b/opendpd/server/signal_generator_routes.py @@ -5,7 +5,7 @@ from opendpd.core.waveforms.generator_presets import presets from opendpd.core.waveforms.generator import allocation from opendpd.schemas.signal_generator import (DatasetSampleCounts, GeneratedSignal, GeneratorConfig, - GeneratorDatasetRequest, GeneratorDatasetResponse, GeneratorPreset) + GeneratorDatasetRequest, GeneratorDatasetResponse, GeneratorPreset, GeneratorBatchRequest) from opendpd.server.routes import require_csrf, require_session from opendpd.server.errors import api_error as _error from opendpd.services import signal_generator as service @@ -80,3 +80,20 @@ def archive(signal_id: str, request: Request): @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) + + +@router.post("/signal-generator/batches", response_model=list[PAInputDataset], status_code=201, + dependencies=[Depends(require_csrf)]) +def generate_batch(body: GeneratorBatchRequest, request: Request): + return service.generate_batch(request.app.state.ws, body) + + +@router.get("/datasets/{dataset_id}/download", dependencies=[Depends(require_session)]) +def dataset_download(dataset_id: str, request: Request, version: str = "raw-v1", collection: bool = True): + import shutil + from starlette.background import BackgroundTask + from opendpd.services.dataset_downloads import export_dataset + path, temporary = export_dataset(request.app.state.ws, dataset_id, version, collection) + return FileResponse(path, filename=path.name, + media_type="application/zip" if path.suffix == ".zip" else "text/csv", + background=BackgroundTask(shutil.rmtree, temporary, ignore_errors=True)) diff --git a/opendpd/server/virtual_pa_routes.py b/opendpd/server/virtual_pa_routes.py index 41b9514..69797be 100644 --- a/opendpd/server/virtual_pa_routes.py +++ b/opendpd/server/virtual_pa_routes.py @@ -3,7 +3,7 @@ from fastapi.responses import FileResponse from opendpd.core.virtual_pa import catalog -from opendpd.schemas.virtual_pa import VirtualPAModel, VirtualPARequest, VirtualPASimulation, PairedDatasetRequest +from opendpd.schemas.virtual_pa import VirtualPAModel, VirtualPARequest, VirtualPASimulation, PairedDatasetRequest, VirtualPADatasetRequest from opendpd.schemas.signal_generator import GeneratorDatasetResponse from opendpd.server.routes import require_csrf, require_session from opendpd.services import virtual_pa @@ -41,3 +41,9 @@ def download(simulation_id: str, filename: str, request: Request): dependencies=[Depends(require_csrf)]) def dataset(simulation_id: str, body: PairedDatasetRequest, request: Request): return virtual_pa.create_dataset(request.app.state.ws, simulation_id, body) + + +@router.post("/datasets", response_model=GeneratorDatasetResponse, status_code=201, + dependencies=[Depends(require_csrf)]) +def simulate_dataset(body: VirtualPADatasetRequest, request: Request): + return virtual_pa.simulate_dataset(request.app.state.ws, body) diff --git a/opendpd/services/dataset_downloads.py b/opendpd/services/dataset_downloads.py new file mode 100644 index 0000000..a75b805 --- /dev/null +++ b/opendpd/services/dataset_downloads.py @@ -0,0 +1,178 @@ +"""Bounded dataset downloads with a frozen, standalone Virtual PA replay script.""" +from __future__ import annotations + +import json +import re +from pathlib import Path +import shutil +import tempfile +import zipfile + +import numpy as np + +from opendpd.services.datasets import load_version_arrays +from opendpd.services.workspace import WorkspaceError, sha256_file + + +def _safe_file(root, path): + if root.is_symlink() or path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root.resolve()): + raise WorkspaceError("Dataset export source is unavailable.") + for parent in path.parents: + if parent == root: + break + if parent.is_symlink(): + raise WorkspaceError("Dataset export source must not use symbolic links.") + return path + + +def _csv(ws, manifest, version, target): + root = ws.dataset_dir(manifest.dataset_id) + directory = ws.dataset_version_dir(manifest.dataset_id, version) + selected = manifest.version(version) + if not selected and version != "raw-v1": + raise WorkspaceError("Dataset version not found.") + refs = selected.files if selected else manifest.files + for ref in refs: + path = _safe_file(root, directory / ref.path if selected else root / ref.path) + if ref.sha256 and sha256_file(path) != ref.sha256: + raise WorkspaceError("Dataset bytes changed. Restore the source before downloading.") + x, y, _ = load_version_arrays(ws, manifest.dataset_id, version) + if len(x) != len(y) or len(x) > 4_000_000: + raise WorkspaceError("Dataset download supports at most 4,000,000 aligned samples.") + with target.open("w") as stream: + stream.write("I_in,Q_in,I_out,Q_out\n") + for start in range(0, len(x), 65536): + np.savetxt(stream, np.column_stack((x[start:start+65536], y[start:start+65536])), + delimiter=",", fmt="%.9g") + + +def _replay(ws, manifests, filenames): + from opendpd.services.virtual_pa import directory, read_simulation + configs, kernel = {}, None + for manifest, filename in zip(manifests, filenames): + provenance = manifest.simulation or {} + result = read_simulation(ws, provenance.get("simulation_id", "")) + root = directory(ws, result.simulation_id) + path = _safe_file(root, root / "kernel.py") + if not result.kernel_sha256 or sha256_file(path) != result.kernel_sha256: + raise WorkspaceError("The frozen PA formula is unavailable for this capture.") + source = path.read_text() + if kernel is not None and source != kernel: + raise WorkspaceError("Captures use different formula versions. Download captures separately.") + kernel = source + configs[filename] = {"model_id": result.config.model_id, "parameters": result.config.parameters, + "sample_rate_hz": result.sample_rate_hz, + "parameter_bounds": {p.key: [p.minimum, p.maximum, p.integer] for p in result.model.parameters}} + header = '''# /// script +# requires-python = ">=3.10" +# dependencies = ["numpy>=1.24", "scipy>=1.10"] +# /// +"""Reproduce synthetic PA outputs: uv run simulate_pa.py INPUT.csv --output OUTPUT.csv. +Use --preset NAME.csv when the input filename differs from the exported capture. +Every capture starts from zero memory state. This is an illustrative PA model. +""" +import argparse +import json +from pathlib import Path + +''' + # JSON is loaded as data; user-facing labels never become Python expressions. + data = "\nCAPTURES = json.loads(" + repr(json.dumps(configs, sort_keys=True)) + ")\n" + cli = ''' + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input", type=Path, help="CSV with I,Q or I_in,Q_in columns") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--preset", choices=sorted(CAPTURES)) + parser.add_argument("--sample-rate", type=float, help="Override this capture's sample rate in Hz") + parser.add_argument("--parameter", action="append", default=[], metavar="NAME=VALUE") + args = parser.parse_args() + name = args.preset or (args.input.name if args.input.name in CAPTURES else None) + if name is None and len(CAPTURES) == 1: + name = next(iter(CAPTURES)) + if name is None: + parser.error("Select --preset for this input filename.") + if args.input.resolve() == args.output.resolve(): + parser.error("Choose an output path different from the input.") + config = CAPTURES[name] + parameters = dict(config["parameters"]) + for override in args.parameter: + key, separator, raw = override.partition("=") + try: + value = float(raw) + low, high, integer = config["parameter_bounds"][key] + if not separator or not math.isfinite(value) or not low <= value <= high or (integer and value != int(value)): + raise ValueError() + except (KeyError, ValueError): + parser.error("Invalid or out-of-range PA parameter: " + override) + parameters[key] = value + raw = np.genfromtxt(args.input, delimiter=",", names=True, dtype=np.float64, encoding="utf-8-sig", max_rows=1_000_001) + raw = np.atleast_1d(raw) + names = raw.dtype.names or () + pair = ("I_in", "Q_in") if {"I_in", "Q_in"}.issubset(names) else ("I", "Q") + if not set(pair).issubset(names): + parser.error("CSV requires I,Q or I_in,Q_in columns.") + x = raw[pair[0]].astype(np.float32).astype(np.float64) + 1j*raw[pair[1]].astype(np.float32).astype(np.float64) + fs = config["sample_rate_hz"] if args.sample_rate is None else args.sample_rate + try: + y, _ = simulate_resolved(x, fs, config["model_id"], parameters) + except ValueError as exc: + parser.error(str(exc)) + output = np.column_stack((y.real, y.imag)).astype(np.float32) + np.savetxt(args.output, output, delimiter=",", header="I,Q", comments="", fmt="%.9g") + + +if __name__ == "__main__": + main() +''' + return header + (kernel or "") + data + cli + + +def export_dataset(ws, dataset_id, version="raw-v1", collection=True): + """Return a temporary file and its directory; the HTTP response removes both.""" + parent = ws.get_dataset(dataset_id) + manifests = [parent] + if collection and len(parent.captures) > 1: + manifests = [ws.get_dataset(c.dataset_id) for c in parent.captures] + if (manifests[0].dataset_id != parent.dataset_id + or any(m.parent_dataset_id != parent.dataset_id for m in manifests[1:]) + or len({m.dataset_id for m in manifests}) != len(manifests)): + raise WorkspaceError("Invalid dataset collection membership.") + if version != "raw-v1": + raise WorkspaceError("A collection ZIP contains original captures. Download a selected processed capture as CSV.") + if sum(m.n_samples or 0 for m in manifests) > 4_000_000: + raise WorkspaceError("Dataset download supports at most 4,000,000 total samples.") + target = Path(tempfile.mkdtemp(prefix="dataset-download-", dir=ws.exports_dir)) + try: + if len(manifests) == 1: + path = target / f"{dataset_id}.csv" + _csv(ws, parent, version, path) + return path, target + # Preset labels are never used as paths. IDs have already passed the schema. + filenames = [f"{i+1:02d}-" + re.sub(r"[^A-Za-z0-9_-]", "-", c.preset_id)[:64] + ".csv" + for i, c in enumerate(parent.captures)] + script = _replay(ws, manifests, filenames) + path = target / f"{dataset_id}.zip" + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=1) as archive: + for manifest, filename in zip(manifests, filenames): + csv = target / filename + _csv(ws, manifest, "raw-v1", csv) + archive.write(csv, filename) + archive.writestr(filename[:-4] + ".json", json.dumps({ + "dataset": manifest.model_dump(mode="json"), "csv_sha256": sha256_file(csv), + "columns": ["I_in", "Q_in", "I_out", "Q_out"], + "pa_script": "simulate_pa.py", "preset_argument": filename}, indent=2)) + csv.unlink() + archive.writestr("simulate_pa.py", script) + archive.writestr("manifest.json", parent.model_dump_json(indent=2)) + archive.writestr("README.txt", "SYNTHETIC PA input/output captures. Each CSV has its own length and sample rate.\n" + "Select a capture in Studio to analyze or train at its own sample rate; do not concatenate different numerologies.\n" + "Run: uv run simulate_pa.py --output output.csv\n" + "Or install numpy and scipy, then run with python. Use --help for parameter overrides.\n" + "Input accepts I,Q or I_in,Q_in. The output is float32 I,Q, cold-start state per capture.\n" + "Formulas and parameters are frozen in the script. Numeric libraries/platforms may differ at floating-point roundoff.\n") + return path, target + except Exception: + shutil.rmtree(target, ignore_errors=True) + raise diff --git a/opendpd/services/signal_generator.py b/opendpd/services/signal_generator.py index 878562b..ad7a989 100644 --- a/opendpd/services/signal_generator.py +++ b/opendpd/services/signal_generator.py @@ -214,3 +214,14 @@ def archive_input(ws, identifier, *, restore=False): return input_summary(result) marker.touch() return {'signal_id': identifier, 'removed': True} + + +def generate_batch(ws, request): + from opendpd.core.waveforms.generator import allocation + for config in request.configs: + if config.waveform == "ofdm": + try: + allocation(config) + except ValueError as exc: + raise WorkspaceError(str(exc)) from exc + return [input_summary(generate(ws, config)) for config in request.configs] diff --git a/opendpd/services/virtual_pa.py b/opendpd/services/virtual_pa.py index 057dda1..555be2e 100644 --- a/opendpd/services/virtual_pa.py +++ b/opendpd/services/virtual_pa.py @@ -4,18 +4,15 @@ import hashlib import json from pathlib import Path -import re -import tempfile import threading import numpy as np -from opendpd.core import virtual_pa as engine +from opendpd.core import virtual_pa as engine, virtual_pa_kernel from opendpd.core.splits import contiguous_boundaries -from opendpd.schemas.dataset import DatasetOrigin, SignalSpec -from opendpd.schemas.importing import CsvOptions +from opendpd.schemas.dataset import DatasetCapture, DatasetOrigin, SignalSpec from opendpd.schemas.signal_generator import GeneratorDatasetResponse -from opendpd.schemas.virtual_pa import VirtualPARequest, VirtualPASimulation, PairedDatasetRequest +from opendpd.schemas.virtual_pa import VirtualPARequest, VirtualPASimulation, PairedDatasetRequest, VirtualPADatasetRequest from opendpd.services import signal_generator as inputs from opendpd.services.datasets import import_arrays from opendpd.services.workspace import WorkspaceError, read_json, sha256_file, write_json_atomic @@ -49,7 +46,9 @@ def preview(ws, request: VirtualPARequest): except ValueError as exc: raise WorkspaceError(str(exc)) from exc config = request.model_copy(update={"parameters": parameters}) - source_hash = sha256_file(Path(engine.__file__)) + kernel = Path(virtual_pa_kernel.__file__).read_bytes() + kernel_hash = hashlib.sha256(kernel).hexdigest() + source_hash = hashlib.sha256(Path(engine.__file__).read_bytes() + kernel).hexdigest() identity = {"version": "virtual-pa-v1", "config": config.model_dump(mode="json"), "input_iq_sha256": signal.iq_sha256, "sample_rate_hz": signal.config.sample_rate_hz, "simulator_source_sha256": source_hash} @@ -68,11 +67,12 @@ def preview(ws, request: VirtualPARequest): y = output[:, 0].astype(complex) + 1j*output[:, 1] analysis = engine.analyze(x, y, signal.config.sample_rate_hz, states, parameters) target.mkdir(parents=True, exist_ok=True) + (target / "kernel.py").write_bytes(kernel) np.save(target / "output.npy", output, allow_pickle=False) base = f"/api/v1/pa-library/simulations/{identifier}" result = VirtualPASimulation(simulation_id=identifier, config=config, model=model, input_iq_sha256=signal.iq_sha256, output_iq_sha256=sha256_file(target / "output.npy"), - simulator_source_sha256=source_hash, sample_rate_hz=signal.config.sample_rate_hz, + kernel_sha256=kernel_hash, simulator_source_sha256=source_hash, sample_rate_hz=signal.config.sample_rate_hz, analysis=analysis, output_csv_url=base + "/output.csv", paired_csv_url=base + "/paired.csv", metadata_url=base + "/metadata.json") write_json_atomic(target / "manifest.json", result) @@ -106,7 +106,7 @@ def export(ws, identifier, kind): return path -def create_dataset(ws, identifier, request: PairedDatasetRequest): +def create_dataset(ws, identifier, request: PairedDatasetRequest, *, _created=None): with _LOCK: result = read_simulation(ws, identifier) signal = inputs.read_signal(ws, result.config.input_signal_id) @@ -144,7 +144,52 @@ def create_dataset(ws, identifier, request: PairedDatasetRequest): nperseg=min(4096, max(512, config.fft_size*config.oversampling)), modulation=f"{config.waveform.upper()} synthetic PA input", amplitude_units="normalized"), notes="SYNTHETIC paired PA input x and virtual PA output y. " + result.model.limitations.en + " " + " ".join(result.analysis.notes)) + if _created is not None: + _created.append(request.dataset_id) manifest = manifest.model_copy(update={"simulation": provenance, "source": manifest.source.model_copy(update={"original_path": None})}) ws.save_dataset(manifest) return GeneratorDatasetResponse(dataset=manifest, test_samples=bounds["test"][1]-bounds["test"][0]) + + +def simulate_dataset(ws, request: VirtualPADatasetRequest): + """Validate captures before registration and roll back newly created data on failure.""" + import shutil + from opendpd.core.waveforms.generator_presets import presets + + with _LOCK: + signals = [inputs.read_signal(ws, identifier) for identifier in request.input_signal_ids] + if any(s.analysis.sample_count < 8192 for s in signals): + raise WorkspaceError("Each preset needs at least 8,192 samples to create a training dataset.") + if sum(s.analysis.sample_count for s in signals) > 4_000_000: + raise WorkspaceError("Use at most 4,000,000 samples across all presets.") + if len({s.config.preset_id for s in signals}) != len(signals): + raise WorkspaceError("Choose distinct presets for a multi-preset dataset.") + simulations = [preview(ws, VirtualPARequest(input_signal_id=s.signal_id, + model_id=request.model_id, parameters=request.parameters)) for s in signals] + identity = json.dumps([r.simulation_id for r in simulations], separators=(",", ":")) + parent_id = "vpa-set-" + hashlib.sha256(identity.encode()).hexdigest()[:32] + identifiers = [parent_id] + [f"{parent_id}-{i+1}" for i in range(1, len(signals))] + labels = {p.preset_id: p.label for p in presets()} + created, results = [], [] + try: + for identifier, signal, result in zip(identifiers, signals, simulations): + label = labels.get(signal.config.preset_id, signal.config.preset_id) + response = create_dataset(ws, result.simulation_id, PairedDatasetRequest( + dataset_id=identifier, display_name=f"Synthetic · {label} · {result.model.name.en}"), _created=created) + results.append(response) + captures = [DatasetCapture(dataset_id=identifier, preset_id=s.config.preset_id, + label=labels.get(s.config.preset_id, s.config.preset_id), n_samples=s.analysis.sample_count, + sample_rate_hz=s.config.sample_rate_hz, bandwidth_hz=s.config.bandwidth_hz) + for identifier, s in zip(identifiers, signals)] + for response in results[1:]: + ws.save_dataset(response.dataset.model_copy(update={"parent_dataset_id": parent_id})) + parent = results[0].dataset.model_copy(update={"captures": captures}) + if len(captures) > 1: + parent = parent.model_copy(update={"display_name": f"Synthetic · {len(captures)} presets · {simulations[0].model.name.en}"}) + ws.save_dataset(parent) + return results[0].model_copy(update={"dataset": parent}) + except Exception: + for identifier in created: + shutil.rmtree(ws.dataset_dir(identifier), ignore_errors=True) + raise diff --git a/opendpd/web/mutation_policy.py b/opendpd/web/mutation_policy.py index 1a6bb38..184bba1 100644 --- a/opendpd/web/mutation_policy.py +++ b/opendpd/web/mutation_policy.py @@ -5,8 +5,8 @@ from dataclasses import dataclass import re -from opendpd.schemas.signal_generator import GeneratorConfig -from opendpd.schemas.virtual_pa import VirtualPARequest +from opendpd.schemas.signal_generator import GeneratorConfig, GeneratorBatchRequest +from opendpd.schemas.virtual_pa import VirtualPARequest, VirtualPADatasetRequest from opendpd.schemas.dataset_catalog import SyntheticSuiteRequest, DatasetPublicationDraft from opendpd.services.workspace import WorkspaceError from opendpd.web.policy import reject @@ -23,6 +23,8 @@ class MutationRule: MUTATIONS = ( + MutationRule(r"/signal-generator/batches", GeneratorBatchRequest, "signal-generator", 12, "generator_batch"), + MutationRule(r"/pa-library/datasets", VirtualPADatasetRequest, "virtual-pa-dataset", 6, "pa_dataset"), MutationRule(r'/signal-analyzer/analyze', None, 'signal-analysis', 36, 'none'), MutationRule(r'/signal-generator/signals', GeneratorConfig, 'signal-generator', 24, 'generator'), MutationRule(r'/signal-generator/signals/sg-[a-f0-9]{64}/dataset', None, 'signal-dataset', 6, 'input_dataset'), @@ -36,6 +38,10 @@ class MutationRule: def estimate_storage(rule, payload, path, ws): from opendpd.services.signal_generator import read_signal from opendpd.services.virtual_pa import read_simulation + if rule.estimate == 'generator_batch': + return sum(c.sample_count * 100 + 2_000_000 for c in payload.configs) + if rule.estimate == 'pa_dataset': + return sum(read_signal(ws, i).analysis.sample_count * 280 + 2_000_000 for i in payload.input_signal_ids) if rule.estimate == 'generator': return payload.sample_count * 100 + 2_000_000 if rule.estimate == 'synthetic': diff --git a/opendpd/web/policy.py b/opendpd/web/policy.py index abd3fb9..5a4777f 100644 --- a/opendpd/web/policy.py +++ b/opendpd/web/policy.py @@ -34,8 +34,8 @@ "PUT": [r"/settings"], } ROUTES["POST"] += [r"/datasets/synthetic", r"/dataset-publications/prepare", r"/dataset-publications/dspr-[a-f0-9]{64}/submit"] -ROUTES["GET"] += [r"/signal-generator/presets", r"/signal-generator/signals/sg-[a-f0-9]{64}(/download)?", rf"/datasets/{SLUG}/sample-counts"] -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/presets", r"/signal-generator/signals/sg-[a-f0-9]{64}(/download)?", rf"/datasets/{SLUG}/(sample-counts|download)"] +ROUTES["POST"] += [r"/signal-generator/batches", r"/pa-library/datasets", 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"/signal-generator/signals/sg-[a-f0-9]{64}/(archive|restore)", r"/pa-library/simulations", r"/pa-library/simulations/vpa-[a-f0-9]{64}/dataset"] @@ -128,7 +128,7 @@ def allowed(method: str, path: str) -> bool: def expensive_request(method: str, path: str) -> bool: """Bound in-process numeric/file work separately from lightweight status and cancellation.""" if method == 'GET': - return bool(re.fullmatch(r'/datasets/builtin|/pa-library/models|/signal-analyzer/sources|/datasets/[^/]+/analysis|/results/compare|/results/[^/]+(/(report|review))?', path)) + return bool(re.fullmatch(r'/datasets/builtin|/pa-library/models|/signal-analyzer/sources|/datasets/[^/]+/(analysis|download)|/results/compare|/results/[^/]+(/(report|review))?', path)) return method == 'POST' and (path == '/exports' or path.startswith(('/datasets/', '/signal-generator/', '/signal-analyzer/', '/pa-library/', '/dataset-publications/'))) diff --git a/pics/studio-dataset-presets.png b/pics/studio-dataset-presets.png new file mode 100644 index 0000000..5672d8c Binary files /dev/null and b/pics/studio-dataset-presets.png differ diff --git a/pics/studio-pa-library.png b/pics/studio-pa-library.png index e2cf0af..2ff58b4 100644 Binary files a/pics/studio-pa-library.png and b/pics/studio-pa-library.png differ diff --git a/pics/studio-signal-generator-custom.png b/pics/studio-signal-generator-custom.png index ff3738b..0d6d678 100644 Binary files a/pics/studio-signal-generator-custom.png and b/pics/studio-signal-generator-custom.png differ diff --git a/pics/studio-signal-generator.png b/pics/studio-signal-generator.png index 682bd80..72d80ef 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 eeb9f8c..d002b02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "opendpd" -version = "2.2.10" +version = "2.2.11" description = "An end-to-end learning framework for modeling power amplifiers and digital pre-distortion" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/verify_signal_generator.mjs b/scripts/verify_signal_generator.mjs index c98b5bd..77b938f 100644 --- a/scripts/verify_signal_generator.mjs +++ b/scripts/verify_signal_generator.mjs @@ -1,193 +1,82 @@ -/** Real local UI and workers; all generated samples are synthetic and private. */ +/** Real local browser verification of private multi-preset PA datasets. */ import { chromium } from '../frontend/node_modules/playwright/index.mjs' import assert from 'node:assert/strict' import fs from 'node:fs/promises' import path from 'node:path' -import { createHash } from 'node:crypto' const [baseURL, out] = process.argv.slice(2) +if (!baseURL || !out || !process.env.OPENDPD_BOOTSTRAP_TOKEN) throw new Error('Usage: OPENDPD_BOOTSTRAP_TOKEN=… node scripts/verify_signal_generator.mjs URL OUTPUT_DIR') await fs.mkdir(out, { recursive: true }) -const bootstrapToken = process.env.OPENDPD_BOOTSTRAP_TOKEN -if (!bootstrapToken) throw new Error('Set OPENDPD_BOOTSTRAP_TOKEN for the local test server') const browser = await chromium.connectOverCDP(process.env.OPENDPD_CDP_URL ?? 'http://127.0.0.1:9222') const evidence = [] -const suffix = Date.now().toString(36) try { - for (const [width, height] of [[1366, 768], [1920, 1080]]) { - const context = await browser.newContext({ viewport: { width, height }, acceptDownloads: true }) + for (const [width, height] of [[1366, 768], [1920, 1080], [390, 844]]) { + const context = await browser.newContext({ viewport: { width, height }, locale: 'en-US', acceptDownloads: true }) try { const page = await context.newPage() - const errors = [], publications = [] + const errors = [] page.on('pageerror', e => errors.push(e.message)) - page.on('request', r => { if (r.url().includes('/dataset-publications/') && r.url().endsWith('/submit')) publications.push(r.url()) }) - const api = (url, method = 'GET', body) => page.evaluate(async ({ url, method, body }) => { - const auth = await (await fetch('/api/v1/session')).json() - const response = await fetch('/api/v1' + url, { method, headers: { 'Content-Type': 'application/json', 'X-OpenDPD-CSRF': auth.csrf_token }, body: body === undefined ? undefined : JSON.stringify(body) }) - const data = await response.json() - if (!response.ok) throw new Error(JSON.stringify(data)) - return data - }, { url, method, body }) - const generated = async (button) => { - const waiting = page.waitForResponse(r => r.url().endsWith('/signal-generator/signals') && r.request().method() === 'POST') - await button.click() - const response = await waiting - assert.equal(response.status(), 201, await response.text()) - const signal = await response.json() - await page.getByTestId('signal-generator-results').waitFor() - return signal - } - const plots = () => page.waitForFunction(() => document.querySelectorAll('.js-plotly-plot .plot-container').length === 4) - await page.goto(baseURL + '/bootstrap?token=' + encodeURIComponent(bootstrapToken)) - await page.getByRole('link', { name: 'Get Started', exact: true }).click() - const guide = page.getByRole('dialog') - 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`) }) - 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.goto(baseURL + '/bootstrap?token=' + encodeURIComponent(process.env.OPENDPD_BOOTSTRAP_TOKEN)) + await page.goto(baseURL + '/signal-generator') + await page.getByRole('heading', { name: 'Signal setup', exact: true }).waitFor() + assert.equal(await page.getByRole('button', { name: /Wi-Fi 8/ }).count(), 0) + assert.equal(await page.getByTestId('workflow-paired').count(), 0) + assert.equal(await page.getByRole('combobox', { name: 'Preset', exact: true }).count(), 0) + await page.getByLabel('I/Q samples', { exact: true }).fill('16384') + await page.getByRole('button', { name: /03 ·.*Wi-Fi 7/ }).click() + await page.getByTestId('preset-wifi7-80-q1024-c2').click() + await page.getByLabel('I/Q samples', { exact: true }).fill('24576') + const generating = page.waitForResponse(r => r.url().endsWith('/signal-generator/batches') && r.request().method() === 'POST') + await page.getByRole('button', { name: 'Generate & preview', exact: true }).click() + const generated = await generating + assert.equal(generated.status(), 201, await generated.text()) + const inputs = await generated.json() + assert.deepEqual(inputs.map(s => s.n_samples), [16384, 24576]) + await page.getByTestId('signal-generator-results').waitFor() + await page.waitForFunction(() => document.querySelectorAll('.js-plotly-plot .plot-container').length >= 4) + await page.evaluate(() => window.scrollTo(0, 0)) await page.screenshot({ path: path.join(out, `generator-${width}.png`), fullPage: true }) assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false) - await page.getByRole('button', { name: /Wi-Fi 8/ }).click() - assert.equal(await page.getByRole('button', { name: 'Export I/Q + configuration', exact: true }).isEnabled(), false) - const wifi8 = await generated(page.getByRole('button', { name: 'Generate & preview', exact: true })) - assert.equal(wifi8.coverage, 'experimental') - assert(wifi8.analysis.notes.some(n => n.includes('UHR'))) - await page.getByRole('button', { name: /Custom/ }).click() - 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() - await page.getByRole('option', { name: 'Explicit signed bin indices', exact: true }).click() - await page.getByLabel('Pilot carrier indices', { exact: true }).fill('-50, 50') - await page.getByLabel('RF carrier frequency (GHz)', { exact: true }).fill('3.5') - await page.getByLabel('Add white Gaussian noise', { exact: true }).check() - await page.getByLabel('SNR relative to target RMS (dB)', { exact: true }).fill('25') - const custom = await generated(page.getByRole('button', { name: 'Apply & regenerate', exact: true })) - assert.equal(custom.analysis.sample_count, 32768) - assert.equal(custom.analysis.active_carriers, 158) - assert.equal(custom.analysis.pilot_carriers, 2) - assert(custom.analysis.evm_percent > 0.5) - await page.getByRole('button', { name: 'Advanced parameters', exact: true }).click() - await page.getByRole('tab', { name: 'Resource allocation', exact: true }).click() - await page.screenshot({ path: path.join(out, `allocation-${width}.png`), fullPage: true }) - await page.getByRole('tab', { name: 'Metrics & provenance', exact: true }).click() - await page.screenshot({ path: path.join(out, `metrics-${width}.png`), fullPage: true }) - const downloading = page.waitForEvent('download') - await page.getByRole('button', { name: 'Export I/Q + configuration', exact: true }).click() - await (await downloading).saveAs(path.join(out, `waveform-${width}.zip`)) - const saveDownload = async (name, filename) => { - const event = page.waitForEvent('download') - await page.getByRole('button', { name, exact: true }).click() - const file = path.join(out, filename) - await (await event).saveAs(file) - return file - } - const inputCsv = await fs.readFile(await saveDownload('Download PA input CSV', `pa-input-${width}.csv`)) - const inputMetadata = JSON.parse(await fs.readFile(await saveDownload('Download input metadata JSON', `pa-input-${width}.json`), 'utf8')) - assert.equal(inputCsv.toString().trim().split('\n').length, 32769) - assert.equal(inputMetadata.has_pa_output, false) - assert.equal(inputMetadata.csv_sha256, createHash('sha256').update(inputCsv).digest('hex')) + const inputDownload = page.waitForEvent('download') + await page.getByRole('button', { name: 'Download PA input CSV', exact: true }).click() + const downloadedInput = await inputDownload + await downloadedInput.saveAs(path.join(out, `pa-input-${width}.csv`)) await page.getByRole('link', { name: 'Choose Virtual PA →', exact: true }).click() - await page.getByRole('heading', { name: 'Solid-state AM/AM + AM/PM', exact: true }).waitFor() + await page.getByText('All 2 selected presets will use this PA.', { exact: false }).waitFor() const gain = page.getByRole('spinbutton', { name: 'Small-signal gain', exact: true }) - await gain.fill('2.2') + await gain.fill('2.5'); await gain.focus() assert.equal(await page.getByTestId('parameter-gain').getAttribute('data-active'), 'true') - assert((await page.getByTestId('equation-gain').evaluateAll(nodes => nodes.map(n => n.getAttribute('aria-pressed')))).every(v => v === 'true')) - const simulating = page.waitForResponse(r => r.url().endsWith('/pa-library/simulations') && r.request().method() === 'POST') + await page.evaluate(() => window.scrollTo(0, 0)) + await page.screenshot({ path: path.join(out, `virtual-pa-${width}.png`), fullPage: true }) + const saving = page.waitForResponse(r => r.url().endsWith('/pa-library/datasets') && r.request().method() === 'POST') await page.getByRole('button', { name: 'Simulate PA output', exact: true }).click() - const simulated = await simulating - assert.equal(simulated.status(), 201, await simulated.text()) - const simulation = await simulated.json() - await page.getByTestId('pa-output-preview').waitFor() - assert.equal(simulation.config.parameters.gain, 2.2) - assert.equal(simulation.analysis.n_samples, custom.analysis.sample_count) - for (const step of ['input', 'virtual', 'output']) assert.equal(await page.getByTestId('workflow-' + step).getAttribute('data-complete'), 'true') - assert.equal(await page.getByTestId('workflow-paired').getAttribute('data-complete'), 'false') - const outputCsv = await fs.readFile(await saveDownload('Download PA output CSV', `pa-output-${width}.csv`), 'utf8') - const pairCsv = await fs.readFile(await saveDownload('Download paired CSV (x, y)', `pa-pair-${width}.csv`), 'utf8') - const simulationMetadata = JSON.parse(await fs.readFile(await saveDownload('Download simulation metadata JSON', `pa-simulation-${width}.json`), 'utf8')) - const inputs = inputCsv.toString().trim().split('\n').slice(1), outputs = outputCsv.trim().split('\n').slice(1), pairs = pairCsv.trim().split('\n').slice(1) - assert.equal(outputs.length, inputs.length) - assert.deepEqual(pairs, inputs.map((x, i) => x + ',' + outputs[i])) - assert.equal(simulationMetadata.simulation.output_iq_sha256, simulation.output_iq_sha256) - await page.getByRole('button', { name: 'Expand', exact: true }).click() - await page.getByRole('dialog').screenshot({ path: path.join(out, `workflow-${width}.png`), animations: 'disabled' }) - await page.getByRole('button', { name: 'Close', exact: true }).click() - await page.getByRole('dialog').waitFor({ state: 'hidden' }) - await page.screenshot({ path: path.join(out, `pa-library-${width}.png`), fullPage: true }) - const datasetId = `qa-generator-${width}-${suffix}` - await page.getByLabel('Dataset ID', { exact: true }).fill(datasetId) - const creating = page.waitForResponse(r => r.url().endsWith('/dataset') && r.request().method() === 'POST') - await page.getByRole('button', { name: 'Create paired dataset & train PA', exact: true }).click() - const created = await creating - assert.equal(created.status(), 201, await created.text()) - const dataset = await created.json() - assert.equal(dataset.dataset.origin, 'synthetic') - await page.getByRole('heading', { name: 'PA Model', exact: true }).waitFor() - assert.equal(await page.getByRole('navigation', { name: 'Choose a task' }).getByRole('link').count(), 2) - assert.equal(await page.getByRole('tab', { name: 'Training', exact: true }).getAttribute('aria-selected'), 'true') - await page.getByRole('tab', { name: 'Testing', exact: true }).click() - const summary = page.getByTestId('testing-samples') - await summary.getByText(dataset.test_samples.toLocaleString('en-US'), { exact: false }).waitFor() - assert.equal(await page.getByRole('tab', { name: 'Testing', exact: true }).getAttribute('aria-selected'), 'true') - await page.screenshot({ path: path.join(out, `pa-testing-${width}.png`), fullPage: true }) - const countInfo = await api(`/datasets/${datasetId}/sample-counts`) - assert.equal(countInfo.counts.test, dataset.test_samples) - // Exercise actual CPU PA and DPD workers on the generated dataset, then - // select those checkpoints through the merged Testing interfaces. - const runs = [] - const waitRun = async id => { - for (let attempt = 0; attempt < 120; attempt++) { - const run = await api('/runs/' + id) - if (['failed', 'cancelled', 'interrupted'].includes(run.status)) throw new Error(JSON.stringify(run)) - if (run.status === 'succeeded') return run - await page.waitForTimeout(250) - } - throw new Error('CPU run did not finish within 30 seconds') - } - for (const task of ['train_pa', 'train_dpd']) { - await page.goto(`${baseURL}/experiments/new?task=${task}&dataset=${datasetId}${runs[0] ? '&paRun=' + runs[0] : ''}`) - await page.getByRole('combobox', { name: 'Model architecture', exact: true }).click() - await page.getByRole('option', { name: 'GRU', exact: true }).click() - await page.getByRole('combobox', { name: 'Starting settings', exact: true }).click() - await page.getByRole('option', { name: 'Quick trial', exact: true }).click() - await page.getByRole('button', { name: 'Continue', exact: true }).click() - await page.getByRole('combobox', { name: 'Device', exact: true }).click() - await page.getByRole('option', { name: 'cpu', exact: true }).click() - await page.getByLabel('Epochs', { exact: true }).fill('1') - await page.getByRole('button', { name: 'Continue', exact: true }).click() - const starting = page.waitForResponse(r => r.url().endsWith('/api/v1/runs') && r.request().method() === 'POST') - await page.getByRole('button', { name: 'Start run', exact: true }).click() - const response = await starting - assert.equal(response.status(), 201, await response.text()) - const run = await response.json() - await waitRun(run.run_id); runs.push(run.run_id) - await page.reload() - await page.waitForFunction(step => document.querySelector('[data-testid="workflow-' + step + '"]')?.getAttribute('data-complete') === 'true', task === 'train_pa' ? 'pa' : 'dpd') - } - for (const [index, task] of ['evaluate_pa', 'run_dpd'].entries()) { - await page.goto(`${baseURL}/experiments/new?task=${task}&modelRun=${runs[index]}&dataset=${datasetId}`) - await page.getByTestId('testing-samples').getByText(dataset.test_samples.toLocaleString('en-US'), { exact: false }).waitFor() - await page.getByRole('button', { name: 'Continue', exact: true }).click() - await page.getByRole('combobox', { name: 'Device', exact: true }).click() - await page.getByRole('option', { name: 'cpu', exact: true }).click() - await page.getByRole('button', { name: 'Continue', exact: true }).click() - const starting = page.waitForResponse(r => r.url().endsWith('/api/v1/runs') && r.request().method() === 'POST') - await page.getByRole('button', { name: 'Start run', exact: true }).click() - const response = await starting - assert.equal(response.status(), 201, await response.text()) - const run = await response.json() - await waitRun(run.run_id); runs.push(run.run_id) - } + const response = await saving + assert.equal(response.status(), 201, await response.text()) + const { dataset } = await response.json() + await page.waitForURL('**/datasets/' + dataset.dataset_id) + assert.equal(dataset.captures.length, 2) + await page.getByRole('combobox', { name: 'Visualize subdataset', exact: true }).click() + await page.getByRole('option', { name: /80 MHz.*1024-QAM/ }).click() + await page.getByText('320 MS/s', { exact: true }).waitFor() + await page.getByRole('link', { name: 'Configure experiment', exact: true }).waitFor({ state: 'visible' }) + assert((await page.getByRole('link', { name: 'Configure experiment', exact: true }).getAttribute('href')).includes(dataset.captures[1].dataset_id)) + const zipDownload = page.waitForEvent('download') + await page.getByRole('button', { name: 'Download all · ZIP', exact: true }).click() + await (await zipDownload).saveAs(path.join(out, `dataset-${width}.zip`)) + const csvDownload = page.waitForEvent('download') + await page.getByRole('button', { name: 'Download CSV', exact: true }).click() + await (await csvDownload).saveAs(path.join(out, `dataset-selected-${width}.csv`)) + await page.waitForFunction(() => document.querySelectorAll('.js-plotly-plot .plot-container').length >= 3) + await page.evaluate(() => window.scrollTo(0, 0)) + await page.screenshot({ path: path.join(out, `dataset-${width}.png`), fullPage: true }) + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false) + // Stored selection is preserved when returning to Signal Generator. + await page.goto(baseURL + '/signal-generator') + await page.getByTestId('signal-generator-results').waitFor() + assert.equal(await page.getByText('2 / 16 presets selected', { exact: true }).count(), 1) + assert.equal(await page.getByRole('link', { name: 'Choose Virtual PA →', exact: true }).getAttribute('aria-disabled'), null) assert.deepEqual(errors, []) - assert.deepEqual(publications, []) - evidence.push({ width, height, initialSignal: initial.signal_id, customSignal: custom.signal_id, simulation: simulation.simulation_id, inputOnly: inputMetadata.has_pa_output === false, exportsExactlyPaired: true, wifi8Coverage: wifi8.coverage, samples: custom.analysis.sample_count, activeCarriers: 158, pilotCarriers: 2, referenceEvmPercent: custom.analysis.evm_percent, dataset: datasetId, testSamples: dataset.test_samples, runs, errors, publications }) + evidence.push({ width, height, datasetId: dataset.dataset_id, captures: dataset.captures, automaticNavigation: true, downloadZip: true, downloadCsv: true, errors }) await fs.writeFile(path.join(out, 'signal-generator.json'), JSON.stringify(evidence, null, 2) + '\n') } finally { await context.close() } } diff --git a/tests/integration/test_public_studio.py b/tests/integration/test_public_studio.py index 722fb0a..d4fa7d8 100644 --- a/tests/integration/test_public_studio.py +++ b/tests/integration/test_public_studio.py @@ -425,3 +425,25 @@ def fail(): # A healthy cleanup must not clear an independent dispatcher failure. assert client.post('/api/v1/web/sessions', json={}).status_code == 503 manager.dispatch_healthy = True + + +def test_public_collections_are_bounded_private_and_downloadable(public): + client, manager, _ = public + auth, other = new_session(client), new_session(client) + configs = [{'preset_id': 'capture-one', 'n_samples': 8192}, {'preset_id': 'capture-two', 'n_samples': 12288}] + generated = client.post('/api/v1/signal-generator/batches', json={'configs': configs}, headers=auth) + assert generated.status_code == 201, generated.text + ids = [s['signal_id'] for s in generated.json()] + request = {'input_signal_ids': ids, 'model_id': 'rapp-am-pm'} + assert client.post('/api/v1/pa-library/datasets', json=request, headers=other).status_code == 404 + created = client.post('/api/v1/pa-library/datasets', json=request, headers=auth) + assert created.status_code == 201, created.text + ds = created.json()['dataset'] + url = '/api/v1/datasets/' + ds['dataset_id'] + '/download' + assert client.get(url, headers=auth).headers['content-type'] == 'application/zip' + assert client.get(url + '?collection=false', headers=auth).headers['content-type'] == 'text/csv; charset=utf-8' + assert client.get(url, headers=other).status_code != 200 + assert client.get(url + '?version=../../etc/passwd', headers=auth).status_code == 422 + assert client.post('/api/v1/signal-generator/batches', json={'configs': [{}]*17}, headers=auth).status_code == 422 + huge = [{'preset_id': str(i), 'n_samples': 1_000_000} for i in range(4)] + assert client.post('/api/v1/signal-generator/batches', json={'configs': huge}, headers=auth).status_code == 413 diff --git a/tests/integration/test_signal_collections.py b/tests/integration/test_signal_collections.py new file mode 100644 index 0000000..d3208ba --- /dev/null +++ b/tests/integration/test_signal_collections.py @@ -0,0 +1,104 @@ +"""Multi-rate captures, one-step simulation, portable exports and admission boundaries.""" +import io +import json +import os +import subprocess +import sys +import zipfile + +import numpy as np +import pytest +from fastapi.testclient import TestClient + +from opendpd.core.virtual_pa import catalog +from opendpd.core.waveforms.generator_presets import presets +from opendpd.server.app import create_app +from opendpd.server.security import CSRF_HEADER +from opendpd.services.workspace import Workspace + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def client(tmp_path): + ws = Workspace.create(tmp_path / "ws") + with TestClient(create_app(ws.root, bootstrap_token="collection"), base_url="http://127.0.0.1:8877") as c: + auth = c.post("/api/v1/session/bootstrap", json={"token": "collection"}).json() + c.headers[CSRF_HEADER] = auth["csrf_token"] + c.headers["origin"] = "http://127.0.0.1:8877" + yield c, ws + + +def inputs(c): + configs = [next(p.config for p in presets() if p.preset_id == key).model_dump(mode="json") + for key in ("nr-20", "wifi7-20")] + configs[0]["n_samples"], configs[1]["n_samples"] = 8192, 12288 + response = c.post("/api/v1/signal-generator/batches", json={"configs": configs}) + assert response.status_code == 201, response.text + return [p["signal_id"] for p in response.json()] + + +@pytest.mark.parametrize("model", catalog(), ids=lambda m: m.model_id) +def test_collection_zip_replays_each_capture_exactly(client, tmp_path, model): + c, ws = client + ids = inputs(c) + request = {"input_signal_ids": ids, "model_id": model.model_id} + response = c.post("/api/v1/pa-library/datasets", json=request) + assert response.status_code == 201, response.text + ds = response.json()["dataset"] + assert [capture["n_samples"] for capture in ds["captures"]] == [8192, 12288] + assert [capture["sample_rate_hz"] for capture in ds["captures"]] == [122880000, 80000000] + assert c.post("/api/v1/pa-library/datasets", json=request).json()["dataset"] == ds + response = c.get(f"/api/v1/datasets/{ds['dataset_id']}/download") + assert response.status_code == 200, response.text[:200] if response.status_code != 200 else "" + assert response.headers["content-type"] == "application/zip" + with zipfile.ZipFile(io.BytesIO(response.content)) as z: + assert "simulate_pa.py" in z.namelist() + csvs = [name for name in z.namelist() if name.endswith(".csv")] + assert len(csvs) == 2 and all('/' not in name for name in z.namelist()) + z.extractall(tmp_path / "export") + for filename, capture in zip(csvs, ds["captures"]): + target = tmp_path / "export" + subprocess.run([sys.executable, str(target / "simulate_pa.py"), str(target / filename), "--output", str(target / "output.csv")], + check=True, capture_output=True, env={**os.environ, "PYTHONPATH": ""}) + pair = np.loadtxt(target / filename, delimiter=",", skiprows=1, dtype=np.float32) + output = np.loadtxt(target / "output.csv", delimiter=",", skiprows=1, dtype=np.float32) + np.testing.assert_array_equal(pair[:, 2:], output) + single = c.get(f"/api/v1/datasets/{capture['dataset_id']}/download?collection=false") + assert single.status_code == 200 + np.testing.assert_array_equal(np.loadtxt(io.StringIO(single.text), delimiter=",", skiprows=1, dtype=np.float32), pair) + metadata = json.loads((target / filename.replace('.csv', '.json')).read_text()) + assert metadata["dataset"]["signal"]["sample_rate_hz"] == capture["sample_rate_hz"] + assert not list(ws.exports_dir.glob('dataset-download-*')) + + +def test_invalid_batch_and_capture_fail_before_dataset_writes(client): + c, ws = client + assert c.post('/api/v1/signal-generator/batches', json={'configs': [{}]*17}).status_code == 422 + assert c.post('/api/v1/signal-generator/batches', json={'configs': [{}]*2}).status_code == 422 + ids = inputs(c) + assert c.post('/api/v1/pa-library/datasets', json={'input_signal_ids': ids*2, 'model_id': 'rapp-am-pm'}).status_code == 422 + response = c.post('/api/v1/pa-library/datasets', json={'input_signal_ids': [ids[0], 'sg-'+'0'*64], 'model_id': 'rapp-am-pm'}) + assert response.status_code == 409 + assert not ws.list_datasets() + short = c.post('/api/v1/signal-generator/signals', json={'n_samples': 512}).json() + response = c.post('/api/v1/pa-library/datasets', json={'input_signal_ids': [ids[0], short['signal_id']], 'model_id': 'rapp-am-pm'}) + assert response.status_code == 409 and not ws.list_datasets() + + +def test_failed_second_import_rolls_back_only_owned_datasets(client, monkeypatch): + from opendpd.services import virtual_pa + c, ws = client + ids = inputs(c) + original = virtual_pa.import_arrays + calls = [] + def failing(*args, **kwargs): + calls.append(True) + if len(calls) == 2: + raise ValueError('injected disk failure') + return original(*args, **kwargs) + monkeypatch.setattr(virtual_pa, 'import_arrays', failing) + with pytest.raises(ValueError, match='disk failure'): + c.post('/api/v1/pa-library/datasets', json={'input_signal_ids': ids, 'model_id': 'rapp-am-pm'}) + assert not ws.list_datasets() + assert not list(ws.datasets_dir.iterdir()) diff --git a/tests/integration/test_virtual_pa_api.py b/tests/integration/test_virtual_pa_api.py index cd7ad66..b4d0015 100644 --- a/tests/integration/test_virtual_pa_api.py +++ b/tests/integration/test_virtual_pa_api.py @@ -92,3 +92,4 @@ def test_custom_import_boundary_also_covers_virtual_pairs(tmp_path): response = client.post("/api/v1/pa-library/simulations/vpa-" + "a"*64 + "/dataset", json={"dataset_id": "blocked"}) assert response.status_code == 403 + assert client.post("/api/v1/pa-library/datasets", json={"input_signal_ids": ["sg-" + "a"*64], "model_id": "rapp-am-pm"}).status_code == 403 diff --git a/tests/unit/test_signal_analyzer.py b/tests/unit/test_signal_analyzer.py index 0d1137c..67f538a 100644 --- a/tests/unit/test_signal_analyzer.py +++ b/tests/unit/test_signal_analyzer.py @@ -106,20 +106,20 @@ def test_gray_bit_mapping_and_total_rrc_span(): def test_burst_gating_constant_phase_and_dft_zero_bins_are_honest(): - c = GeneratorConfig(waveform='tone', n_samples=4096, burst_on_samples=1024, + c = GeneratorConfig(waveform='tone', n_samples=4096, filter_enabled=False, burst_on_samples=1024, burst_off_samples=1024, burst_ramp_samples=0, phase_offset_deg=90) x, a = synthesize(c) assert x[0].imag == pytest.approx(c.rms) assert not np.any(x[1024:2048]) assert a.papr_db == pytest.approx(10*np.log10(2), abs=1e-6) - _, a = synthesize(GeneratorConfig(dft_spreading=True, pilot_mode='none', payload_mode='bits', payload_bits='0')) + _, a = synthesize(GeneratorConfig(filter_enabled=False, dft_spreading=True, pilot_mode='none', payload_mode='bits', payload_bits='0')) assert np.isfinite(a.evm_per_subcarrier_percent).all() assert a.evm_percent < .001 @pytest.mark.parametrize('waveform', ['psk', 'fsk', 'gfsk', 'noise']) def test_new_waveforms_are_reproducible(waveform): - c = GeneratorConfig(waveform=waveform, samples_per_symbol=16, n_samples=8192) + c = GeneratorConfig(waveform=waveform, samples_per_symbol=16, n_samples=8192, filter_enabled=False) x, a = synthesize(c) np.testing.assert_array_equal(x, synthesize(c)[0]) assert len(x) == 8192 and np.isfinite(x).all() diff --git a/tests/unit/test_signal_generator.py b/tests/unit/test_signal_generator.py index b8d4e84..521cf75 100644 --- a/tests/unit/test_signal_generator.py +++ b/tests/unit/test_signal_generator.py @@ -21,24 +21,23 @@ def preset(identifier, **changes): @pytest.mark.parametrize("item", presets(), ids=lambda p: p.preset_id) def test_presets_are_finite_exact_length_and_explicit_about_coverage(item): - x, a = synthesize(item.config) - assert x.dtype == np.complex64 and len(x) == item.config.sample_count + config = item.config.model_copy(update={"n_samples": max(4096, 2*(item.config.fft_size + 256)*item.config.oversampling)}) + x, a = synthesize(config) + assert x.dtype == np.complex64 and len(x) == config.sample_count assert np.isfinite(x).all() and np.isfinite(a.psd_dbfs_hz).all() assert a.rms == pytest.approx(item.config.rms, rel=1e-6) assert a.papr_db == pytest.approx(10*np.log10(np.max(np.abs(x.astype(complex))**2)/np.mean(np.abs(x.astype(complex))**2))) assert a.ccdf_probability == sorted(a.ccdf_probability, reverse=True) assert a.duration_ms == len(x)/item.config.sample_rate_hz*1000 if item.config.waveform == "ofdm": - assert a.evm_percent < .0001 + assert a.evm_percent is not None and np.isfinite(a.evm_percent) assert a.pilot_carriers + a.data_carriers == sum(item.config.channel_subcarriers) assert any("Not a conformance" in note for note in a.notes) - if item.family == "wifi8": - assert coverage(item.config) == "experimental" - assert any("UHR" in note for note in a.notes) + assert coverage(config) in {"custom", "numerology"} def test_one_ms_nr_prefix_lengths_follow_subframe_timing(): - config = preset("nr-20", length_mode="duration", duration_ms=1) + config = preset("nr-20", length_mode="duration", duration_ms=1, filter_enabled=False) x, a = synthesize(config) assert len(x) == 122880 and a.complete_symbols == 28 and a.trailing_samples == 0 assert a.cp_lengths_samples == [352] + [288]*13 + [352] + [288]*13 @@ -50,7 +49,7 @@ def test_one_ms_nr_prefix_lengths_follow_subframe_timing(): def test_tone_has_analytic_zero_db_papr_and_rf_metadata_does_not_mix(): - config = preset("custom-tone", n_samples=4096) + config = preset("custom-tone", n_samples=4096, filter_enabled=False) x, a = synthesize(config) time = np.arange(4096)/config.sample_rate_hz np.testing.assert_allclose(x, config.rms*np.exp(2j*np.pi*config.tone_frequency_hz*time), atol=2e-8) @@ -60,7 +59,7 @@ def test_tone_has_analytic_zero_db_papr_and_rf_metadata_does_not_mix(): def test_pilots_channel_powers_and_nulls_are_real_fft_allocations(): - config = GeneratorConfig(fft_size=512, sample_rate_hz=80e6, bandwidth_hz=20e6, + config = GeneratorConfig(filter_enabled=False, fft_size=512, sample_rate_hz=80e6, bandwidth_hz=20e6, channel_subcarriers=[48, 72], channel_modulations=[4, 16], channel_power_db=[0, -6], channel_gap_bins=4, pilot_mode="explicit", pilot_indices=[-50, -25, 25, 50], n_samples=32768) channels, pilots = allocation(config) @@ -80,7 +79,7 @@ def test_impairments_are_visible_and_seed_is_reproducible(): np.testing.assert_array_equal(x, synthesize(noisy)[0]) assert a.evm_percent > 10 assert not np.array_equal(x, synthesize(noisy.model_copy(update={"seed": 43}))[0]) - clipped = clean.model_copy(update={"clip_db": 3}) + clipped = clean.model_copy(update={"clip_db": 3, "filter_enabled": False}) z, _ = synthesize(clipped) assert np.max(np.abs(z)) <= clean.rms*10**(3/20)*(1+1e-6) @@ -166,3 +165,39 @@ def test_archive_is_reversible_and_preserves_simulation_source(tmp_path): assert read_signal(ws,signal.signal_id).iq_sha256==signal.iq_sha256 archive_input(ws,signal.signal_id,restore=True) assert list_inputs(ws)[0].signal_id==signal.signal_id + + +def test_catalog_covers_axes_and_no_wifi8(): + items = presets() + assert len(items) == 1186 and len({p.preset_id for p in items}) == len(items) + assert {p.family for p in items} == {"nr", "wifi6", "wifi7", "custom"} + assert max(p.config.bandwidth_hz for p in items if p.family == "wifi7") == 320e6 + assert max(p.config.bandwidth_hz for p in items if p.family == "wifi6") == 160e6 + nr = [p for p in items if p.numerology == "FR1 · 30 kHz" and p.channel_count == 1] + assert next(p.config.channel_subcarriers for p in nr if p.config.bandwidth_hz == 100e6) == [273*12] + assert {p.channel_count for p in items if p.family == "wifi6"} == {1, 2, 4, 8} + + +def test_default_filter_suppresses_stopband_preserves_length_rms_and_is_optional(): + raw = preset("nr-20", n_samples=32768, filter_enabled=False) + x, before = synthesize(raw) + y, after = synthesize(raw.model_copy(update={"filter_enabled": True})) + f = np.fft.fftfreq(len(x), 1/raw.sample_rate_hz) + outside = abs(f) >= raw.bandwidth_hz/2 + power = lambda z: abs(np.fft.fft(z.astype(complex)))**2 + px, py = power(x), power(y) + assert py[outside].sum()/py.sum() < 1e-13 + assert px[outside].sum()/px.sum() > 1e-5 + assert len(x) == len(y) == 32768 + assert after.rms == pytest.approx(before.rms, rel=1e-7) + assert GeneratorConfig().filter_enabled is True + np.testing.assert_array_equal(y, synthesize(raw.model_copy(update={"filter_enabled": True}))[0]) + np.testing.assert_array_equal(x, synthesize(raw)[0]) + + +def test_filter_passband_tone_and_frequency_offset(): + c = GeneratorConfig(waveform="tone", n_samples=8192, sample_rate_hz=80e6, + tone_frequency_hz=80e6*128/8192, frequency_offset_hz=80e6*64/8192) + actual, _ = synthesize(c) + expected, _ = synthesize(c.model_copy(update={"filter_enabled": False})) + np.testing.assert_allclose(actual, expected, atol=1e-8)