# Created: 2026-06-05
# (c) Copyright 2025 ETH Zurich
#
# Licensed under the GNU General Public License v3.0;
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at:
#
# https://www.gnu.org/licenses/gpl-3.0.en.html
#
# This software is distributed "AS IS", WITHOUT WARRANTY OF ANY KIND,
# express or implied. See the License for specific language governing
# permissions and limitations under the License.
"""Shared configuration + runner for the inverter byte-identical baselines.
Single source of truth for the ``IEEE39_bus_inverter`` baseline runs, imported by
both ``create_baseline_inverter.py`` (which pickles the references) and the
``test_recur_{sim,est}_inverter.py`` gates (which compare against them). Keeping
the config here prevents drift between baseline-creation and the tests -- any
drift would surface as a spurious byte-identity failure rather than a real
regression.
This is **Phase 0** of ``docs/inverter_modernization_design.md``: the byte-identical
safety net the whole inverter refactor rests on. Three cases gate the surface the
``fgcall``/``finit`` refactor touches:
- ``sim_ld`` -- SIM, ``line_dyn=True`` (ODE; cvodes): 2 GridForming + 3
GridFollowing + 5 SGs through the t=4.0 bus fault. Run at the production output
step ``ts=1e-4`` but stored **decimated** (every ``SIM_LD_STRIDE``-th sample) so
the pickle stays ~1 MB -- a byte-identical refactor matches at every sample, so
the decimated reference is just as catching.
- ``sim_alg`` -- SIM, ``line_dyn=False`` (algebraic network / DAE; idas): the
distinct code path the eventual ``LCL_static`` realization will exercise.
- ``est`` -- estimation (iekf) over the IEEE39_bus_inverter est model, which
carries **both** a GridForming and a GridFollowing (``GFLI7_est`` at bus 36) so
the GFL/PLL estimation path (qcall, algebraic weights, finit) is gated too.
Baselines store only the ``x_full`` numpy arrays, not the whole DaeSim/DaeEst
objects: under pytest's ``pythonpath="pydynamicestimator"`` +
``--import-mode=importlib``, ``system.DaeSim`` is reachable under two module paths,
so whole-object pickling raises "it's not the same object". The arrays are all the
gates compare anyway.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
from pydynamicestimator.config import config
from pydynamicestimator.run import run
FIXTURE_ROOT = Path(__file__).resolve().parent.parent / "fixtures"
_BASE = Path(__file__).resolve().parent
#: Decimation stride for the line_dyn=True sim (run at ts=1e-4, stored every 50th
#: sample -> 5e-3 effective spacing) to keep the pickle ~1 MB.
SIM_LD_STRIDE = 50
#: End time; the bus fault fires at t=4.0, leaving ~1 s of post-fault transient.
T_END = 5.0
BASELINES = {
"sim_ld": _BASE / "baseline_inverter_sim_linedyn.pkl",
"sim_alg": _BASE / "baseline_inverter_sim_alg.pkl",
"est": _BASE / "baseline_inverter_est.pkl",
}
_COMMON = dict(
testsystemfile="IEEE39_bus_inverter",
system_root=FIXTURE_ROOT,
omega_mode="nom",
fn=50,
Sb=100,
incl_lim=False,
T_start=0.0,
T_end=T_END,
plot=False,
plot_voltage=False,
plot_diff=False,
print_power_flow=False,
log_level="WARNING",
)
# A fixed, JIT-free integrator setup for cross-run determinism (the config default
# uses jit=True + reltol=1e-14, which add a compiler dependency we don't want in a
# byte-identity gate).
_INT_OPTS = {"reltol": 1e-10, "max_num_steps": 500000}
[docs]
def make_inverter_baseline_config(case: str):
"""Return the config for one baseline ``case`` (``sim_ld`` / ``sim_alg`` / ``est``)."""
if case == "sim_ld":
return config.updated(
**_COMMON,
line_dyn=True,
skip_est=True,
int_scheme_sim="cvodes",
int_scheme_sim_options=_INT_OPTS,
ts=0.0001,
)
if case == "sim_alg":
return config.updated(
**_COMMON,
line_dyn=False,
skip_est=True,
int_scheme_sim="idas",
int_scheme_sim_options=_INT_OPTS,
ts=0.005,
)
if case == "est":
return config.updated(
**_COMMON,
line_dyn=True,
skip_est=False,
int_scheme="backward",
int_scheme_sim="cvodes",
int_scheme_sim_options=_INT_OPTS,
ts=0.005,
te=0.02,
filter="iekf",
proc_noise_alg=1e-3,
proc_noise_diff=1e-4,
init_error_diff=1,
init_error_alg=True,
)
raise ValueError(f"unknown baseline case {case!r}")
[docs]
def run_inverter_case(case: str) -> np.ndarray:
"""Run one baseline ``case`` and return the array the gate compares (the
estimated trajectory for ``est``, otherwise the simulated trajectory;
decimated for ``sim_ld``)."""
est, sim = run(make_inverter_baseline_config(case))
if case == "est":
return np.asarray(est.x_full)
arr = np.asarray(sim.x_full)
if case == "sim_ld":
arr = arr[:, ::SIM_LD_STRIDE]
return arr