Inverse-OPF simulator-calibration layer for PyPSA — recovers bid-cost vectors from observed market data
Project description
pypsa-invopt
Calibrate your PyPSA grid model to what the market actually did yesterday — then ask "what if?"
So you've built a beautiful PyPSA network of the Dutch grid. CCGTs at Maasvlakte, batteries at AM, the COBRAcable to Denmark, the whole thing. You feed it engineering-reference costs (gas heat-rate × fuel + CO₂ + O&M) and run it. The LMPs come out... fine. Plausible. But you know they're wrong, because if you plot them against yesterday's actual EPEX clearings the RMSE is like 50+ €/MWh and the peaks are in the wrong hours.
That's because nobody in the real market bids their engineering reference. They bid hedging positions, ramp-up opportunity costs, strategic markups, unit-commitment overhang. Your textbook PyPSA model has none of that. This package fixes that.
You give it (network, observed_LMPs) → it gives you back the bid vector that explains those LMPs. Drop the bids back into the network, re-solve, and now your model matches reality. Then go ask the question you actually care about: "what if I add 600 MW of BES here?" The forward solver gives you a defensible answer because the costs underneath are calibrated, not engineered.
The math is one sparse convex QP — Liang & Dvorkin's KKT reformulation from their 2023 e-Energy paper — and HiGHS solves it in tens of milliseconds. Quadratic-affine bid recovery (the α + β·q heat-rate slopes) follows Birge-Hortaçsu-Pavlin 2017. We didn't invent any of this. We wrapped it for PyPSA, added a temporal-batching trick for big thermal-only grids (ASTB), and built an identifiability layer that tells you which recovered bids you can trust and which ones to ignore.
Wait — isn't this just an LMP forecaster?
No, and please don't use it as one. ML is way better at LMP point forecasting. Modo Energy, Aurora, Eperion — they all use gradient-boosted trees or LSTMs, and they get 3–5 €/MWh RMSE on real European DA markets. This package gets ~7 €/MWh on the §10 cross-validation in the example notebook. Worse than ML. We won't pretend otherwise.
So why would you ever reach for this instead?
Because ML is structurally incapable of answering counterfactual questions. It learned a mapping from features to LMP. Ask it "what's the LMP if I add a 600 MW battery at AM bus?" and it has no training data for that — that state doesn't exist in history. Ask "what if CCGT-X trips offline?" same problem. "What if CO₂ price doubles?" extrapolates badly. "Decompose tomorrow's €95 peak into thermal-bid + congestion + carbon-shadow"? It can't — it's a black box. "Is this CCGT strategically withholding?" — the learned LMP includes the withholding, so it can't separate it out.
| What you're trying to do | Use ML | Use this |
|---|---|---|
| Point-forecast tomorrow's LMP | yes | no |
| Size a new BES project (need to know LMP post-installation) | no | yes |
| "What if CCGT-X trips?" | no | yes |
| Decompose LMP into bid + congestion + CO₂ shadow | no | yes |
| Detect strategic withholding by a competitor | no | yes |
| "What if CO₂ price doubles?" | no | yes |
| Defend a number to your board / regulator | "model said so" | physical price-formation story |
| Latency in production | ms | seconds–minutes |
The correct architecture is both, not either. Run ML for the headline forecast. Run this package for the scenario fan around it. A BES desk that uses ML-only is flying blind on counterfactuals — which is every investment decision. A desk that uses this package only is leaving 30 %+ point-forecast accuracy on the table.
When this earns its keep
There are basically three regimes where you genuinely need inverse-OPF:
1. Infrastructure planning — you're sizing a new asset and counterfactuals on textbook costs would give you garbage. A BESS developer evaluating 600 MW at Amsterdam needs to know what the LMP curve actually looks like post-installation, not what the engineering model thinks it should look like. The §9 walkthrough in examples/full_lifecycle_NL.ipynb is exactly this workflow end-to-end.
2. Market monitoring — you suspect a generator is strategically withholding capacity. Birge-Hortaçsu-Pavlin (2017) on MISO is the canonical paper; pio.flag_withholding implements the same scorer. You compare recovered marginal cost against engineering reference (fuel + heat-rate + CO₂ + O&M); large positive gap → flag.
3. Decomposed LMP attribution — you want to know why an LMP spike happened. Was it bid markup? Congestion on a specific line? CO₂ cap binding? With inverse-OPF + the network duals, each piece falls out of the QP — you can do risk decomposition that ML can't.
For NL specifically: case 2 is the sweet spot — batteries, gas, offshore wind, grid getting tighter every year, big project pipelines. Anyone running PyPSA simulations with textbook costs is feeding their planner garbage.
What's in the box
- Three formulations.
noiseless(KKT-equality LP, fast, for clean synthetic data),noisy(KKT-residual QP, the default, canonical L-D 2023),zonal(bidding-zone level for EUPHEMIA-style DA clearings). - Active-Set Temporal Batching (ASTB) for pure-thermal grids — the package partitions the snapshot set by active-set signature and solves each batch independently, then aggregates via BLUE. Collapses to one batch when storage or links or global constraints are present (the math forces this — they couple snapshots intertemporally — not an algorithmic limitation).
- Bayesian posterior over recovered bids — Laplace approximation (default, fast) or full NUTS-MCMC (slow, but exact when you need it). Both give you per-parameter σ.
- Identifiability flagging. Not every bid is recoverable from every dataset — if a generator is always at p_max it can bid anything ≤ LMP and the data won't pin it. The package flags these explicitly via
pio.identifiability; you can then fall back to engineering reference for the unidentifiable ones. - Strategic-withholding scorer (
pio.flag_withholding) — BHP-2017 reproduced. observations_from_pypsa(network)— one call to extract the observation DataFrame from a solved PyPSA network. No manual schema wrangling.validate_observations+assess_data_quality— pre-flight checks that catch gappy or spiky data before you spend a solver run. Essential for real ENTSO-E downloads (Europe).
Install
pip install pypsa-invopt # core
pip install pypsa-invopt[mcmc] # + pymc + arviz (for NUTS posterior)
pip install pypsa-invopt[entso_e] # + entsoe-py (for European DA data)
pip install pypsa-invopt[all] # everything
Quick start
import pypsa
import pypsa_invopt as pio
# 1. Load your PyPSA network, solve forward once, extract observations
n = pypsa.Network('my_network.nc')
n.optimize(solver_name='highs')
obs = pio.observations_from_pypsa(n) # price_<bus>, dispatch_<gen>, …
# Optional but recommended for real data — catches gaps/spikes early
pio.validate_observations(obs)
print(pio.assess_data_quality(obs))
# 2. Recover the bids — one HiGHS solve, ~50 ms on a small grid
result = pio.calibrate(
network=n, observations=obs,
formulation='noisy', # canonical L-D 2023 KKT-QP
lambda_reg=1e-6, obs_sigma=1.0,
)
# 3. Write them back into the network
pio.apply(result, n)
# 4. Diagnostics — what's trustworthy, what isn't, who's withholding
posterior = pio.posterior(n, obs, result, method='laplace',
prior_std=5.0, obs_std=1.0)
report = pio.identifiability(posterior)
flags = pio.flag_withholding(
theta_hat=result.theta_hat,
generator_carriers={g: n.generators.at[g, 'carrier'] for g in n.generators.index},
posterior_identifiability=report,
fuel_prices={'gas': 32, 'coal': 12, 'wind': 0},
co2_price=75,
)
# 5. Now ask the question you actually care about
n.add('StorageUnit', 'new_BES', bus='AM', p_nom=600, max_hours=4)
n.optimize(solver_name='highs') # forward DCOPF with calibrated bids
new_lmps = n.buses_t.marginal_price
Real ENTSO-E zonal data (Europe)
European DA markets clear at the bidding-zone level (EUPHEMIA), so use formulation='zonal':
obs = pio.load_entso_e(bidding_zone='NL', start='2025-06-15',
end='2025-06-16', synthesize=True)
result = pio.calibrate(
network=n, observations=obs,
formulation='zonal',
bus_to_zone={'b1': 'NL', 'b2': 'NL'}, # map each bus to its zone
lambda_reg=1e-6,
)
Does it actually work?
Yes — on real European EPEX data. Headline numbers from examples/real_data_DE_LU_validation.ipynb (DE_LU day-ahead, week of 11 Nov 2019, calibrated on the preceding week):
| Held-out-week LMP RMSE | EUR/MWh |
|---|---|
| Engineering reference (fuel + heat-rate + CO₂ + O&M) | 22.80 |
| Inverse-OPF calibrated bids | 5.66 |
| Improvement | −75.2 % |
Data source: Open Power System Data (OPSD) 2020-10-06 release, ENTSO-E Transparency snapshot. Public, citeable, no API key. Shipped slice at examples/data/de_lu_2019_validation.csv (38 KB).
The rest of the test stack
- 89 unit tests pass, 85 % line coverage. Core math modules (formulations / calibration / network / identifiability / reference-cost / posterior) sit at 83–100 %. Optional integration paths (
bayes/mcmc.pyneeds PyMC) skip gracefully. - Notebook-execution integration test runs both example notebooks headless in CI on every push.
- Storage-marginality test. Recovers truth
c_s = €45 → €44.98on a 2-bus network where the battery sets the marginal price at evening peak. - CO₂ shadow-price recovery. Within ±$15/tCO₂ of the analytical KKT switch-point value.
- Synthetic stress-test.
examples/full_lifecycle_NL.ipynb§10 calibrated vs deliberately-50%-perturbed reference: 88 % RMSE reduction. The number to act on is the 75 % on real EPEX data above; the synthetic number is just the upper bound when you control the truth. - Real-data ENTSO-E path.
examples/real_data_entso_e.ipynbwires the same pipeline to the live ENTSO-E Transparency API (NL bidding zone). Runs in full with anENTSOE_API_KEYenv var; falls back to a fixture otherwise.
What this package is not
- Not an LMP forecaster. For point prediction, ML beats this. We will not pretend otherwise. See the ML-vs-this table above.
- Not a replacement for your ML / statistical forecasting stack. Use this alongside it, for the counterfactual questions ML can't answer.
- Not a trading product. Using competitor-specific recovered bids to inform your own bidding edges into EU competition-law territory. The safe regime is portfolio-level LMP forecasting + market monitoring, not "I see CCGT-X is bidding €68 so I'll undercut at €67."
- Not a market-monitor replacement. Regulators (FERC, ACER, ACM) use proprietary audited tools. The
flag_withholdingscorer is a screening tool, not a finding. - Not a step-function bid recoverer. Real EUPHEMIA bids are step functions of quantity (multiple price-quantity blocks). This package recovers linear + quadratic only — matches L-D 2023 / BHP 2017 SOTA. Step recovery is open research.
- Not a mixed-integer inverse-OPF. Unit commitment with binary on/off + startup costs requires complementarity conditions, not linear KKT. Also open research.
References
- Liang Z., Dvorkin Y. (2023) "Data-Driven Inverse Optimization for Marginal Offer Price Recovery in Electricity Markets." arXiv:2302.05498. The canonical single-level KKT-QP reformulation this package implements.
- Birge J.R., Hortaçsu A., Pavlin J.M. (2017) "Inverse Optimization for the Recovery of Market Structure from Market Outcomes." Operations Research 65(4). Quadratic-affine bid recovery + the market-monitoring application behind
flag_withholding. - Aswani A., Shen Z.J., Siddiq A. (2018) "Inverse Optimization with Noisy Data." Operations Research 66(3). The noisy-KKT reformulation behind
formulation='noisy'. - Stuart A.M. (2010) "Inverse Problems: A Bayesian Perspective." Acta Numerica 19. Laplace + posterior σ for parameter identifiability.
- Brown T., Hörsch J., Schlachtberger D. (2018) "PyPSA: Python for Power System Analysis." JORS 6(4).
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pypsa_invopt-0.1.0a1.tar.gz.
File metadata
- Download URL: pypsa_invopt-0.1.0a1.tar.gz
- Upload date:
- Size: 975.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
96a9bf5c258fbc7b4d96e97d8737f9bd31536d6a148bbf36b8cc08c21e39fe19
|
|
| MD5 |
12cf28316e2d4bb27f7c947b52119fbb
|
|
| BLAKE2b-256 |
0c62293795886bf886e5dc9e326910dcbc402c7dad30568736894e0ccb2fb754
|
File details
Details for the file pypsa_invopt-0.1.0a1-py3-none-any.whl.
File metadata
- Download URL: pypsa_invopt-0.1.0a1-py3-none-any.whl
- Upload date:
- Size: 92.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
be54d4888a197bb525b64201572323b6d272750bdb4248c6a76cc6c3e0b15308
|
|
| MD5 |
82f95494aa75db8da1a5c981e20d6c0a
|
|
| BLAKE2b-256 |
56e4fef854a8bccea8eb6c32bdf6d19e3afc197db706674339f2dd57d8464b83
|