gridoxide 0.1.0

AC power flow and state estimation solver with a sparse Newton-Raphson core
Documentation
# gridoxide

Python bindings for [`gridoxide`](https://github.com/m-mirz/gridoxide), a Rust AC power flow and
state estimation solver, both Newton-based with a sparse Jacobian (via
[`faer`](https://crates.io/crates/faer)).

Grids are loaded from [power-grid-model (PGM)](https://github.com/PowerGridModel/power-grid-model) JSON
input files. Repeated solves against the same topology reuse a cached symbolic factorization, so scenario
sweeps and time-series runs only pay the fill-reducing-ordering cost once.

## Install

```bash
pip install gridoxide
```

Prebuilt wheels are published for Linux (x86_64), Windows, and macOS (arm64).

## Quickstart

```python
import gridoxide

model = gridoxide.PowerFlowModel.from_pgm_json("grid.json", backend="scalar")
model.solve()
print(model.voltage_mag())  # per-unit voltage magnitude, one entry per node
print(model.voltage_ang())  # voltage angle in radians, one entry per node
```

`grid.json` is a PGM-format input file, e.g.:

```json
{
  "version": "1.0",
  "type": "input",
  "is_batch": false,
  "attributes": {},
  "data": {
    "node": [
      {"id": 1, "u_rated": 10500.0},
      {"id": 2, "u_rated": 10500.0}
    ],
    "line": [
      {"id": 3, "from_node": 1, "to_node": 2, "from_status": 1, "to_status": 1,
       "r1": 0.25, "x1": 0.2, "c1": 1e-06, "tan1": 0.0,
       "r0": 0.25, "x0": 0.2, "c0": 1e-06, "tan0": 0.0, "i_n": 1000.0}
    ],
    "sym_load": [
      {"id": 9, "node": 2, "status": 1, "type": 0, "p_specified": 10000.0, "q_specified": 2000.0}
    ],
    "source": [
      {"id": 4, "node": 1, "status": 1, "u_ref": 1.0, "sk": 1e10, "rx_ratio": 0.1, "z01_ratio": 1.0}
    ]
  }
}
```

## Reusing factorization across repeated solves

`PowerFlowModel` wraps gridoxide's `PersistentSolver`: construct one per topology, then call `.solve()`
as many times as needed. Only the first call pays for symbolic factorization; later calls only need
values (`p_specified`/`q_specified`) to change, not the topology.

```python
model = gridoxide.PowerFlowModel.from_pgm_json("grid.json", backend="scalar")
for scenario in scenarios:
    apply_scenario(model, scenario)  # e.g. edit and re-load p/q values
    model.solve()
    results.append(model.voltage_mag())
```

Call `model.reset()` if the topology itself changes between solves (not just bus values).

## Building input grids

Two helpers produce or convert PGM JSON, so a working grid doesn't require hand-writing one:

- `gridoxide.generate_grid` — generates a synthetic radial MV/LV distribution grid at any scale, no
  extra dependencies needed:

  ```python
  from gridoxide.generate_grid import generate
  generate(target_nodes=2200, seed=42, out_path="grid.json")  # ~2,600 nodes
  ```

  or from the shell: `gridoxide-generate-grid grid.json --target-nodes 2200 --seed 42`.

- `PowerFlowModel.from_matpower("case14.m")` reads a MATPOWER `.m` case directly, solving the
  problem MATPOWER's `runpf` solves; `bus_numbers()` names each bus. See
  [Reading MATPOWER Input]https://github.com/m-mirz/gridoxide/blob/main/docs/src/import/matpower.md.

- `gridoxide.matpower` (needs `pip install gridoxide[matpower]`) — converts a raw MATPOWER case
  (`.m` or `.mat`) into PGM JSON, for tools that read PGM. It cannot carry a case exactly: PGM's
  slack is a source behind an impedance, and its transformers hold phase shifts in 30° steps only:

  ```python
  from gridoxide.matpower import convert
  convert("case14.m", "case14.json")
  ```

  or `gridoxide-matpower case14.m case14.json`.

`gridoxide` deliberately has no pandapower-based converter: it would need the full pandapower +
power-grid-model-io dependency chain, and `gridoxide.matpower` already covers the same real-world
test-case grids straight from their original MATPOWER source files without it. If you already have a
`pandapower.pandapowerNet` object and pandapower installed, see
[`scripts/bench/convert_pandapower_case.py`](https://github.com/m-mirz/gridoxide/blob/main/scripts/bench/convert_pandapower_case.py)
in the main repo for a standalone (not part of this package) converter.

## API

- `PowerFlowModel.from_pgm_json(path, backend="scalar", tol=1e-6, max_iter=20, s_base_va=1e6, freq_hz=50.0)`
  — loads a PGM JSON file and builds the Y-bus admittance matrix.
- `model.n_nodes` — number of buses, including one virtual slack bus per active `source`.
- `model.solve()` — runs Newton-Raphson from a flat/linear-initial-guess start; raises `RuntimeError` if
  it doesn't converge within `max_iter` iterations.
- `model.reset()` — discards the cached symbolic factorization; call before the next `solve()` if the
  topology has changed.
- `model.voltage_mag()` / `model.voltage_ang()` — per-bus results in node order (per-unit magnitude,
  radians).

### Backends

`backend` selects the linear solver used inside the Newton-Raphson Jacobian:

| Backend | Notes |
|---|---|
| `"klu_native"` (default) | From-scratch Rust translation of SuiteSparse KLU, always available in this wheel; about six times faster than `"scalar"` on large networks. |
| `"scalar"` | Sparse LU via `faer`, no special build requirements. |
| `"block"` | Block-structured variant of the same solver (one 2×2 block per bus); faster on some topologies. |

Two additional backends exist in the source tree but are **not** included in the published wheel, since
they need extra system dependencies at build time — build gridoxide from source with the matching Cargo
feature to use them:

- `"klu"` — links vendored SuiteSparse C directly (`--features python,klu`).
- `"pardiso"` — Intel oneMKL's PARDISO solver (`--features python,pardiso`, needs `MKLROOT` set).

## State estimation

`StateEstimationModel` recovers the most likely grid state from measurements, rather than computing
voltages from known injections. Its input is a PGM document carrying `sym_voltage_sensor` and/or
`sym_power_sensor` entries — and notably it does *not* need `p_specified` on its loads or `u_ref` on
its sources, since those are quantities the estimator solves for rather than inputs it consumes.

```python
import gridoxide

model = gridoxide.StateEstimationModel.from_pgm_json("grid_with_sensors.json")
model.solve()

print(model.voltage_mag())      # per-unit magnitude, one entry per bus
print(model.voltage_ang())      # radians
print(model.residuals())        # z - h(x), one per measurement
print(model.objective)          # J(x) = 1/2 r^T W r
```

A converged estimate is not automatically a trustworthy one, so two analyses come with it:

```python
# What the measurements fail to determine, as (bus, "angle" | "magnitude").
# Empty means fully observable. Entries beyond the physical node count are
# gridoxide's own synthesized buses (one virtual slack bus per source), which
# are expected whenever a source's own power is unmeasured.
print(model.observability())

# (chi_squared, degrees_of_freedom, p_value, [(measurement_index, normalized_residual), ...])
# p below 0.05 conventionally means the measurements are not merely noisy;
# a normalized residual above 3 conventionally identifies the culprit.
print(model.bad_data())
```

`from_pgm_json` takes the same `backend`, `tol`, `max_iter`, `s_base_va` and `freq_hz` arguments as
`PowerFlowModel`. Every backend produces the same estimate — the gain matrix is an ordinary square
sparse system — so the choice is performance only.

## License

gridoxide's own code is Apache-2.0. A default build (and this published wheel) also always bundles
`crates/gridoxide-sparse/src/klu_native/`, a from-scratch Rust translation of vendored SuiteSparse AMD/BTF/KLU source
(BSD-3-Clause for the AMD-derived pieces, LGPL-2.1-or-later for the BTF/KLU-derived pieces) — see the
[main README's License section](https://github.com/m-mirz/gridoxide#license) for the full breakdown.