diff --git a/containers/power_electronics_v1.NOTICES.md b/containers/power_electronics_v1.NOTICES.md new file mode 100644 index 00000000..693c8026 --- /dev/null +++ b/containers/power_electronics_v1.NOTICES.md @@ -0,0 +1,34 @@ +# PowerElectronics v1 runtime notices + +The PowerElectronics v1 SIF is a multi-license software aggregate. The +EngiBench repository license and the dataset license do not replace the +licenses of software installed in the image. + +The image contains Debian 12 (bookworm) packages, Python packages installed +from PyPI, and ngspice 44.2. Copyright and license notices supplied by Debian +packages are available inside the image under +`/usr/share/doc//copyright`. Python package metadata and license files +are retained in their installed `.dist-info` directories. The accompanying +SPDX SBOM is an inventory aid; the installed notices remain authoritative. +The publication bundle preserves those notices and records the exact Debian +binary and source package versions in `debian-source-packages.tsv`. Debian +source packages remain available from +[`sources.debian.org`](https://sources.debian.org/) and +[`snapshot.debian.org`](https://snapshot.debian.org/). + +The ngspice 44.2 `COPYING` file describes the main license as Modified BSD and +lists exceptions under LGPL, MPL, GPL, MIT-compatible, and public-domain +terms. The exact unmodified ngspice 44.2 source archive, including that +complete `COPYING` file, is distributed with the runtime publication bundle as +`ngspice-44.2.tar.gz`: + +```text +e7dadfb7bd5474fd22409c1e5a67acdec19f77e597df68e17c5549bc1390d7fd ngspice-44.2.tar.gz +``` + +The SIF was used as a simulation runtime. EngiBench source code and the +generated PowerElectronics datasets are not copied into it; EngiBench was +mounted into the container when the dataset was generated. + +The SIF is provided without warranty. Recipients remain responsible for +reviewing the license terms of the components they use or redistribute. diff --git a/containers/power_electronics_v1.PUBLICATION.md b/containers/power_electronics_v1.PUBLICATION.md new file mode 100644 index 00000000..035503e9 --- /dev/null +++ b/containers/power_electronics_v1.PUBLICATION.md @@ -0,0 +1,37 @@ +# PowerElectronics v1 runtime publication + +The canonical Linux x86_64 runtime is published as a SIF artifact in GitHub +Container Registry: + +```text +ghcr.io/ideallab/engibench-power-electronics-v1:v1.0.0 +``` + +For an immutable pull, use its OCI manifest digest: + +```bash +apptainer pull power-electronics-v1.sif \ + oras://ghcr.io/ideallab/engibench-power-electronics-v1@sha256:3377ea0e2315e9e83a1f042df1a9f9852bf7098a404ed0adc85c8b01c9a3ca85 +``` + +Verify the downloaded SIF itself before use: + +```bash +echo "40816f203b7e1c68ae37f4d9353bd302486d77988b98733c021f1ff71f48ae02 power-electronics-v1.sif" \ + | sha256sum --check +``` + +The OCI manifest digest identifies the registry manifest; the SIF SHA-256 +identifies the exact file used to generate the dataset. The `v1.0.0` tag is a +convenience reference, while the digest-pinned form is the reproducible input. + +The registry artifact has two OCI referrers: an SPDX SBOM and a provenance +bundle containing the definition file, checksums, package manifests, installed +license metadata, and the exact ngspice 44.2 source archive. See +[`power_electronics_v1.NOTICES.md`](power_electronics_v1.NOTICES.md) for the +mixed-license disclosure. + +The corresponding dataset is +[`IDEALLab/power_electronics_v1`](https://huggingface.co/datasets/IDEALLab/power_electronics_v1) +at tag `v1.0.0` and commit +`eefad7d727ea1e5bef5e1b7088dea20a9b0cd67f`. diff --git a/containers/power_electronics_v1.def b/containers/power_electronics_v1.def new file mode 100644 index 00000000..1d7cb117 --- /dev/null +++ b/containers/power_electronics_v1.def @@ -0,0 +1,70 @@ +Bootstrap: docker +From: debian@sha256:f3034a6ec3c1205360777c4aae76234998866ad18806ae62b63a3f84ccad782b + +%labels + org.opencontainers.image.title "EngiBench PowerElectronics v1 dataset backend" + org.opencontainers.image.base.digest "sha256:f3034a6ec3c1205360777c4aae76234998866ad18806ae62b63a3f84ccad782b" + org.opencontainers.image.ngspice.version "44.2" + org.opencontainers.image.ngspice.source.sha256 "e7dadfb7bd5474fd22409c1e5a67acdec19f77e597df68e17c5549bc1390d7fd" + +%post + set -eu + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + bison \ + build-essential \ + ca-certificates \ + curl \ + flex \ + git \ + libtool \ + python3 \ + python3-venv + + work_dir="$(mktemp -d)" + archive="$work_dir/ngspice-44.2.tar.gz" + curl --fail --location --retry 3 \ + --output "$archive" \ + "https://sourceforge.net/projects/ngspice/files/ng-spice-rework/old-releases/44.2/ngspice-44.2.tar.gz/download" + echo "e7dadfb7bd5474fd22409c1e5a67acdec19f77e597df68e17c5549bc1390d7fd $archive" | sha256sum --check + tar -xzf "$archive" -C "$work_dir" + cd "$work_dir/ngspice-44.2" + ./configure \ + --prefix=/opt/ngspice-44.2 \ + --enable-relpath \ + --without-x \ + --with-readline=no \ + --with-fftw3=no \ + --disable-openmp + make -j2 + make install + cd / + rm -rf "$work_dir" + + python3 -m venv /opt/power-electronics-v1 + /opt/power-electronics-v1/bin/pip install --no-cache-dir \ + datasets==5.0.1 \ + gymnasium==1.3.0 \ + matplotlib==3.11.2 \ + networkx==3.6.1 \ + numpy==2.4.6 \ + pandas==3.0.6 \ + pyarrow==25.0.1 + /opt/power-electronics-v1/bin/pip freeze | sort > /opt/power-electronics-v1-requirements.txt + + apt-get clean + rm -rf /var/lib/apt/lists/* + +%environment + export PATH=/opt/power-electronics-v1/bin:/opt/ngspice-44.2/bin:$PATH + export NGSPICE_PATH=/opt/ngspice-44.2/bin/ngspice + export PYTHONNOUSERSITE=1 + export MPLBACKEND=Agg + +%test + set -eu + test "$(uname -m)" = "x86_64" + /opt/ngspice-44.2/bin/ngspice --version 2>&1 | grep -q "ngspice-44.2" + /opt/power-electronics-v1/bin/python -c \ + "import datasets, gymnasium, matplotlib, networkx, numpy, pandas, pyarrow" diff --git a/docs/problems/power_electronics.md b/docs/problems/power_electronics.md index a8a9a815..1e7dcabd 100644 --- a/docs/problems/power_electronics.md +++ b/docs/problems/power_electronics.md @@ -4,6 +4,17 @@ :lead: Xuliang Dong @ liangXD523 ``` +```{warning} +For new work, use v1: +`from engibench.problems.power_electronics.v1 import PowerElectronics`. +The plain `engibench.problems.power_electronics` import and problem registry +still select v0 for compatibility. v0 has historical objective directions and +labels whose original simulator environment is unknown; do not treat it as the +corrected v1 benchmark. Interactive v1 simulations on a backend other than the +published Linux x86_64/ngspice 44.2 binary warn because their numerical results +may differ from the v1 labels. +``` + ## Motivation Optimizing circuit parameters is a critical aspect of circuit design but remains challenging, particularly for power converter circuits that contain diodes and switches, which introduce significant nonlinearity and discontinuity. These characteristics make key objectives such as *DcGain* and *Voltage Ripple* highly sensitive to even small parameter variations. @@ -18,23 +29,39 @@ To ensure stable simulations, a specific on-off switching pattern is chosen for Despite this simplification, determining the optimal parameter values remains highly challenging. ## Design Space -The design space for this problem is represented as a 10-dimensional bounded box, where each dimension corresponds to a specific circuit parameter. These parameters include values for capacitors, inductors, and a shared duty cycle for all switches. Each design can be expressed as a vector **x** of the form: +The design space is a 20-dimensional bounded box. Its first ten entries are six +capacitors, three inductors, and one shared duty cycle. The final ten entries +are the two binary control levels for each of five switches. The v0 and v1 +datasets use the same designs and split membership. $$ -x = \begin{bmatrix} C_1,\dots,C_6,L_1,L_2,L_3,T_1 \end{bmatrix}^{\top} \in \mathcal{X}, +x = \begin{bmatrix} C_1,\dots,C_6,L_1,L_2,L_3,T_1, +G_{1,1},\dots,G_{5,1},G_{1,2},\dots,G_{5,2} \end{bmatrix}^{\top} \in \mathcal{X}, \quad -\mathcal{X} = [1\text{e}{-6}, 2\text{e}{-5}]^6 \times [1\text{e}{-6}, 1\text{e}{-3}]^3 \times [0.1, 0.9] +\mathcal{X} = [1\text{e}{-6}, 2\text{e}{-5}]^6 \times [1\text{e}{-6}, 1\text{e}{-3}]^3 +\times [0.1, 0.9] \times \{0,1\}^{10} $$ Here, $C_1,\dots,C_6$ are the capacitance values (in Farads), $L_1,L_2,L_3$ are the inductance values (in Henries), and $T_1$ is the duty cycle shared across all 5 switches. The duty cycle $T_1$ denotes the fraction of time during which the switches are in the β€œon” state and governs a periodic on-off pattern repeated at high frequency throughout the simulation. ## Objectives -The simulation outputs two scalar values: *DcGain* and *Voltage Ripple*. The former represents the ratio of load to input voltage and should ideally approximate a predefined constant, such as $0.25$, as closely as possible. Meanwhile, the latter quantifies the voltage fluctuation at the load. + +### v1 + +The simulator first retains two signed/raw physical measurements: + +- the mean output voltage $\overline{V_{load}}$; +- the peak-to-peak output voltage $V_{pp}$. + +With $V_{source}=1000$ V, v1 derives a signed DC gain and two minimization +objectives: **DcGain objective:** $$ -\min_{\mathbf{x} \in \mathcal{X}} \; \bigg|\frac{\overline{V_{load}(t)}}{V_{source}} - 0.25\bigg| +\operatorname{gain}(\mathbf{x}) = \frac{\overline{V_{load}(t)}}{V_{source}}, +\qquad +\min_{\mathbf{x} \in \mathcal{X}} \; \big|\operatorname{gain}(\mathbf{x}) - 0.25\big| = \bigg|\frac{1}{V_{source}} \cdot \frac{1}{T} \sum_{i=1}^{N-1} \frac{V_{load}(t_{i+1}) + V_{load}(t_i)}{2} \cdot (t_{i+1} - t_i) - 0.25\bigg| $$ @@ -43,33 +70,110 @@ where $\overline{V_{load}(t)}$ is the average load voltage, $V_{source} = 1000$ **Voltage Ripple objective:** $$ -\min_{\mathbf{x} \in \mathcal{X}} \; \text{Voltage Ripple} -= \frac{V_{pp}(t)}{\overline{V_{load}(t)}} -= \frac{\max_{i \in [1, N]} V_{load}(t_i) - \min_{i \in [1, N]} V_{load}(t_i)}{\overline{V_{load}(t)}} +\min_{\mathbf{x} \in \mathcal{X}} \; \text{Relative Voltage Ripple} += \frac{V_{pp}(t)}{|\overline{V_{load}(t)}|} += \frac{\max_{i \in [1, N]} V_{load}(t_i) - \min_{i \in [1, N]} V_{load}(t_i)}{|\overline{V_{load}(t)}|} $$ -where $V_{pp}$ is the peak-to-peak load voltage calculated during transient analysis. +where $V_{pp}$ is the peak-to-peak load voltage calculated during transient +analysis. The absolute value appears only in the ripple denominator and the +gain-error calculation; it does not discard the sign of the stored mean voltage +or gain. A zero mean voltage makes relative ripple undefined, which is stored +as a null objective with an explicit status instead of being silently replaced. + +### v0 compatibility + +v0 is intentionally unchanged. Its Python contract declares `DcGain` as a +minimization objective and `Voltage_Ripple` as a maximization objective, while +the older documentation described gain error and ripple minimization. Its +netlist computes signed `Gain = Vo_mean / 1000` and +`Vpp_ratio = Vpp / Vo_mean`; the archived dataset-generation notebook then +applied an absolute-value transformation before saving the published labels. +These semantics are preserved for reproducibility, not recommended as the new +objective definition. ## Conditions This problem does not include environmental or operational conditions as part of its input specification. Unlike other domains where the simulation setup may vary based on conditions (e.g., load configurations or external temperatures), the circuit is simulated under fixed source voltage and switching behavior. As a result, the design optimization task focuses solely on tuning internal circuit parameters, with no external conditions to vary. More complex variants of this problem β€” involving multiple topologies or variable source voltages β€” may be considered in future releases. ## Simulator -The simulator is ngSpice circuit simulator. You can download it based on your operating system: -- Windows: [https://sourceforge.net/projects/ngspice/files/ng-spice-rework/45.2/](https://sourceforge.net/projects/ngspice/files/ng-spice-rework/45.2/) -- MacOS: `brew install ngspice` -- Linux: `sudo apt-get install ngspice` +For interactive use, install ngspice separately from the Python package: + +- Linux: install your distribution's `ngspice` package (for example, + `sudo apt-get install ngspice` on Ubuntu). +- macOS: build the CI-tested ngspice 44.2 binary with + `scripts/install_ngspice_macos.sh /path/to/install` and set `NGSPICE_PATH` to + its `bin/ngspice` executable. On Apple Silicon this installer builds an + x86_64 binary for Rosetta 2. +- Windows: install [ngspice 45.2](https://sourceforge.net/projects/ngspice/files/ng-spice-rework/old-releases/45.2/) + and put `ngspice.exe` on `PATH`, or point `NGSPICE_PATH` to it. + +`PowerElectronics(ngspice_path=...)` takes precedence over `NGSPICE_PATH`, +which takes precedence over `PATH`. The wrapper accepts ngspice major versions +42 through 45 for interactive use. v0 warns once when created because its +historical labels have unknown simulator provenance. v1 warns once for each +noncanonical simulator identity; using a supported ngspice version does **not** +guarantee matching the published v1 labels. In particular, an ordinary Ubuntu +package or an ngspice 44.2 build for another architecture may give different +results. See [ngspice bug #622](https://sourceforge.net/p/ngspice/bugs/622/) +for a reported AArch64/x86_64 discrepancy on another circuit. + +### Reproducing the v1 dataset + +The canonical v1 dataset backend is ngspice 44.2 in a provenance-frozen Linux +x86_64 Apptainer image. Dataset generation rejects a different ngspice version, +operating system, CPU architecture, simulator checksum, or container checksum +unless the caller explicitly selects the noncanonical development override. +The image recipe is +[`containers/power_electronics_v1.def`](../../containers/power_electronics_v1.def); +it records the amd64 base-image digest, the ngspice source archive checksum, +and the Python package versions used by the generation entry point. Because +Debian package repositories change over time, the recipe alone is not a claim +of a byte-identical rebuild. The published SIF is the canonical runtime +artifact. Its SHA-256 is +`40816f203b7e1c68ae37f4d9353bd302486d77988b98733c021f1ff71f48ae02`, +and its ngspice binary SHA-256 is +`11a4334ee90509f5edfdceef541711a34a1943d26a14cf0928ac8d5947b72374`. +Both fingerprints are enforced by every canonical shard; an arbitrary +caller-provided fingerprint cannot redefine the canonical backend. + +The runtime publication also includes an SPDX SBOM, package manifests, the +exact ngspice 44.2 source archive, and +[`third-party notices`](source:containers/power_electronics_v1.NOTICES.md). +Pull and verification instructions, including the immutable OCI digest, are in +the [`runtime publication record`](source:containers/power_electronics_v1.PUBLICATION.md). + +This policy is narrower than the versions accepted by the interactive v0 +wrapper. It exists because transient results have differed materially across +ngspice versions, builds, and CPU architectures. ## Dataset -The dataset linked to this problem is hosted on the [Hugging Face Datasets Hub](https://huggingface.co/datasets/IDEALLab/power_electronics). ### v0 +The immutable source dataset is +[`IDEALLab/power_electronics_v0`](https://huggingface.co/datasets/IDEALLab/power_electronics_v0) +at revision `5c4adb2ec5cfc71794988b1297a7ff8ffe59daa5`. It contains 9,676 +training rows, 2,765 validation rows, and 1,383 test rows. + +The exact original runtime can no longer be confirmed. An archived generation +notebook refers to a Windows `ngspice.exe` and an ngspice 36 manual, which is +evidence for Windows/x86_64. On the other hand, a small reproduction with +ngspice 44.2 on an ARM64 Mac is much closer to the published values than the +corresponding ngspice 44.2 x86_64 run, which is evidence consistent with the +dataset having been generated on ARM. Because the reproduction also changes +the ngspice version, it cannot determine the original architecture. Treat the +v0 architecture as unknown and retain both pieces of evidence. + #### Fields The dataset contains 3 fields: - `initial_design`: The 20-dimensional design variable defined above. - `DcGain`: The ratio of load vs. input voltage. - `Voltage_Ripple`: The fluctuation of voltage on the load `R0`. +The published `DcGain` and `Voltage_Ripple` values are absolute-valued outputs +from the archived generation path. v0 has no complete simulator fingerprint in +its stored metadata. + #### Creation Method We created this dataset in 3 parts. All the 3 parts are simulated with {`GS0_L1`, `GS1_L1`, `GS2_L1`, `GS3_L1`, `GS4_L1`} = {1, 0, 0, 1, 1} and {`GS0_L2`, `GS1_L2`, `GS2_L2`, `GS3_L2`, `GS4_L2`} = {1, 0, 1, 1, 0}. Here are the 3 parts: @@ -77,6 +181,71 @@ Here are the 3 parts: 2. Random sample 4608 points in the 6 + 3 + 1 = 10 dimensional space. Min and max values in each dimension will not be sampled. 3. Latin hypercube sample 4608 points in the 6 + 3 + 1 = 10 dimensional space. Each dimension is split into 10 intervals. Min and max values in each dimension will not be sampled. +### v1 + +v1 re-simulates the exact v0 `initial_design` rows without changing their +train/validation/test membership. Each output row retains: + +- `split`, `source_index`, and a SHA-256 identifier for the design; +- the original v0 labels for direct comparison; +- signed `output_voltage_mean` and signed `dc_gain`; +- `output_voltage_peak_to_peak`; +- `dc_gain_error` and `relative_voltage_ripple`; +- simulation/objective validity flags, a status, and any simulator error; +- the exact v0 dataset revision, EngiBench commit, ngspice version, and ngspice + executable checksum. + +The audited dataset is published as +[`IDEALLab/power_electronics_v1`](https://huggingface.co/datasets/IDEALLab/power_electronics_v1) +at immutable tag +[`v1.0.0`](https://huggingface.co/datasets/IDEALLab/power_electronics_v1/tree/v1.0.0) +and commit `eefad7d727ea1e5bef5e1b7088dea20a9b0cd67f`. It contains all +13,824 source rows in their original split membership. No values or rows were +clipped, removed, or imputed. The two invalid training rows (source indices +4,309 and 5,960) and one invalid validation row (source index 223) remain in +the dataset with null measurements/objectives and explicit status fields. +Consumers should retain those rows and mask them from objective losses. + +`simulation_status` describes why objectives may be null. `null` here means a +missing field in the dataset, not a clipped or imputed value: + +| Status | Measurements and objectives | Validity flags | +| --- | --- | --- | +| `ok` | Signed measurements and both objectives are finite. | `simulation_valid=true`, `objectives_valid=true` | +| `invalid_measurements` | At least one raw measurement is non-finite or peak-to-peak voltage is negative. Non-finite measurements and derived objectives are null; other raw measurements may remain. | Both false | +| `undefined_relative_voltage_ripple` | Mean voltage is zero. Raw measurements, gain, and gain error remain; relative ripple is null. | `simulation_valid=true`, `objectives_valid=false` | +| `simulation_error` | Simulation raised an error. All measurements and objectives are null; `error_type` and `error_message` are populated. | Both false | + +For an objective loss, use `objectives_valid` as the mask. Retain every source +design and split row, including invalid rows, for auditing and reproducibility. + +For backward compatibility, +`engibench.problems.power_electronics.PowerElectronics` continues to select +v0. New code selects v1 explicitly with +`from engibench.problems.power_electronics.v1 import PowerElectronics`. + +Each atomically written JSONL shard has a companion manifest containing the +selected source indices, Hugging Face dataset fingerprint, complete ngspice +version banner, platform, container image checksum, output checksum, and status +counts. The generator refuses dirty EngiBench code and an uncontainerized or +noncanonical backend by default. Development overrides are explicit and must +not be used for the published dataset. + +The generation entry point is: + +```bash +python -m engibench.problems.power_electronics.dataset_generation \ + --split test \ + --indices 0,1,2 \ + --output /path/to/results/test-pilot.jsonl \ + --work-dir /path/to/work/test-pilot \ + --ngspice-path /usr/local/bin/ngspice \ + --container-image /path/to/power-electronics-v1.sif +``` + +For a full run, use `--num-shards` and `--shard-index` for deterministic, +non-overlapping source indices. Do not use `--limit` for the published run. + ## Citation This problem is an original contribution to EngiBench and was not refactored from any prior publication. If you use this problem in your research, please cite the EngiBench paper: diff --git a/engibench/problems/power_electronics/README.md b/engibench/problems/power_electronics/README.md index e5937a55..a76a6a56 100644 --- a/engibench/problems/power_electronics/README.md +++ b/engibench/problems/power_electronics/README.md @@ -1,5 +1,9 @@ # Title +The maintained user-facing description, v0 provenance notes, v1 objective +definitions, and reproducible dataset-generation command are in +[`docs/problems/power_electronics.md`](../../../docs/problems/power_electronics.md). + ## Environment `pip install -e ".[electronics]"` @@ -29,14 +33,18 @@ scripts/install_ngspice_macos.sh "$HOME/.local/ngspice-44.2" export NGSPICE_PATH="$HOME/.local/ngspice-44.2/bin/ngspice" ``` -The installer builds the validated x86_64 binary. On Apple Silicon, it runs -through Rosetta 2 so that simulation results remain consistent with the -EngiBench reference values. +The installer builds the x86_64 binary used by the macOS test reference. On +Apple Silicon, it runs through Rosetta 2. This does not establish that the +binary reproduces the published v0 dataset; see the provenance warning in the +user-facing documentation. Architecture-dependent transient results have also been reported in the [ngspice issue tracker](https://sourceforge.net/p/ngspice/bugs/622/). That report concerns a different circuit, but documents significant AArch64 and -x86_64 differences in a numerically sensitive simulation. +x86_64 differences in a numerically sensitive simulation and has status +`closed-wont-fix`. The default v0 problem warns once about its historical +objectives and unknown dataset backend. Explicit v1 warns when its interactive +ngspice identity differs from the published Linux x86_64/ngspice 44.2 backend. On every platform, an explicit `PowerElectronics(ngspice_path=...)` argument takes precedence over `NGSPICE_PATH`, which takes precedence over `PATH`. @@ -93,9 +101,14 @@ The narrower capacitor, inductor, and duty-cycle ranges in the design space are If ngSpice does not produce finite objective measurements, the simulation returns the non-finite values and emits an `InvalidNgSpiceOutputWarning` with the log-file path. This allows batch dataset generation to continue while making failed simulations visible. Python warning filters can promote this warning to an exception for strict workflows. ### Objectives -The objectives are defined by the following parameters: -- `DcGain-0.25`: The ratio of load vs. input voltage. It's desired to be as close to a preset constant, such as 0.25, as possible. -- `Voltage Ripple`: Fluctuation of voltage on the load `R0`. The lower the better. +v0 is retained with its historical API and dataset semantics. v1 stores the +signed raw measurements and minimizes: + +- `dc_gain_error = abs(output_voltage_mean / 1000 - 0.25)`; +- `relative_voltage_ripple = output_voltage_peak_to_peak / abs(output_voltage_mean)`. + +See the user-facing documentation for the provenance caveats and complete v0 +versus v1 distinction. ### Conditions There is no condition for this problem. @@ -107,7 +120,16 @@ The simulator is ngSpice circuit simulator. You can download it based on your op - Linux: `sudo apt-get install ngspice` ### Dataset -The dataset linked to this problem is hosted on the [Hugging Face Datasets Hub](https://huggingface.co/datasets/IDEALLab/power_electronics). +The historical v0 and provenance-frozen v1 datasets are hosted on the Hugging +Face Datasets Hub: + +- [`IDEALLab/power_electronics_v0`](https://huggingface.co/datasets/IDEALLab/power_electronics_v0) +- [`IDEALLab/power_electronics_v1`](https://huggingface.co/datasets/IDEALLab/power_electronics_v1/tree/v1.0.0) + +The v1 runtime's mixed-license disclosure is in +[`containers/power_electronics_v1.NOTICES.md`](../../../containers/power_electronics_v1.NOTICES.md). +Its digest-pinned pull and verification instructions are in +[`containers/power_electronics_v1.PUBLICATION.md`](../../../containers/power_electronics_v1.PUBLICATION.md). ### v0 diff --git a/engibench/problems/power_electronics/dataset_generation.py b/engibench/problems/power_electronics/dataset_generation.py new file mode 100644 index 00000000..a86efd40 --- /dev/null +++ b/engibench/problems/power_electronics/dataset_generation.py @@ -0,0 +1,522 @@ +"""Generate reproducible Power Electronics v1 dataset shards from pinned v0 rows.""" + +import argparse +from collections.abc import Callable, Iterable, Mapping, Sequence +from dataclasses import asdict +from dataclasses import dataclass +from datetime import datetime +from datetime import timezone +import hashlib +import json +import math +import os +from pathlib import Path +import platform +import subprocess +import sys +from typing import Any, Protocol + +from datasets import Dataset +from datasets import load_dataset +import numpy as np + +from engibench.problems.power_electronics.utils.ngspice import NgSpiceIdentity +from engibench.problems.power_electronics.v1 import CANONICAL_NGSPICE_SHA256 +from engibench.problems.power_electronics.v1 import CANONICAL_NGSPICE_VERSION +from engibench.problems.power_electronics.v1 import CANONICAL_PLATFORM_MACHINES +from engibench.problems.power_electronics.v1 import CANONICAL_PLATFORM_SYSTEM +from engibench.problems.power_electronics.v1 import PowerElectronics +from engibench.problems.power_electronics.v1 import PowerElectronicsSimulationResult +from engibench.problems.power_electronics.v1 import SOURCE_VOLTAGE +from engibench.problems.power_electronics.v1 import TARGET_DC_GAIN + +SOURCE_DATASET_ID = "IDEALLab/power_electronics_v0" +SOURCE_DATASET_REVISION = "5c4adb2ec5cfc71794988b1297a7ff8ffe59daa5" +CANONICAL_CONTAINER_SHA256 = "40816f203b7e1c68ae37f4d9353bd302486d77988b98733c021f1ff71f48ae02" +DATASET_SCHEMA_VERSION = 1 +MANIFEST_SCHEMA_VERSION = 1 +SPLITS = ("train", "val", "test") + + +@dataclass(frozen=True) +class GitState: + """Git provenance for the EngiBench checkout running the simulation.""" + + commit: str + dirty: bool + + +@dataclass(frozen=True) +class ContainerIdentity: + """Identity of the Apptainer image supplied to the generation command.""" + + path: str | None + sha256: str | None + + +class PowerElectronicsRunner(Protocol): + """Narrow simulator interface required by the dataset generator.""" + + @property + def simulator_identity(self) -> NgSpiceIdentity: + """Return the simulator identity.""" + + @property + def config(self) -> Any: + """Return the configuration containing the source netlist path.""" + + def simulate_verbose(self, design: np.ndarray) -> PowerElectronicsSimulationResult: + """Simulate one design with detailed outputs.""" + + +def sha256_file(path: Path) -> str: + """Return the SHA-256 digest of a file without loading it entirely in memory.""" + digest = hashlib.sha256() + with path.open("rb") as file_handle: + for chunk in iter(lambda: file_handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def design_sha256(design: Sequence[float]) -> str: + """Return a stable identifier for a source design row.""" + serialized = json.dumps(list(design), allow_nan=False, separators=(",", ":")) + return hashlib.sha256(serialized.encode()).hexdigest() + + +def read_git_state(repo_root: Path) -> GitState: + """Read the exact commit and dirty state of a Git checkout.""" + commit = subprocess.run( + ["git", "-C", str(repo_root), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + status = subprocess.run( + ["git", "-C", str(repo_root), "status", "--porcelain", "--untracked-files=normal"], + check=True, + capture_output=True, + text=True, + ).stdout + return GitState(commit=commit, dirty=bool(status.strip())) + + +def resolve_container_identity(container_image: str | None, *, allow_uncontainerized: bool) -> ContainerIdentity: + """Resolve and hash the exact container image used for a generation run.""" + supplied_path = container_image or os.environ.get("APPTAINER_CONTAINER") + if supplied_path is None: + if allow_uncontainerized: + return ContainerIdentity(path=None, sha256=None) + raise RuntimeError( + "A pinned Apptainer image is required. Run inside Apptainer, pass --container-image, " + "or use --allow-uncontainerized for local development only." + ) + + resolved_path = Path(supplied_path).expanduser().resolve() + if not resolved_path.is_file(): + raise FileNotFoundError(f"Container image does not exist: {resolved_path}") + return ContainerIdentity(path=str(resolved_path), sha256=sha256_file(resolved_path)) + + +def validate_backend( + *, + simulator_version: str, + simulator_sha256: str, + simulator_system: str, + simulator_machine: str, + container_identity: ContainerIdentity, + allow_noncanonical_backend: bool, +) -> bool: + """Fail before simulation unless the backend matches the frozen canonical identity.""" + mismatches = [] + if simulator_version != CANONICAL_NGSPICE_VERSION: + mismatches.append(f"ngspice version {simulator_version!r} != {CANONICAL_NGSPICE_VERSION!r}") + if simulator_system != CANONICAL_PLATFORM_SYSTEM: + mismatches.append(f"platform system {simulator_system!r} != {CANONICAL_PLATFORM_SYSTEM!r}") + if simulator_machine.lower() not in CANONICAL_PLATFORM_MACHINES: + mismatches.append(f"platform machine {simulator_machine!r} is not x86_64") + if simulator_sha256 != CANONICAL_NGSPICE_SHA256: + mismatches.append(f"ngspice SHA-256 {simulator_sha256!r} != {CANONICAL_NGSPICE_SHA256!r}") + if container_identity.sha256 != CANONICAL_CONTAINER_SHA256: + mismatches.append(f"container SHA-256 {container_identity.sha256!r} != {CANONICAL_CONTAINER_SHA256!r}") + + if mismatches and not allow_noncanonical_backend: + details = "\n- ".join(mismatches) + raise RuntimeError(f"Backend is not the frozen Power Electronics v1 backend:\n- {details}") + return not mismatches + + +def select_indices( + row_count: int, + *, + explicit_indices: Sequence[int] | None, + limit: int | None, + shard_index: int, + num_shards: int, +) -> list[int]: + """Select source row indices deterministically without changing their split.""" + if row_count < 0: + raise ValueError("row_count must be non-negative") + if num_shards <= 0: + raise ValueError("num_shards must be positive") + if not 0 <= shard_index < num_shards: + raise ValueError("shard_index must be in [0, num_shards)") + if limit is not None and limit < 0: + raise ValueError("limit must be non-negative") + if explicit_indices is not None and (num_shards != 1 or shard_index != 0): + raise ValueError("Explicit indices cannot be combined with sharding") + + if explicit_indices is None: + indices = list(range(shard_index, row_count, num_shards)) + else: + indices = list(explicit_indices) + if len(indices) != len(set(indices)): + raise ValueError("Explicit indices must be unique") + if any(index < 0 or index >= row_count for index in indices): + raise IndexError(f"Explicit indices must be in [0, {row_count})") + + return indices if limit is None else indices[:limit] + + +def _nullable_float(value: float) -> float | None: + return float(value) if math.isfinite(value) else None + + +def _base_record( + *, + split: str, + source_index: int, + source_row: Mapping[str, Any], + git_state: GitState, + source_revision: str, + simulator_identity: NgSpiceIdentity, + container_identity: ContainerIdentity, + netlist_sha256: str, +) -> dict[str, Any]: + design = [float(value) for value in source_row["initial_design"]] + return { + "dataset_schema_version": DATASET_SCHEMA_VERSION, + "split": split, + "source_index": source_index, + "design_sha256": design_sha256(design), + "initial_design": design, + "v0_DcGain": float(source_row["DcGain"]), + "v0_Voltage_Ripple": float(source_row["Voltage_Ripple"]), + "source_dataset_id": SOURCE_DATASET_ID, + "source_dataset_revision": source_revision, + "problem_version": PowerElectronics.version, + "engibench_git_commit": git_state.commit, + "netlist_sha256": netlist_sha256, + "simulator_version": simulator_identity.version, + "simulator_sha256": simulator_identity.executable_sha256, + "simulator_platform_system": simulator_identity.platform_system, + "simulator_platform_machine": simulator_identity.platform_machine, + "container_sha256": container_identity.sha256, + } + + +def simulation_record( + *, + split: str, + source_index: int, + source_row: Mapping[str, Any], + result: PowerElectronicsSimulationResult, + git_state: GitState, + source_revision: str, + container_identity: ContainerIdentity, + netlist_sha256: str, +) -> dict[str, Any]: + """Build one successful or measurement-invalid output record.""" + record = _base_record( + split=split, + source_index=source_index, + source_row=source_row, + git_state=git_state, + source_revision=source_revision, + simulator_identity=result.simulator_identity, + container_identity=container_identity, + netlist_sha256=netlist_sha256, + ) + record.update( + { + "output_voltage_mean": _nullable_float(result.output_voltage_mean), + "output_voltage_peak_to_peak": _nullable_float(result.output_voltage_peak_to_peak), + "dc_gain": _nullable_float(result.dc_gain), + "dc_gain_error": _nullable_float(result.dc_gain_error), + "relative_voltage_ripple": _nullable_float(result.relative_voltage_ripple), + "simulation_valid": result.simulation_valid, + "objectives_valid": result.objectives_valid, + "simulation_status": result.status, + "error_type": None, + "error_message": None, + } + ) + return record + + +def error_record( + *, + split: str, + source_index: int, + source_row: Mapping[str, Any], + error: Exception, + git_state: GitState, + source_revision: str, + simulator_identity: NgSpiceIdentity, + container_identity: ContainerIdentity, + netlist_sha256: str, +) -> dict[str, Any]: + """Build a row that preserves a simulator failure instead of dropping the design.""" + record = _base_record( + split=split, + source_index=source_index, + source_row=source_row, + git_state=git_state, + source_revision=source_revision, + simulator_identity=simulator_identity, + container_identity=container_identity, + netlist_sha256=netlist_sha256, + ) + record.update( + { + "output_voltage_mean": None, + "output_voltage_peak_to_peak": None, + "dc_gain": None, + "dc_gain_error": None, + "relative_voltage_ripple": None, + "simulation_valid": False, + "objectives_valid": False, + "simulation_status": "simulation_error", + "error_type": type(error).__name__, + "error_message": str(error), + } + ) + return record + + +def _atomic_write_json(path: Path, value: Mapping[str, Any]) -> None: + temporary_path = path.with_name(f".{path.name}.tmp-{os.getpid()}") + try: + with temporary_path.open("x", encoding="utf-8") as output_file: + json.dump(value, output_file, allow_nan=False, indent=2, sort_keys=True) + output_file.write("\n") + output_file.flush() + os.fsync(output_file.fileno()) + os.link(temporary_path, path) + finally: + temporary_path.unlink(missing_ok=True) + + +def _atomic_write_jsonl(path: Path, records: Iterable[Mapping[str, Any]]) -> None: + temporary_path = path.with_name(f".{path.name}.tmp-{os.getpid()}") + try: + with temporary_path.open("x", encoding="utf-8") as output_file: + for record in records: + output_file.write(json.dumps(record, allow_nan=False, separators=(",", ":"))) + output_file.write("\n") + output_file.flush() + os.fsync(output_file.fileno()) + os.link(temporary_path, path) + finally: + temporary_path.unlink(missing_ok=True) + + +def generate_shard( + *, + dataset: Dataset, + split: str, + indices: Sequence[int], + output_path: Path, + work_dir: Path, + source_revision: str, + git_state: GitState, + container_identity: ContainerIdentity, + ngspice_path: str | None, + allow_noncanonical_backend: bool, + problem_factory: Callable[..., PowerElectronicsRunner] = PowerElectronics, +) -> dict[str, Any]: + """Simulate v0 rows and atomically create a v1 shard without overwriting files.""" + manifest_path = output_path.with_suffix(f"{output_path.suffix}.manifest.json") + if output_path.exists() or manifest_path.exists(): + raise FileExistsError(f"Refusing to overwrite existing output: {output_path} or {manifest_path}") + + output_path.parent.mkdir(parents=True, exist_ok=True) + work_dir.mkdir(parents=True, exist_ok=True) + problem = problem_factory(target_dir=str(work_dir), ngspice_path=ngspice_path) + simulator_identity = problem.simulator_identity + netlist_path = Path(problem.config.original_netlist_path) + netlist_sha256 = sha256_file(netlist_path) + canonical_backend_validated = validate_backend( + simulator_version=simulator_identity.version, + simulator_sha256=simulator_identity.executable_sha256, + simulator_system=simulator_identity.platform_system, + simulator_machine=simulator_identity.platform_machine, + container_identity=container_identity, + allow_noncanonical_backend=allow_noncanonical_backend, + ) + records: list[dict[str, Any]] = [] + for source_index in indices: + source_row = dataset[source_index] + try: + result = problem.simulate_verbose(np.asarray(source_row["initial_design"], dtype=np.float64)) + record = simulation_record( + split=split, + source_index=source_index, + source_row=source_row, + result=result, + git_state=git_state, + source_revision=source_revision, + container_identity=container_identity, + netlist_sha256=netlist_sha256, + ) + except Exception as error: # noqa: BLE001 - failures are dataset rows and must remain visible + record = error_record( + split=split, + source_index=source_index, + source_row=source_row, + error=error, + git_state=git_state, + source_revision=source_revision, + simulator_identity=simulator_identity, + container_identity=container_identity, + netlist_sha256=netlist_sha256, + ) + records.append(record) + + _atomic_write_jsonl(output_path, records) + output_sha256 = sha256_file(output_path) + status_counts: dict[str, int] = {} + for record in records: + status = str(record["simulation_status"]) + status_counts[status] = status_counts.get(status, 0) + 1 + + manifest = { + "manifest_schema_version": MANIFEST_SCHEMA_VERSION, + "created_at_utc": datetime.now(timezone.utc).isoformat(), + "source": { + "dataset_id": SOURCE_DATASET_ID, + "revision": source_revision, + "split": split, + "source_row_count": len(dataset), + "selected_indices": list(indices), + "dataset_fingerprint": dataset._fingerprint, # noqa: SLF001 - HF exposes no public immutable fingerprint API + }, + "problem": { + "name": "power_electronics", + "version": PowerElectronics.version, + "source_voltage": SOURCE_VOLTAGE, + "target_dc_gain": TARGET_DC_GAIN, + "objectives": [ + {"name": "dc_gain_error", "direction": "minimize", "definition": "abs(dc_gain - 0.25)"}, + { + "name": "relative_voltage_ripple", + "direction": "minimize", + "definition": "output_voltage_peak_to_peak / abs(output_voltage_mean)", + }, + ], + }, + "engibench": asdict(git_state), + "simulator": asdict(simulator_identity), + "container": asdict(container_identity), + "netlist": {"path": str(netlist_path), "sha256": netlist_sha256}, + "canonical_backend_validated": canonical_backend_validated, + "runtime": { + "python": sys.version, + "platform_system": platform.system(), + "platform_machine": platform.machine(), + }, + "output": { + "path": str(output_path.resolve()), + "sha256": output_sha256, + "record_count": len(records), + "status_counts": status_counts, + }, + } + _atomic_write_json(manifest_path, manifest) + return manifest + + +def _parse_indices(value: str | None) -> list[int] | None: + if value is None: + return None + if not value.strip(): + raise argparse.ArgumentTypeError("--indices cannot be empty") + try: + return [int(index) for index in value.split(",")] + except ValueError as error: + raise argparse.ArgumentTypeError("--indices must be comma-separated integers") from error + + +def build_parser() -> argparse.ArgumentParser: + """Build the command-line parser for reproducible shard generation.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--split", choices=SPLITS, required=True) + parser.add_argument("--output", type=Path, required=True, help="Destination JSONL shard") + parser.add_argument("--work-dir", type=Path, required=True, help="Scratch directory for ngspice files") + parser.add_argument("--indices", help="Comma-separated source row indices; cannot be combined with sharding") + parser.add_argument("--limit", type=int, help="Limit rows after deterministic index selection") + parser.add_argument("--shard-index", type=int, default=0) + parser.add_argument("--num-shards", type=int, default=1) + parser.add_argument("--ngspice-path") + parser.add_argument("--container-image", help="Exact .sif path; defaults to APPTAINER_CONTAINER") + parser.add_argument( + "--allow-uncontainerized", + action="store_true", + help="Permit local development outside Apptainer; never use for the canonical dataset", + ) + parser.add_argument( + "--allow-dirty-code", + action="store_true", + help="Permit a dirty EngiBench checkout; never use for the canonical dataset", + ) + parser.add_argument( + "--allow-noncanonical-backend", + action="store_true", + help="Permit a backend other than ngspice 44.2 on Linux x86_64; comparison/development only", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Generate one deterministic Power Electronics v1 dataset shard.""" + args = build_parser().parse_args(argv) + repo_root = Path(__file__).resolve().parents[3] + git_state = read_git_state(repo_root) + if git_state.dirty and not args.allow_dirty_code: + raise RuntimeError("EngiBench checkout is dirty; commit it or pass --allow-dirty-code for local development only.") + container_identity = resolve_container_identity( + args.container_image, + allow_uncontainerized=args.allow_uncontainerized, + ) + dataset = load_dataset( + SOURCE_DATASET_ID, + revision=SOURCE_DATASET_REVISION, + split=args.split, + ) + if not isinstance(dataset, Dataset): + raise TypeError(f"Expected a Dataset for split {args.split!r}, got {type(dataset).__name__}") + indices = select_indices( + len(dataset), + explicit_indices=_parse_indices(args.indices), + limit=args.limit, + shard_index=args.shard_index, + num_shards=args.num_shards, + ) + manifest = generate_shard( + dataset=dataset, + split=args.split, + indices=indices, + output_path=args.output, + work_dir=args.work_dir, + source_revision=SOURCE_DATASET_REVISION, + git_state=git_state, + container_identity=container_identity, + ngspice_path=args.ngspice_path, + allow_noncanonical_backend=args.allow_noncanonical_backend, + ) + print(json.dumps(manifest["output"], indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/engibench/problems/power_electronics/utils/netlist_handler.py b/engibench/problems/power_electronics/utils/netlist_handler.py index b78712ae..c59ef18c 100644 --- a/engibench/problems/power_electronics/utils/netlist_handler.py +++ b/engibench/problems/power_electronics/utils/netlist_handler.py @@ -58,7 +58,13 @@ def parse_topology(config: Config) -> tuple[Config, str, dict[str, list[int]], n return config, rewrite_netlist_str, edge_map, G -def rewrite_netlist(config: Config, rewrite_netlist_str: str, edge_map: dict[str, list[int]]) -> None: # noqa: C901, PLR0912 +def rewrite_netlist( # noqa: C901, PLR0912 + config: Config, + rewrite_netlist_str: str, + edge_map: dict[str, list[int]], + *, + include_raw_measurements: bool = False, +) -> None: """Rewrite the netlist based on the topology and the sweep data. It creates the direct input file sent to ngSpice. @@ -148,7 +154,13 @@ def rewrite_netlist(config: Config, rewrite_netlist_str: str, edge_map: dict[str cmp_edg_str += f"let Vdiff = V({edge_map['R0'][0]}) - V({edge_map['R0'][1]})\n" cmp_edg_str += "meas TRAN Vo_mean avg Vdiff from = 1m to = 1.06m\n" cmp_edg_str += "meas TRAN Vpp pp Vdiff from = 1m to = 1.06m\n" - cmp_edg_str += "let Gain = Vo_mean / 1000\nlet Vpp_ratio = Vpp / Vo_mean\nprint Gain, Vpp_ratio\nrun\nset filetype = binary\n" + printed_measurements = "Vo_mean, Vpp, Gain, Vpp_ratio" if include_raw_measurements else "Gain, Vpp_ratio" + cmp_edg_str += ( + "let Gain = Vo_mean / 1000\n" + "let Vpp_ratio = Vpp / Vo_mean\n" + f"print {printed_measurements}\n" + "run\nset filetype = binary\n" + ) cmp_edg_str += f"write {config.raw_file_path}\n" cmp_edg_str += "quit\n.endc\n\n.end" diff --git a/engibench/problems/power_electronics/utils/ngspice.py b/engibench/problems/power_electronics/utils/ngspice.py index afc03b34..6e9c54eb 100644 --- a/engibench/problems/power_electronics/utils/ngspice.py +++ b/engibench/problems/power_electronics/utils/ngspice.py @@ -1,5 +1,8 @@ """NgSpice wrapper for cross-platform support.""" +from dataclasses import dataclass +from functools import cached_property +import hashlib import os import platform import re @@ -12,6 +15,19 @@ NGSPICE_PATH_ENV = "NGSPICE_PATH" +@dataclass(frozen=True) +class NgSpiceIdentity: + """Immutable identity of the ngspice backend used for a simulation.""" + + version: str + major_version: int + executable_path: str + executable_sha256: str + platform_system: str + platform_machine: str + version_output: str + + class NgSpice: """A class to handle ngspice execution across different operating systems.""" @@ -113,6 +129,42 @@ def executable_path(self) -> str: """Return the resolved ngspice executable path.""" return self._ngspice_path + @cached_property + def version_output(self) -> str: + """Return the complete version banner emitted by ngspice.""" + result = subprocess.run( + [self._ngspice_path, "--version"], + capture_output=True, + text=True, + check=True, + ) + return f"{result.stdout}\n{result.stderr}".strip() + + @cached_property + def version_string(self) -> str: + """Return the complete ngspice version, including patch components.""" + match = re.search(r"\bngspice-(\d+(?:\.\d+)*)\b", self.version_output, flags=re.IGNORECASE) + if match is None: + raise RuntimeError(f"Could not determine ngspice version from: {self.version_output!r}") + return match.group(1) + + @cached_property + def identity(self) -> NgSpiceIdentity: + """Return the exact simulator and host identity used by this wrapper.""" + digest = hashlib.sha256() + with open(self._ngspice_path, "rb") as executable: + for chunk in iter(lambda: executable.read(1024 * 1024), b""): + digest.update(chunk) + return NgSpiceIdentity( + version=self.version_string, + major_version=self.version, + executable_path=self._ngspice_path, + executable_sha256=digest.hexdigest(), + platform_system=platform.system(), + platform_machine=platform.machine(), + version_output=self.version_output, + ) + @property def version(self) -> int: """Get the version of ngspice. @@ -140,24 +192,7 @@ def version(self) -> int: except OSError: print(f"Could not read ngspice docs folder at {docs_path!r}, falling back to --version flag.") - cmd = [self._ngspice_path, "--version"] - result = subprocess.run(cmd, capture_output=True, text=True, check=True) - - # Example output: - # ****** - # ** ngspice-44.2 : Circuit level simulation program - # ** Compiled with KLU Direct Linear Solver - # ** The U. C. Berkeley CAD Group - # ** Copyright 1985-1994, Regents of the University of California. - # ** Copyright 2001-2024, The ngspice team. - # ** Please get your ngspice manual from https://ngspice.sourceforge.io/docs.html - # ** Please file your bug-reports at http://ngspice.sourceforge.net/bugrep.html - # ****** - output = f"{result.stdout}\n{result.stderr}" - match = re.search(r"\bngspice-(\d+)(?:\.\d+)*\b", output, flags=re.IGNORECASE) - if match is None: - raise RuntimeError(f"Could not determine ngspice version from: {output.strip()!r}") - return int(match.group(1)) + return int(self.version_string.split(".", maxsplit=1)[0]) class NgSpiceManualNotFoundError(FileNotFoundError): diff --git a/engibench/problems/power_electronics/utils/process_log_file.py b/engibench/problems/power_electronics/utils/process_log_file.py index 87f5b120..ce6ca479 100644 --- a/engibench/problems/power_electronics/utils/process_log_file.py +++ b/engibench/problems/power_electronics/utils/process_log_file.py @@ -1,6 +1,7 @@ """Read from the log file to get the DcGain and Voltage Ripple values.""" # ruff: noqa: N806 # Upper case +from dataclasses import dataclass import warnings import numpy as np @@ -12,6 +13,14 @@ class InvalidNgSpiceOutputWarning(RuntimeWarning): """Warn that ngspice did not produce finite objective measurements.""" +@dataclass(frozen=True) +class PowerElectronicsMeasurements: + """Signed physical measurements emitted by the v1 ngspice netlist.""" + + output_voltage_mean: float + output_voltage_peak_to_peak: float + + def process_log_file(log_file_path: str) -> tuple[float, float]: """Read from log_file_path to get the DcGain and Voltage Ripple values.""" DcGain, VoltageRipple = np.nan, np.nan @@ -35,3 +44,32 @@ def process_log_file(log_file_path: str) -> tuple[float, float]: stacklevel=2, ) return DcGain, VoltageRipple + + +def process_measurements(log_file_path: str) -> PowerElectronicsMeasurements: + """Read signed mean voltage and peak-to-peak voltage from an ngspice log.""" + values = {"vo_mean": np.nan, "vpp": np.nan} + with open(log_file_path) as log: + for line in log: + parts = line.split() + if len(parts) <= MEASUREMENT_VALUE_INDEX: + continue + name = parts[0].lower() + if name not in values: + continue + try: + values[name] = float(parts[MEASUREMENT_VALUE_INDEX]) + except ValueError: + continue + + measurements = PowerElectronicsMeasurements( + output_voltage_mean=values["vo_mean"], + output_voltage_peak_to_peak=values["vpp"], + ) + if not np.all(np.isfinite((measurements.output_voltage_mean, measurements.output_voltage_peak_to_peak))): + warnings.warn( + f"ngspice did not produce finite mean-voltage and peak-to-peak measurements; see {log_file_path}.", + InvalidNgSpiceOutputWarning, + stacklevel=2, + ) + return measurements diff --git a/engibench/problems/power_electronics/v0.py b/engibench/problems/power_electronics/v0.py index a6a25442..bdd11bc1 100644 --- a/engibench/problems/power_electronics/v0.py +++ b/engibench/problems/power_electronics/v0.py @@ -4,8 +4,10 @@ """Power Electronics problem.""" from dataclasses import dataclass +from functools import cache import os from typing import Any, NoReturn +import warnings from gymnasium import spaces from matplotlib.figure import Figure @@ -36,6 +38,23 @@ MAX_PWL_DUTY_CYCLE = 1.0 - MIN_PWL_DUTY_CYCLE +class HistoricalPowerElectronicsWarning(RuntimeWarning): + """Warn that the default v0 objectives are retained only for compatibility.""" + + +@cache +def _warn_if_v0() -> None: + """Tell users once per process how to opt into the corrected v1 objectives.""" + warnings.warn( + "PowerElectronics v0 uses historical objectives (minimize gain, maximize " + "ripple) and its original simulation environment is unknown. For new " + "work, use `from engibench.problems.power_electronics.v1 import " + "PowerElectronics`; v0 is retained for compatibility with existing results.", + HistoricalPowerElectronicsWarning, + stacklevel=3, + ) + + def has_expected_shape(design: npt.NDArray) -> bool: """Return whether a design has the shape required by the indexed constraints.""" return np.shape(design) == (20,) @@ -141,6 +160,8 @@ def __init__( ``NGSPICE_PATH`` and then ``PATH`` are used. """ super().__init__(seed=seed) + if self.version == 0: + _warn_if_v0() self.config = Config( target_dir=target_dir, diff --git a/engibench/problems/power_electronics/v1.py b/engibench/problems/power_electronics/v1.py new file mode 100644 index 00000000..c24f40fd --- /dev/null +++ b/engibench/problems/power_electronics/v1.py @@ -0,0 +1,209 @@ +"""Power Electronics v1 with explicit physical measurements and corrected objectives. + +Unlike v0, v1 preserves signed measurements and minimizes gain error and +relative voltage ripple. Its dataset is regenerated from the exact v0 designs +and splits with a frozen ngspice 44.2 Linux x86_64 backend. +""" + +from dataclasses import dataclass +from functools import cache +from typing import Any +import warnings + +from datasets import Dataset +from datasets import load_dataset +import numpy as np +import numpy.typing as npt + +from engibench.core import ObjectiveDirection +from engibench.core import SimulationResult +from engibench.problems.power_electronics.utils.netlist_handler import parse_topology +from engibench.problems.power_electronics.utils.netlist_handler import rewrite_netlist +from engibench.problems.power_electronics.utils.ngspice import NgSpice +from engibench.problems.power_electronics.utils.ngspice import NgSpiceIdentity +from engibench.problems.power_electronics.utils.process_log_file import process_measurements +from engibench.problems.power_electronics.utils.process_sweep_data import process_sweep_data +from engibench.problems.power_electronics.v0 import PowerElectronics as PowerElectronicsV0 + +SOURCE_VOLTAGE = 1000.0 +TARGET_DC_GAIN = 0.25 +CANONICAL_NGSPICE_VERSION = "44.2" +CANONICAL_NGSPICE_SHA256 = "11a4334ee90509f5edfdceef541711a34a1943d26a14cf0928ac8d5947b72374" +CANONICAL_PLATFORM_SYSTEM = "Linux" +CANONICAL_PLATFORM_MACHINES = ("x86_64", "amd64") +DATASET_REVISION = "eefad7d727ea1e5bef5e1b7088dea20a9b0cd67f" + + +class NoncanonicalPowerElectronicsBackendWarning(RuntimeWarning): + """Warn when interactive v1 results may differ from the published labels.""" + + +@cache +def _warn_if_noncanonical_backend(version: str, sha256: str, system: str, machine: str) -> None: + """Warn once for each distinct noncanonical simulator identity in a process.""" + if ( + version == CANONICAL_NGSPICE_VERSION + and sha256 == CANONICAL_NGSPICE_SHA256 + and system == CANONICAL_PLATFORM_SYSTEM + and machine.lower() in CANONICAL_PLATFORM_MACHINES + ): + return + warnings.warn( + "PowerElectronics v1 is running with a backend different from the " + "published dataset (ngspice 44.2, pinned binary SHA-256, Linux x86_64). " + f"Current backend: ngspice {version}, SHA-256 {sha256}, {system}/{machine}. " + "Results may differ from v1 labels. Use the pinned runtime for canonical " + "dataset reproduction; see https://sourceforge.net/p/ngspice/bugs/622/ " + "for a reported architecture-dependent ngspice discrepancy.", + NoncanonicalPowerElectronicsBackendWarning, + stacklevel=3, + ) + + +@dataclass(frozen=True) +class PowerElectronicsMetrics: + """Raw signed measurements, derived objectives, and validity information.""" + + output_voltage_mean: float + output_voltage_peak_to_peak: float + dc_gain: float + dc_gain_error: float + relative_voltage_ripple: float + simulation_valid: bool + objectives_valid: bool + status: str + + @property + def objective_values(self) -> npt.NDArray[np.float64]: + """Return objectives in the order declared by :class:`PowerElectronics`.""" + return np.array([self.dc_gain_error, self.relative_voltage_ripple], dtype=np.float64) + + +@dataclass +class PowerElectronicsSimulationResult(SimulationResult): + """v1 simulation result with raw measurements and backend provenance.""" + + output_voltage_mean: float + output_voltage_peak_to_peak: float + dc_gain: float + dc_gain_error: float + relative_voltage_ripple: float + simulation_valid: bool + objectives_valid: bool + status: str + simulator_identity: NgSpiceIdentity + + +def derive_metrics(output_voltage_mean: float, output_voltage_peak_to_peak: float) -> PowerElectronicsMetrics: + """Derive the v1 objectives without discarding the sign of the DC gain.""" + dc_gain = output_voltage_mean / SOURCE_VOLTAGE + simulation_valid = bool( + np.all(np.isfinite((output_voltage_mean, output_voltage_peak_to_peak))) and output_voltage_peak_to_peak >= 0.0 + ) + if not simulation_valid: + return PowerElectronicsMetrics( + output_voltage_mean=output_voltage_mean, + output_voltage_peak_to_peak=output_voltage_peak_to_peak, + dc_gain=dc_gain, + dc_gain_error=np.nan, + relative_voltage_ripple=np.nan, + simulation_valid=False, + objectives_valid=False, + status="invalid_measurements", + ) + + dc_gain_error = abs(dc_gain - TARGET_DC_GAIN) + if output_voltage_mean == 0.0: + return PowerElectronicsMetrics( + output_voltage_mean=output_voltage_mean, + output_voltage_peak_to_peak=output_voltage_peak_to_peak, + dc_gain=dc_gain, + dc_gain_error=dc_gain_error, + relative_voltage_ripple=np.nan, + simulation_valid=True, + objectives_valid=False, + status="undefined_relative_voltage_ripple", + ) + + return PowerElectronicsMetrics( + output_voltage_mean=output_voltage_mean, + output_voltage_peak_to_peak=output_voltage_peak_to_peak, + dc_gain=dc_gain, + dc_gain_error=dc_gain_error, + relative_voltage_ripple=output_voltage_peak_to_peak / abs(output_voltage_mean), + simulation_valid=True, + objectives_valid=True, + status="ok", + ) + + +class PowerElectronics(PowerElectronicsV0): + """Power Electronics v1 with corrected objective semantics and provenance.""" + + version = 1 + objectives: tuple[tuple[str, ObjectiveDirection], ...] = ( + ("dc_gain_error", ObjectiveDirection.MINIMIZE), + ("relative_voltage_ripple", ObjectiveDirection.MINIMIZE), + ) + dataset_id = "IDEALLab/power_electronics_v1" + dataset_revision = DATASET_REVISION + + _ngspice_backend: NgSpice | None = None + + @property + def dataset(self) -> Dataset: + """Load the published v1 dataset at its immutable commit, not main.""" + if self._dataset is None: + self._dataset = load_dataset(self.dataset_id, revision=self.dataset_revision) + return self._dataset + + def _backend(self) -> NgSpice: + """Resolve ngspice lazily and reuse its immutable identity across simulations.""" + if self._ngspice_backend is None: + self._ngspice_backend = NgSpice(ngspice_path=self.ngspice_path) + identity = self._ngspice_backend.identity + _warn_if_noncanonical_backend( + identity.version, + identity.executable_sha256, + identity.platform_system, + identity.platform_machine, + ) + return self._ngspice_backend + + @property + def simulator_identity(self) -> NgSpiceIdentity: + """Return the exact ngspice binary and host identity used by this problem.""" + return self._backend().identity + + def simulate_verbose( + self, design: npt.NDArray, config: dict[str, Any] | None = None + ) -> PowerElectronicsSimulationResult: + """Simulate a design and return signed measurements plus v1 objectives.""" + del config + self.config, rewrite_netlist_str, edge_map, _ = parse_topology(self.config) + self.config = process_sweep_data(config=self.config, sweep_data=design.tolist()) + rewrite_netlist( + self.config, + rewrite_netlist_str, + edge_map, + include_raw_measurements=True, + ) + ngspice = self._backend() + ngspice.run(self.config.rewrite_netlist_path, self.config.log_file_path) + measurements = process_measurements(self.config.log_file_path) + metrics = derive_metrics( + measurements.output_voltage_mean, + measurements.output_voltage_peak_to_peak, + ) + return PowerElectronicsSimulationResult( + objective_values=metrics.objective_values, + output_voltage_mean=metrics.output_voltage_mean, + output_voltage_peak_to_peak=metrics.output_voltage_peak_to_peak, + dc_gain=metrics.dc_gain, + dc_gain_error=metrics.dc_gain_error, + relative_voltage_ripple=metrics.relative_voltage_ripple, + simulation_valid=metrics.simulation_valid, + objectives_valid=metrics.objectives_valid, + status=metrics.status, + simulator_identity=self.simulator_identity, + ) diff --git a/tests/test_ngspice.py b/tests/test_ngspice.py index c9208288..7d426249 100644 --- a/tests/test_ngspice.py +++ b/tests/test_ngspice.py @@ -1,5 +1,6 @@ """Tests for locating and validating the ngspice executable.""" +import hashlib from pathlib import Path import subprocess @@ -10,6 +11,7 @@ from engibench.problems.power_electronics.utils.ngspice import NgSpice VERSION_OUTPUT = "******\n** ngspice-44.2 : Circuit level simulation program\n******\n" +EXPECTED_MAJOR_VERSION = 44 def mock_version(monkeypatch: pytest.MonkeyPatch) -> None: @@ -92,3 +94,33 @@ def test_version_can_be_reported_on_stderr(monkeypatch: pytest.MonkeyPatch, tmp_ ) assert NgSpice(ngspice_path=str(configured)).version == MAX_SUPPORTED_VERSION + + +def test_version_string_preserves_patch_version(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Provenance must distinguish ngspice 44 from the 44.2 bug-fix release.""" + configured = executable(tmp_path) + monkeypatch.setattr(ngspice_module.platform, "system", lambda: "Linux") + mock_version(monkeypatch) + + ngspice = NgSpice(ngspice_path=str(configured)) + + assert ngspice.version == EXPECTED_MAJOR_VERSION + assert ngspice.version_string == "44.2" + + +def test_identity_fingerprints_binary_and_platform(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Dataset manifests need an immutable backend identity, not only a major version.""" + configured = executable(tmp_path) + monkeypatch.setattr(ngspice_module.platform, "system", lambda: "Linux") + monkeypatch.setattr(ngspice_module.platform, "machine", lambda: "x86_64") + mock_version(monkeypatch) + + identity = NgSpice(ngspice_path=str(configured)).identity + + assert identity.version == "44.2" + assert identity.major_version == EXPECTED_MAJOR_VERSION + assert identity.executable_path == str(configured) + assert identity.executable_sha256 == hashlib.sha256(configured.read_bytes()).hexdigest() + assert identity.platform_system == "Linux" + assert identity.platform_machine == "x86_64" + assert "ngspice-44.2" in identity.version_output diff --git a/tests/test_power_electronics.py b/tests/test_power_electronics.py index 174c02ba..ab629d96 100644 --- a/tests/test_power_electronics.py +++ b/tests/test_power_electronics.py @@ -9,6 +9,7 @@ from engibench.constraint import Violations from engibench.problems.power_electronics.utils.process_log_file import InvalidNgSpiceOutputWarning from engibench.problems.power_electronics.utils.process_log_file import process_log_file +from engibench.problems.power_electronics.utils.process_log_file import process_measurements from engibench.problems.power_electronics.v0 import DUTY_CYCLE from engibench.problems.power_electronics.v0 import PowerElectronics from engibench.problems.power_electronics.v0 import SWITCH_LEVELS @@ -106,6 +107,18 @@ def test_process_log_file_reads_finite_objectives(tmp_path: Path) -> None: assert process_log_file(str(log_path)) == (1.25, 0.125) +def test_process_measurements_preserves_signed_mean_voltage(tmp_path: Path) -> None: + log_path = tmp_path / "simulation.log" + log_path.write_text("vo_mean = -250.0\nvpp = 25.0\n") + + measurements = process_measurements(str(log_path)) + + np.testing.assert_allclose( + (measurements.output_voltage_mean, measurements.output_voltage_peak_to_peak), + (-250.0, 25.0), + ) + + @pytest.mark.parametrize( "contents", [ diff --git a/tests/test_power_electronics_dataset_generation.py b/tests/test_power_electronics_dataset_generation.py new file mode 100644 index 00000000..446718a0 --- /dev/null +++ b/tests/test_power_electronics_dataset_generation.py @@ -0,0 +1,203 @@ +import json +from pathlib import Path + +from datasets import Dataset +import numpy as np +import pytest + +from engibench.problems.power_electronics.dataset_generation import CANONICAL_CONTAINER_SHA256 +from engibench.problems.power_electronics.dataset_generation import CANONICAL_NGSPICE_SHA256 +from engibench.problems.power_electronics.dataset_generation import ContainerIdentity +from engibench.problems.power_electronics.dataset_generation import design_sha256 +from engibench.problems.power_electronics.dataset_generation import generate_shard +from engibench.problems.power_electronics.dataset_generation import GitState +from engibench.problems.power_electronics.dataset_generation import resolve_container_identity +from engibench.problems.power_electronics.dataset_generation import select_indices +from engibench.problems.power_electronics.dataset_generation import validate_backend +from engibench.problems.power_electronics.utils.ngspice import NgSpiceIdentity +from engibench.problems.power_electronics.v1 import PowerElectronicsSimulationResult + +SIMULATOR_IDENTITY = NgSpiceIdentity( + version="44.2", + major_version=44, + executable_path="/opt/ngspice/bin/ngspice", + executable_sha256=CANONICAL_NGSPICE_SHA256, + platform_system="Linux", + platform_machine="x86_64", + version_output="ngspice-44.2 : Circuit level simulation program", +) +FAILED_DESIGN_MARKER = 2.0 +OUTPUT_VOLTAGE_MEAN = -250.0 +DC_GAIN = -0.25 +FAILED_V0_DC_GAIN = -0.2 +RECORD_COUNT = 2 + + +class FakeProblem: + simulator_identity = SIMULATOR_IDENTITY + + def __init__(self, *, target_dir: str, ngspice_path: str | None) -> None: + self.target_dir = target_dir + self.ngspice_path = ngspice_path + netlist_path = Path(target_dir) / "source.net" + netlist_path.parent.mkdir(parents=True, exist_ok=True) + netlist_path.write_text("test netlist\n") + self.config = type("Config", (), {"original_netlist_path": str(netlist_path)})() + + def simulate_verbose(self, design: np.ndarray) -> PowerElectronicsSimulationResult: + if design[0] == FAILED_DESIGN_MARKER: + raise RuntimeError("deliberate failure") + return PowerElectronicsSimulationResult( + objective_values=np.array([0.5, 0.1]), + output_voltage_mean=-250.0, + output_voltage_peak_to_peak=25.0, + dc_gain=-0.25, + dc_gain_error=0.5, + relative_voltage_ripple=0.1, + simulation_valid=True, + objectives_valid=True, + status="ok", + simulator_identity=self.simulator_identity, + ) + + +def test_select_indices_is_deterministic_and_preserves_source_indices() -> None: + assert select_indices(10, explicit_indices=None, limit=None, shard_index=1, num_shards=3) == [1, 4, 7] + assert select_indices(10, explicit_indices=None, limit=2, shard_index=1, num_shards=3) == [1, 4] + assert select_indices(10, explicit_indices=[8, 2], limit=None, shard_index=0, num_shards=1) == [8, 2] + + +@pytest.mark.parametrize( + ("kwargs", "error_type"), + [ + ({"row_count": 10, "explicit_indices": [10], "limit": None, "shard_index": 0, "num_shards": 1}, IndexError), + ({"row_count": 10, "explicit_indices": [2, 2], "limit": None, "shard_index": 0, "num_shards": 1}, ValueError), + ({"row_count": 10, "explicit_indices": [2], "limit": None, "shard_index": 1, "num_shards": 2}, ValueError), + ], +) +def test_select_indices_rejects_ambiguous_or_invalid_selection(kwargs: dict, error_type: type[Exception]) -> None: + with pytest.raises(error_type): + select_indices(**kwargs) + + +def test_design_hash_is_stable_and_order_sensitive() -> None: + assert design_sha256([1.0, 2.0]) == design_sha256([1.0, 2.0]) + assert design_sha256([1.0, 2.0]) != design_sha256([2.0, 1.0]) + + +def test_container_is_required_unless_explicitly_relaxed(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("APPTAINER_CONTAINER", raising=False) + with pytest.raises(RuntimeError, match="pinned Apptainer image"): + resolve_container_identity(None, allow_uncontainerized=False) + assert resolve_container_identity(None, allow_uncontainerized=True) == ContainerIdentity(path=None, sha256=None) + + +def test_canonical_backend_requires_matching_architecture_and_frozen_hashes() -> None: + container = ContainerIdentity(path="/image.sif", sha256=CANONICAL_CONTAINER_SHA256) + assert validate_backend( + simulator_version="44.2", + simulator_sha256=CANONICAL_NGSPICE_SHA256, + simulator_system="Linux", + simulator_machine="x86_64", + container_identity=container, + allow_noncanonical_backend=False, + ) + with pytest.raises(RuntimeError, match="platform machine 'arm64' is not x86_64"): + validate_backend( + simulator_version="44.2", + simulator_sha256=CANONICAL_NGSPICE_SHA256, + simulator_system="Darwin", + simulator_machine="arm64", + container_identity=container, + allow_noncanonical_backend=False, + ) + + with pytest.raises(RuntimeError, match="ngspice SHA-256 'other-simulator'"): + validate_backend( + simulator_version="44.2", + simulator_sha256="other-simulator", + simulator_system="Linux", + simulator_machine="x86_64", + container_identity=container, + allow_noncanonical_backend=False, + ) + + with pytest.raises(RuntimeError, match="container SHA-256 'other-container'"): + validate_backend( + simulator_version="44.2", + simulator_sha256=CANONICAL_NGSPICE_SHA256, + simulator_system="Linux", + simulator_machine="x86_64", + container_identity=ContainerIdentity(path="/image.sif", sha256="other-container"), + allow_noncanonical_backend=False, + ) + + +def test_generate_shard_preserves_rows_failures_and_provenance(tmp_path: Path) -> None: + dataset = Dataset.from_dict( + { + "initial_design": [[1.0] * 20, [2.0] * 20], + "DcGain": [0.1, -0.2], + "Voltage_Ripple": [0.3, 0.4], + } + ) + output_path = tmp_path / "train-00000.jsonl" + git_state = GitState(commit="engibench-commit", dirty=False) + + manifest = generate_shard( + dataset=dataset, + split="train", + indices=[0, 1], + output_path=output_path, + work_dir=tmp_path / "work", + source_revision="source-revision", + git_state=git_state, + container_identity=ContainerIdentity(path="/image.sif", sha256=CANONICAL_CONTAINER_SHA256), + ngspice_path="/ngspice", + allow_noncanonical_backend=False, + problem_factory=FakeProblem, + ) + + rows = [json.loads(line) for line in output_path.read_text().splitlines()] + assert [row["source_index"] for row in rows] == [0, 1] + assert rows[0]["output_voltage_mean"] == OUTPUT_VOLTAGE_MEAN + assert rows[0]["dc_gain"] == DC_GAIN + assert rows[0]["simulation_status"] == "ok" + assert rows[0]["source_dataset_revision"] == "source-revision" + assert rows[0]["engibench_git_commit"] == "engibench-commit" + assert rows[0]["simulator_version"] == "44.2" + assert rows[0]["simulator_platform_machine"] == "x86_64" + assert rows[0]["container_sha256"] == CANONICAL_CONTAINER_SHA256 + assert rows[0]["netlist_sha256"] == manifest["netlist"]["sha256"] + assert rows[1]["simulation_status"] == "simulation_error" + assert rows[1]["error_type"] == "RuntimeError" + assert rows[1]["dc_gain_error"] is None + assert rows[1]["v0_DcGain"] == FAILED_V0_DC_GAIN + + manifest_path = output_path.with_suffix(".jsonl.manifest.json") + written_manifest = json.loads(manifest_path.read_text()) + assert written_manifest == manifest + assert manifest["source"]["selected_indices"] == [0, 1] + assert manifest["simulator"]["executable_sha256"] == CANONICAL_NGSPICE_SHA256 + assert manifest["container"]["sha256"] == CANONICAL_CONTAINER_SHA256 + assert manifest["output"]["record_count"] == RECORD_COUNT + assert manifest["output"]["status_counts"] == {"ok": 1, "simulation_error": 1} + original_output = output_path.read_text() + original_manifest = manifest_path.read_text() + + with pytest.raises(FileExistsError): + generate_shard( + dataset=dataset, + split="train", + indices=[0], + output_path=output_path, + work_dir=tmp_path / "work", + source_revision="source-revision", + git_state=git_state, + container_identity=ContainerIdentity(path="/image.sif", sha256=CANONICAL_CONTAINER_SHA256), + ngspice_path="/ngspice", + allow_noncanonical_backend=False, + problem_factory=FakeProblem, + ) + assert output_path.read_text() == original_output + assert manifest_path.read_text() == original_manifest diff --git a/tests/test_power_electronics_v1.py b/tests/test_power_electronics_v1.py new file mode 100644 index 00000000..4e7f7355 --- /dev/null +++ b/tests/test_power_electronics_v1.py @@ -0,0 +1,208 @@ +"""Tests for Power Electronics v1 objective semantics and provenance.""" + +import os +from pathlib import Path +import shutil +import warnings + +import numpy as np +import pytest + +from engibench.core import ObjectiveDirection +from engibench.problems.power_electronics import PowerElectronics as PublicPowerElectronics +from engibench.problems.power_electronics import v1 as v1_module +from engibench.problems.power_electronics.utils.ngspice import NgSpiceIdentity +from engibench.problems.power_electronics.v0 import _warn_if_v0 +from engibench.problems.power_electronics.v0 import HistoricalPowerElectronicsWarning +from engibench.problems.power_electronics.v0 import PowerElectronics as PowerElectronicsV0 +from engibench.problems.power_electronics.v1 import _warn_if_noncanonical_backend +from engibench.problems.power_electronics.v1 import CANONICAL_NGSPICE_SHA256 +from engibench.problems.power_electronics.v1 import DATASET_REVISION +from engibench.problems.power_electronics.v1 import derive_metrics +from engibench.problems.power_electronics.v1 import NoncanonicalPowerElectronicsBackendWarning +from engibench.problems.power_electronics.v1 import PowerElectronics +from tests.test_power_electronics import VALID_DESIGN + + +class FakeNgSpice: + """Backend stub that emits deterministic signed physical measurements.""" + + identity = NgSpiceIdentity( + version="44.2", + major_version=44, + executable_path="/opt/ngspice/bin/ngspice", + executable_sha256="abc123", + platform_system="Linux", + platform_machine="x86_64", + version_output="ngspice-44.2 : Circuit level simulation program", + ) + + def run(self, _netlist_path: str, log_file_path: str, timeout: int = 30) -> None: + del timeout + Path(log_file_path).write_text("vo_mean = -250.0\nvpp = 25.0\n") + + +def test_v1_is_explicit_while_v0_remains_the_public_default() -> None: + assert PublicPowerElectronics is PowerElectronicsV0 + assert PowerElectronics.version == 1 + assert PowerElectronics.dataset_id == "IDEALLab/power_electronics_v1" + assert PowerElectronicsV0.version == 0 + assert PowerElectronicsV0.dataset_id == "IDEALLab/power_electronics_v0" + assert PowerElectronicsV0.objectives == ( + ("DcGain", ObjectiveDirection.MINIMIZE), + ("Voltage_Ripple", ObjectiveDirection.MAXIMIZE), + ) + + +def test_v0_creation_warns_once_but_v1_creation_does_not(tmp_path: Path) -> None: + """The compatibility default must direct new users to v1 without spamming.""" + _warn_if_v0.cache_clear() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + PowerElectronicsV0(target_dir=str(tmp_path)) + PowerElectronicsV0(target_dir=str(tmp_path)) + PowerElectronics(target_dir=str(tmp_path)) + _warn_if_v0.cache_clear() + + historical = [warning for warning in caught if warning.category is HistoricalPowerElectronicsWarning] + assert len(historical) == 1 + assert "from engibench.problems.power_electronics.v1 import PowerElectronics" in str(historical[0].message) + + +def test_v1_dataset_is_pinned_to_published_commit(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Later dataset main-branch edits must not change the v1 problem data.""" + calls: list[tuple[str, str]] = [] + dataset = object() + + def fake_load_dataset(dataset_id: str, *, revision: str) -> object: + calls.append((dataset_id, revision)) + return dataset + + monkeypatch.setattr(v1_module, "load_dataset", fake_load_dataset) + problem = PowerElectronics(target_dir=str(tmp_path)) + + assert problem.dataset is dataset + assert problem.dataset is dataset + assert calls == [("IDEALLab/power_electronics_v1", DATASET_REVISION)] + + +def test_v1_backend_warning_is_version_specific_and_once_per_backend( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Interactive v1 warns for noncanonical platforms, including x86 with another binary.""" + _warn_if_noncanonical_backend.cache_clear() + backend = FakeNgSpice() + monkeypatch.setattr(v1_module, "NgSpice", lambda *, ngspice_path: backend) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + for _ in range(2): + problem = PowerElectronics(target_dir=str(tmp_path)) + assert problem.simulator_identity == FakeNgSpice.identity + _warn_if_noncanonical_backend("44.2", CANONICAL_NGSPICE_SHA256, "Linux", "x86_64") + _warn_if_noncanonical_backend.cache_clear() + + backend_warnings = [warning for warning in caught if warning.category is NoncanonicalPowerElectronicsBackendWarning] + assert len(backend_warnings) == 1 + assert "different from the published dataset" in str(backend_warnings[0].message) + assert "Linux/x86_64" in str(backend_warnings[0].message) + + +def test_v1_declares_both_corrected_objectives_as_minimize() -> None: + assert PowerElectronics.objectives == ( + ("dc_gain_error", ObjectiveDirection.MINIMIZE), + ("relative_voltage_ripple", ObjectiveDirection.MINIMIZE), + ) + + +def test_derive_metrics_preserves_negative_gain() -> None: + metrics = derive_metrics(output_voltage_mean=-250.0, output_voltage_peak_to_peak=25.0) + + np.testing.assert_allclose( + (metrics.dc_gain, metrics.dc_gain_error, metrics.relative_voltage_ripple), + (-0.25, 0.5, 0.1), + ) + assert metrics.simulation_valid + assert metrics.objectives_valid + assert metrics.status == "ok" + np.testing.assert_allclose(metrics.objective_values, [0.5, 0.1]) + + +def test_zero_mean_voltage_has_undefined_ripple_without_hiding_measurement() -> None: + metrics = derive_metrics(output_voltage_mean=0.0, output_voltage_peak_to_peak=25.0) + + assert metrics.dc_gain == 0.0 + np.testing.assert_allclose(metrics.dc_gain_error, 0.25) + assert np.isnan(metrics.relative_voltage_ripple) + assert metrics.simulation_valid + assert not metrics.objectives_valid + assert metrics.status == "undefined_relative_voltage_ripple" + + +@pytest.mark.parametrize( + ("mean_voltage", "peak_to_peak_voltage"), + [(np.nan, 1.0), (1.0, np.inf), (1.0, -1.0)], +) +def test_invalid_measurements_produce_invalid_objectives(mean_voltage: float, peak_to_peak_voltage: float) -> None: + metrics = derive_metrics(mean_voltage, peak_to_peak_voltage) + + assert not metrics.simulation_valid + assert not metrics.objectives_valid + assert metrics.status == "invalid_measurements" + assert np.all(np.isnan(metrics.objective_values)) + + +def test_simulate_verbose_returns_raw_measurements_and_backend_identity( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + problem = PowerElectronics(target_dir=str(tmp_path)) + backend = FakeNgSpice() + monkeypatch.setattr(problem, "_backend", lambda: backend) + + result = problem.simulate_verbose(VALID_DESIGN) + + np.testing.assert_allclose(result.objective_values, [0.5, 0.1]) + np.testing.assert_allclose( + ( + result.output_voltage_mean, + result.output_voltage_peak_to_peak, + result.dc_gain, + result.dc_gain_error, + result.relative_voltage_ripple, + ), + (-250.0, 25.0, -0.25, 0.5, 0.1), + ) + assert result.simulation_valid + assert result.objectives_valid + assert result.status == "ok" + assert result.simulator_identity == backend.identity + netlist = Path(problem.config.rewrite_netlist_path).read_text() + assert "print Vo_mean, Vpp, Gain, Vpp_ratio" in netlist + + +@pytest.mark.skipif(os.name == "nt", reason="The Windows CI ngspice 45.2 backend stalls on this transient netlist") +def test_real_ngspice_v1_parses_finite_signed_measurements(tmp_path: Path) -> None: + """Exercise the actual rewritten netlist and log parser on Linux/macOS CI.""" + if not os.environ.get("NGSPICE_PATH") and shutil.which("ngspice") is None: + pytest.skip("ngspice is not installed") + problem = PowerElectronics(target_dir=str(tmp_path)) + + result = problem.simulate_verbose(VALID_DESIGN) + + assert result.status == "ok" + assert result.simulation_valid + assert result.objectives_valid + measurements = ( + result.output_voltage_mean, + result.output_voltage_peak_to_peak, + result.dc_gain, + result.dc_gain_error, + result.relative_voltage_ripple, + ) + assert np.all(np.isfinite(measurements)) + assert result.output_voltage_peak_to_peak >= 0.0 + np.testing.assert_allclose(result.dc_gain, result.output_voltage_mean / 1000.0) + np.testing.assert_allclose(result.dc_gain_error, abs(result.dc_gain - 0.25)) + np.testing.assert_allclose( + result.relative_voltage_ripple, + result.output_voltage_peak_to_peak / abs(result.output_voltage_mean), + ) diff --git a/tests/test_problem_implementations.py b/tests/test_problem_implementations.py index 202a8921..361edb82 100644 --- a/tests/test_problem_implementations.py +++ b/tests/test_problem_implementations.py @@ -19,6 +19,7 @@ import pytest from engibench import Problem +from engibench.problems.power_electronics.v1 import PowerElectronics as PowerElectronicsV1 from engibench.utils.all_problems import BUILTIN_PROBLEMS from tests.problem_policies import problem_id from tests.problem_policies import problem_test_policy @@ -27,7 +28,7 @@ from typing import Self -@pytest.mark.parametrize("problem_class", BUILTIN_PROBLEMS.values()) +@pytest.mark.parametrize("problem_class", [*BUILTIN_PROBLEMS.values(), PowerElectronicsV1]) def test_problem_impl(problem_class: type[Problem]) -> None: """Check that all builtin problems define all required class attributes and methods.""" print(f"Testing {problem_class.__name__}...") @@ -87,7 +88,7 @@ def _problem_params() -> list[Any]: return params -@pytest.mark.parametrize("problem_class", _problem_params()) +@pytest.mark.parametrize("problem_class", [*_problem_params(), pytest.param(PowerElectronicsV1, id="PowerElectronicsV1")]) def test_problem_dataset(problem_class: type[Problem]) -> None: """Check that each published dataset has the required splits and fields.""" problem: Problem = problem_class()