Case augmentation
From a faithful import to a meaningful benchmark
An imported distribution model needs explicit study assumptions before it can serve as an OPF benchmark. Imports can already carry ratings, source controls, and limits; missing voltage bounds do not guarantee that the nonlinear power-flow equations have a solution. Conversion fidelity and operating feasibility must be assessed separately.
Augmentation adds selected bounds, costs and capability assumptions reproducibly, following the benchmark-curation motivation of PGLib-OPF (ref. 1). BMOPFTools supports this in the four-wire distribution setting with three composable, audited operations:
fix_case— structural repairs. Remove inert elements, drop disconnected islands, collapse near-zero-impedance lines to switches, strip redundant bounds. Make the graph sound before anything is written onto it.add_generators/add_ibrs— deliberate DER placement. Create dispatchablegeneratorelements (or richer inverter-interfacedibrelements) at semantically/topologically chosen buses so the OPF has something to decide. Never random; every placement is explained.augment_case— audited study assumptions. Inject voltage bounds, optionally estimate synthetic thermal limits, price the slack, and derive reactive capability. Fills only what is missing; never overwrites.
Each returns a (net′, TransformationManifest) pair and never mutates its input. The manifest is the heart of the contract: every value written is recorded with the rule that produced it and a confidence tag (standards-derived vs :heuristic), so the resulting case is self-documenting and the modeler can see at a glance exactly which numbers are defensible defaults and which are design choices worth revisiting (see A starting point for fine-tuning).
Why this sequence
The three operations are a pipeline, not a menu. The order
fix_case ──► add_generators ──► augment_case ──► solve_opf
│ │ │
repair place DERs fill gaps
topology (p/p_max/cost) (bounds, limits,
q_min/q_max, slack cost)is deliberate, and each edge exists for a reason:
fix_casefirst — bounds and generators placed on a broken topology are wasted or actively wrong. A DER stranded on an islanded bus injects into nothing; a thermal limit on a zero-impedance loop is meaningless. Repair the graph — drop the islands, collapse the switch-like lines, remove inert loads — before annotating it, so every later pass operates on the network that will actually be solved.add_generatorsbefore the finalaugment_case— placement writes onlyp_min/p_max/cost. The reactive capability of each DER (q_min/q_max) is then filled byaugment_case's generation pass from a single power-factor rule. Reversing the order would leave the freshly-placed DERs with active-power bounds but no reactive capability — silently restricting the very flexibility you added them to study. Keeping reactive logic in one place (augment_case) is why generators go in first.augment_caselast — gap-filling is idempotent and only ever fills missing fields, so it should see the final element set: the repaired topology plus the new DERs. Run last, it bounds everything once and completely.
The placement step (add_generators, or add_ibrs for converter-interfaced DERs) is genuinely optional. If you only need a feasible power-flow benchmark (CVR, state estimation, maximum-load-delivery studies) you can run fix_case → augment_case and skip placement entirely — the slack remains the only injector. Add DERs when you want the dispatch itself to be the object of study.
Recommended workflow
using BMOPFTools
net = parse_bmopf("my_feeder.json") # or from_dss
net1, fix_mf = fix_case(net) # 1. structural repairs first
net2, der_mf = add_generators(net1; # 2. place DERs (optional)
recipe = GeneratorRecipe(strategy = :load_following))
net3, aug_mf = augment_case(net2) # 3. fill bounds, limits, q, slack cost
render_manifest(fix_mf) # inspect what was repaired
render_manifest(der_mf) # inspect every placement
render_manifest(aug_mf) # inspect what was added
write_bmopf("my_feeder_ready.json", net3)
result = solve_opf(net3)
report′ = profile_solution(net3, result)Passing the pre-computed analyze result to augment_case avoids re-running voltage-level and provenance analyses internally:
report = analyze(net2)
net3, aug_mf = augment_case(net2; analysis=report.analysis)For a runnable, build-executed walk through this exact pipeline on a real feeder, see the end-to-end tutorial; the DER placement tutorial and VVWO tutorial then drill into placement strategies and smart-IBR control.
The four augment_case passes below run in order. Each can be disabled independently via the AugmentationRecipe apply_* flags. No pass ever overwrites an existing value — augmentation only fills gaps.
Pass 1 — Voltage bounds
Sets v_min/v_max, vpn_min/vpn_max, vpp_min/vpp_max, and vuf_max on supported three-phase buses that lack it. vpn_* are written as per-phase arrays and vpp_* as per-pair arrays (see Conventions); the OPF consumes them in those shapes.
Bounds are expressed as fractions of a declared supply voltage — the nominal voltage defined by the relevant power-quality standard, which may differ from the transformer rated voltage in the network model (e.g. a 240 V transformer in a grid declared at 230 V per EN 50160). The declared voltage is resolved per bus in priority order:
bus["v_declared"]— explicit field set at import time (e.g. authored in the BMOPF JSON)- the optional voltage-snap pass (see below) — writes
v_declaredby snappingv_nomto a standard level; never overrides an explicit value AugmentationRecipefieldsv_declared_lv/v_declared_mv/v_declared_hv— regional fallback (e.g.v_declared_lv = 230.0for Europe/Australia)v_nomfromvoltage_level_analysis— last resort when none of the above is set
Pass 0 — Voltage-level snapping (optional)
Imported cases frequently carry LV transformers rated 240 or 250 V, so the derived v_nom is non-standard and the bounds would be referenced to it. When enabled, augment_case first snaps each bus's v_nom to the nearest standardised level (IEC 60038 / ANSI C84.1) and writes the result as v_declared, so the bounds below reference the standard voltage (e.g. 230 V).
Configured in the TOML config (off by default — no behaviour change unless opted in):
[augment.voltage_snap]
enabled = true # opt-in
preset = "IEC_50Hz" # "IEC_50Hz" | "ANSI_60Hz" | "none"
tolerance = 0.10 # snap only if |v_nom / std − 1| ≤ tolerance
levels = [] # extra phase-to-neutral volts, merged with the presetA value outside every tolerance band is left unchanged, so genuinely non-standard buses (e.g. a real 277 V LV) are preserved. Levels are phase-to-neutral (per-conductor) volts — the same basis as v_nom; custom levels follow the same convention (line-to-line ÷ √3). A bus that already carries an explicit v_declared is never re-snapped. Each snap is recorded in the manifest as a v_declared entry with rule IEC60038_snap. Pass the config via augment_case(net; config=load_config("my.toml")).
Solver regularisation bounds (v_min / v_max)
v_min and v_max are hyperparameters, not power-quality guarantees. They widen the feasible set so the solver can find a feasible point even when the operating point sits at the edge of the physical window. The same values are applied to all buses regardless of voltage level.
| Field | Default (pu of v_declared) | Purpose |
|---|---|---|
v_min | 0.85 | Lower regularisation bound — disable with v_min_pu = nothing |
v_max | 1.15 | Upper regularisation bound — disable with v_max_pu = nothing |
Source buses receive v_min/v_max only — vpn/vpp/vuf bounds are meaningless there because the voltage source pins the terminal voltages.
Power-quality bounds
Applied to all non-source buses. Percentage windows follow EN 50160:2010.
Four-wire buses (have a neutral terminal) receive vpn bounds, since customers experience the phase-to-neutral voltage. vpp bounds are also set when ≥ 2 phase terminals are present.
| Bound | Level | Default (pu of v_declared) | Standard |
|---|---|---|---|
vpn_min/vpn_max | LV (≤ 1 kV) | 0.90 / 1.10 | EN 50160:2010 §3.5/§3.6, 95 %-of-week ±10 % |
vpn_min/vpn_max | MV (1–35 kV) | 0.94 / 1.06 | DSO planning practice ±6 %, budgets for LV voltage drop |
vpp_min/vpp_max | LV | 0.90 / 1.10 | EN 50160:2010 §3.5, same ±10 % band |
vpp_min/vpp_max | MV | 0.94 / 1.06 | Same ±6 % band as vpn |
vpp_min/vpp_max | HV (> 35 kV) | 0.95 / 1.05 | Transmission planning ±5 % |
Pair nominal voltages use va_nom when supplied. A center-tapped split-phase zone uses twice the per-leg nominal (120 V → 240 V); a full three-phase bus uses √3. An ambiguous two-phase bus is skipped with a manifest entry until its nominal angles are supplied. Sequence limits require all three phase terminals.
Three-wire buses (no neutral terminal) receive vpp and, with three phases, vuf_max; phase-to-ground regularisation remains a separate study policy.
Single-phase buses (one phase terminal + neutral) receive vpn but not vpp or sequence limits.
Unassigned buses (islanded from all voltage sources) are skipped; the manifest records a note.
| Bound | Applies to | Default | Standard |
|---|---|---|---|
vuf_max | Three-phase, non-source | 0.02 (dimensionless) | Instantaneous study limit on negative/positive sequence magnitude, not time-aggregated standards compliance |
Intra-bus angle-difference bounds (opt-in)
Disabled by default (apply_va_diff_bounds = false). When enabled, a pass injects the centering reference va_nom and a symmetric window va_diff_min/va_diff_max = ∓va_diff_window_rad (default ±30°) on multiphase, non-source buses whose nominal phasor arrangement is unambiguous:
| Bus | va_nom (rad) | Meaning |
|---|---|---|
| three-phase (3 phase terminals) | [0, −2π/3, 2π/3] | positive-sequence |
split-phase (center_tap-fed, 2 legs) | [0, π] | anti-phase legs |
The OPF bounds the centered difference θⱼ − θₖ − (va_nom[j] − va_nom[k]), so the window pins the correct rotation while staying inside the tangent-valid (−90°, 90°) regime (see Bus limits). Single-phase and ambiguous buses are skipped — the offset is never guessed. Enable with AugmentationRecipe(apply_va_diff_bounds = true).
Pass 2 — Thermal limits
Infers a heuristic i_max for linecodes that lack it by matching the diagonal series resistance R₁₁ against a lookup table of representative conductor cross-sections and their ampacities. This is a synthetic estimate, not a standards lookup: R₁₁ is the series resistance (the conductor's AC resistance plus the Carson earth-return coupling), so on its own it does not uniquely identify a conductor's material, construction class, cross-section, or installation method. Treat the result as a plausible default; a high-confidence rating requires the conductor material/class and installation method to be known independently.
The table spans representative cross-sections from 4 mm² to 240 mm²:
| Cross-section (mm²) | R₁₁ (mΩ/m at 20 °C) | Underground XLPE (A) | Overhead (illustrative, A) |
|---|---|---|---|
| 4 | 4.950 | 34 | — |
| 6 | 3.300 | 41 | — |
| 10 | 1.980 | 57 | 70 |
| 16 | 1.240 | 76 | 95 |
| 25 | 0.787 | 104 | 130 |
| 35 | 0.559 | 134 | 160 |
| 50 | 0.396 | 170 | 200 |
| 70 | 0.283 | 220 | 260 |
| 95 | 0.209 | 277 | 320 |
| 120 | 0.164 | 326 | 375 |
| 150 | 0.132 | 386 | 430 |
| 185 | 0.107 | 451 | 490 |
| 240 | 0.082 | 541 | 600 |
On the columns: the R₁₁ values are representative maximum DC resistances at 20 °C for standard conductor sizes — the quantity IEC 60228:2004 actually specifies (its procedure measures DC resistance), used here only as a size fingerprint, not an AC or earth-return-inclusive value. The underground column is loosely calibrated to IEC 60364-5-52:2009 Table B.52 installation ampacity (70 °C conductor, single circuit, in-ground or in-air); the overhead column is an illustrative overhead rating, not an IEC 60364-5-52 value (that standard covers LV installation ampacity, not an overhead-AAC catalogue). The table as a whole is a heuristic default, not a standards-derived rating, and is deliberately not tagged as an IEC-conformant result.
The match uses a 15 % relative tolerance on R₁₁. If no table row falls within tolerance the linecode is skipped and the manifest records "R₁₁ outside lookup range".
Opt-in synthetic policy. Use AugmentationRecipe(apply_thermal=true) to request estimates. Existing ratings are never overwritten. The legacy thermal_min_confidence keyword gates impedance classifications (distinct: :high, near_balanced: :medium, others: :low); it does not measure ampacity confidence. Even a distinct matrix does not establish material, conductor size, or installation. Every inferred rating is marked :heuristic. meta.provenance.thermal_estimates[linecode_id] records the impedance classification, assumed construction category, equal-conductor-rating assumption, and lack of material/installation verification; it survives write_bmopf/parse_bmopf.
Neutral conductor rating. The neutral conductor is assigned the same i_max as the phase conductors. IEC 60364-5-52:2009 §523 permits a reduced neutral cross-section above 16 mm² under balanced loading conditions, but the appropriate reduction is installation- and load-specific and is not applied automatically. Set the neutral entry of i_max in the linecode manually, or pass a pre-computed i_max vector (which will not be overwritten) if a derated rating is required.
Power → current conversion (opt-in). With AugmentationRecipe(apply_power_to_current = true), any line or switch that carries an apparent-power limit s_max but no i_max gets an equivalent per-conductor current limit i_max = s_max / v_ref, where v_ref is the resolved phase-to-ground reference voltage at the from-bus. Current is the preferred thermal representation for conductors (no voltage-reference ambiguity, no neutral degeneracy — see current vs. apparent-power limits); the conversion is exact only at v_ref. Transformers are never converted — their kVA nameplate stays canonical. Off by default; each write is recorded in the transformation manifest.
Pass 3 — Generation
Slack cost. The voltage source is itself the network's current slack, so no slack generator is created. If a source has no cost, a per-phase cost is written onto the voltage_source (default 1.0 $/kWh) so imported power is priced in the objective. No flow bounds are added, so the source can absorb the network's net active/reactive imbalance. This removes one common cause of infeasibility, but does not override voltage, thermal, device, or other hard constraints. Controlled by the recipe's apply_slack_generator / slack_cost fields (names kept for backwards compatibility).
Reactive bounds. For each generator that has p_max defined but lacks q_min/q_max, symmetric reactive bounds are derived from the recipe power-factor setting:
Q_max = P_max × tan(arccos(pf))Default pf = 0.90 (EN 50549-1:2019, LV grid-connected DERs): Qmax ≈ 0.484 × Pmax. Set q_capability_pf = 0.95 for IEEE 1547-2018 (ANSI) deployments: Qmax ≈ 0.329 × Pmax.
Pass 4 — IBR dispatch bounds
ibr (inverter-based resource) elements carry a richer model than generators (an apparent-power nameplate s_max, a topology, a prime_mover, and an optional smart-IBR control_profile). This pass fills their active and reactive dispatch box from the nameplate, leaving the nameplate itself untouched. Controlled by the recipe's apply_ibr flag; disabled with apply_ibr = false.
Active power. If p_max is absent it is derived per phase — from p_avail (split equally across phases) or from the per-phase s_max ratings. For PV prime movers p_min = 0 is also injected when absent, since PV cannot absorb active power.
Reactive power. If the IBR references a control_profile carrying a power_factor sub-object, q_min/q_max are left absent — the OPF enforces the exact PF coupling Q = f(P) as an equality constraint instead. Otherwise, for IBRs lacking explicit q_min/q_max, symmetric bounds are derived from p_max using the recipe's ibr_default_pf (EN 50549-1:2019 default cos φ = 0.90), exactly as for generators:
Q_max = P_max × tan(arccos(pf)), Q_min = -Q_maxThis pass only bounds IBRs that already exist in the case. To place IBRs in the first place, see Adding IBRs below; the intended order is add_ibrs → augment_case, so this pass turns each placed nameplate into a full dispatch box.
fix_case — structural repairs
Nine passes run in order. Each is independently controlled by the corresponding apply_* flag in FixRecipe. All passes default to true except the four that change power-system semantics or representation, which default to false and must be opted into explicitly.
| # | Pass | Default | Notes |
|---|---|---|---|
| 1 | Largest connected component | true | Drops buses, lines, loads, generators, and shunts that have no path to a voltage source. |
| 2 | Simplify network | true | Merges consecutive same-linecode series lines; removes dangling stub lines (wraps simplify_network). |
| 3 | Remove zero loads | true | Deletes loads with p_nom = q_nom = 0 on all phases — electrically inert. |
| 4 | Low-impedance lines → switches | true | Replaces lines with total series impedance |Z| < threshold (default 10⁻⁴ Ω) with closed switches. |
| 5 | Source bus bounds | true | Strips all voltage bounds from source buses — redundant because the voltage source pins the terminal voltages exactly. |
| 6 | Adjacent current bounds | false | Infers i_max for lines/switches that lack it from directly adjacent elements (one hop). Transformers contribute s_rating / (√3 × V_ref); lines and switches propagate their own i_max. Takes the minimum over all adjacent bounds. |
| 7 | Perfect grounding | false | Promotes grounding shunts whose 1/|Y₁₁| < threshold (default 0.1 Ω) to perfectly_grounded_terminals and removes the shunt. Changes OPF physics (forces V_n = 0). |
| 8 | Capacitive shunts → capacitors | false | Re-represents a purely capacitive shunt (G ≈ 0; flagged by I.PROV.SHUNT_LIKELY_CAPACITOR) as a first-class capacitor. The susceptance matrix B is fingerprinted by sign pattern + sparsity + row sums into SINGLE_PHASE, WYE, or DELTA (or a phase-to-ground bank → WYE/SINGLEPHASE on a grounded neutral), and the nameplate `qrated = B·vnom²is recovered (vnom= bus P-N nominal, or √3·P-N = L-L for DELTA). The conversion is committed **only when the emitted capacitor's susceptance reproducesBexactly** (round-trip guard), so it is faithful by construction; anything ambiguous or unsupported (mixed return paths,n>3` cyclic banks, a star with no name-resolvable neutral, no grounded return for a phase-to-ground bank, or no resolvable nominal) is left untouched. |
| 9 | Snap placeholder transformer leakage | false | Zeroes a two-winding transformer's tiny non-zero series impedance (|Z| < snap_transformer_z_min_pu, default 0.1 % on the rating base; flagged by W.DOM.XFMR_LOW_IMPEDANCE) — a placeholder for zero from an admittance-based tool. Exact zero is better-conditioned in the IVR formulation than a small value (the transformer analogue of the low-impedance-line → switch pass). Genuine leakage (1–15 %) is never touched; n_winding units are skipped (rating base differs). |
Both passes replace a near-zero impedance with an exact zero handled semantically (a switch, or the ideal-transformer constraint) rather than a small numerical placeholder. This is deliberate: a small ε impedance is the single most ill-conditioned point on the whole axis, whereas an exact zero is well-conditioned. See Zero impedance: represent it honestly in the developer guide for the conditioning argument and the figure.
BMOPFTools.fix_case — Function
fix_case(net; recipe=FixRecipe()) -> (net′, TransformationManifest)Apply structural repairs to a BMOPF network dict, returning an independent deep copy together with a TransformationManifest recording every change.
net is never mutated. Passes run in order; each is independently controlled by the corresponding apply_* flag in recipe.
Passes (in order)
- Largest connected component — drop buses, lines, loads, generators, and shunts that belong to components unreachable from any voltage source.
- Simplify network — merge consecutive same-linecode series lines and remove dangling stub lines (wraps
simplify_network). - Remove zero loads — delete loads with
p_nom = q_nom = 0on all phases. - Low-impedance lines → switches — replace lines whose total per-conductor series impedance is below
recipe.low_impedance_threshold_ohmwith closed switches. - Source bus bounds — remove all voltage bounds (
v_min/v_max,vpn_*,vpp_*,vneg_max, etc.) from source buses; they are redundant because the voltage source fixes the terminal voltages exactly. - Adjacent current bounds (opt-in, default off) — for each line or switch lacking
i_max, infer an upper bound from directly adjacent elements (lines, switches, transformers) at either endpoint bus. - Perfect grounding (opt-in, default off) — promote grounding shunts whose equivalent resistance is below
recipe.perfect_grounding_threshold_ohmtoperfectly_grounded_terminalsentries and remove the shunt.
Example
net = parse_bmopf("feeder.json")
net′, fix_mf = fix_case(net)
net″, aug_mf = augment_case(net′)
render_manifest(fix_mf)
result = solve_opf(net″)BMOPFTools.FixRecipe — Type
FixRecipeParameters controlling which repair passes fix_case runs and their thresholds. Lossless, semantics-preserving passes default to true; the one pass that changes power-system physics (apply_perfect_grounding) defaults to false and must be opted into explicitly.
augment_case — audited study assumptions
BMOPFTools.augment_case — Function
augment_case(net; recipe=default_recipe(), analysis=nothing, config=_DEFAULT_CONFIG)
-> (net′::Dict{String,Any}, manifest::TransformationManifest)Derive a candidate benchmark by injecting selected study bounds and assumptions for absent fields. The manifest records these choices; successful augmentation does not establish physical feasibility or standards compliance.
net is never mutated. The returned net′ is an independent deep copy.
Arguments
net— a BMOPF network dict (fromparse_bmopforfrom_dss)recipe— anAugmentationRecipecontrolling which passes run and what defaults to use; seedefault_recipeanalysis— output ofanalyzeor a dict containing at least"voltage_levels"and"provenance"sub-results; ifnothingthe relevant sub-analyses are run internallyconfig— tunable thresholds (seeload_config); currently drives optional voltage-level snapping ([augment.voltage_snap]), which snaps each bus's derived nominal to a standard IEC/ANSI level and writesv_declaredbefore the bounds pass. Off by default.
Returns
A 2-tuple (net′, manifest) where:
net′is the augmented network dictmanifestis aTransformationManifestrecording every field written, the standards rule that motivated it, and benchmark-readiness findings before and after augmentation
Passes (in order)
- Voltage snapping (optional) — snap each bus's derived nominal to a standard IEC 60038 / ANSI C84.1 level and write
v_declared([augment.voltage_snap]inconfig; off by default) - Voltage bounds —
v_min/v_max,vpn_min/vpn_max,vpp_min/vpp_max,vuf_maxon supported buses (instantaneous study policies) - Thermal limits — opt-in synthetic
i_maxestimates from R₁₁; no verified ampacity claim - Generation — slack generator at source buses;
q_min/q_maxon existing generators withp_max(EN 50549 / IEEE 1547)
Each pass is independently skippable via the recipe's apply_* flags. Existing values are never overwritten.
Example
net = parse_bmopf("my_feeder.json")
net′, manifest = augment_case(net)
render_manifest(manifest)
result = solve_opf(net′)BMOPFTools.AugmentationRecipe — Type
AugmentationRecipeParameters controlling which augmentation passes run and what default values they inject. Defaults are study policies and approximations; select them for the supported network and record the assumptions. Synthetic thermal estimation is opt-in.
Construct with keyword arguments:
recipe = AugmentationRecipe(vpn_lv_pu = (0.85, 1.15))or use default_recipe to get the unmodified defaults.
Declared supply voltage fallbacks
Voltage bounds are expressed as percentages of a declared supply voltage, not the transformer rated voltage. The authoritative source is the optional v_declared field on the bus (V). When absent, the recipe fallbacks below are used; when those are nothing, the bus's v_nom from voltage-level analysis is used as a last resort.
The declared voltage is expressed per conductor (phase-to-ground ≈ phase-to-neutral), the same basis as v_nom. Phase-to-neutral bounds use it directly; phase-pair bounds use declared nominal angles, or the supported three-phase (√3) / center-tapped split-phase (2) arrangement. Ambiguous pairs are skipped with a manifest entry. Set the fallbacks to the per-conductor declared voltage for the deployment region, e.g. v_declared_lv = 230.0 for Europe/Australia (230 V L-N → 400 V L-L); for an 11 kV (L-L) MV system use v_declared_mv = 11000 / √3 ≈ 6350.0.
Full precedence for the declared voltage, highest first:
bus["v_declared"]— explicit, set at import time- the optional voltage-snap pass (
[augment.voltage_snap]in the TOMLconfig) — snapsv_nomto a standard IEC/ANSI level and writesv_declared; never overrides an explicitv_declared - these recipe fallbacks (
v_declared_lv/mv/hv) v_nomfrom voltage-level analysis
Snapping (off by default) is the recommended way to pull imported 240/250 V transformers onto the standard 230 V without per-bus editing.
BMOPFTools.default_recipe — Function
default_recipe() -> AugmentationRecipeReturn an AugmentationRecipe with all defaults as specified in the package documentation.
TransformationManifest
BMOPFTools.TransformationManifest — Type
TransformationManifestComplete audit trail for one call to augment_case.
Fields
created_at— ISO-8601 timestamp of the augmentation runrecipe— the recipe used (AugmentationRecipe,FixRecipe, orGeneratorRecipe); the change detail is also captured inentriesentries— ordered list ofTransformEntryrecordsfindings_before—benchmark_readiness_checkfindings on the input case (snapshot)findings_after— findings on the augmented output case
BMOPFTools.TransformEntry — Type
TransformEntryA single field change made by an augmentation pass.
Fields
component_type—:bus,:linecode,:generator,:ibr, or:transformercomponent_id— the dict key of the modified componentfield— the field name written (e.g."vpn_min","i_max")old_value— previous value (nothingif the field was absent)new_value— value writtenrule— standards citation (e.g."EN50160:2010§3.5")confidence—:standard,:heuristic,:high,:medium, or:lownote— human-readable explanation
BMOPFTools.manifest_to_dict — Function
manifest_to_dict(m::TransformationManifest) -> Dict{String,Any}Convert a manifest to a plain dict suitable for JSON serialisation via write_bmopf or JSON3.write.
BMOPFTools.render_manifest — Function
render_manifest(m::TransformationManifest; io=stdout)Print a human-readable diff of all changes recorded in the manifest, grouped by component type.
Serialising the manifest
The manifest is intended to travel alongside the augmented case file so the pair (case.json, case_manifest.json) is self-documenting:
using JSON3
net′, manifest = augment_case(net)
write_bmopf(net′, "feeder_aug.json")
open("feeder_aug_manifest.json", "w") do io
JSON3.write(io, manifest_to_dict(manifest))
endAdding generators (DER placement)
augment_case fills selected study bounds and policies; it does not create generation. Without dispatchable generators the OPF is trivial (the slack imports everything). add_generators is a separate, opt-in pass that places dispatchable generator elements using the semantic and topological knowledge the library already computes — never randomly — so the OPF becomes a meaningful optimisation. Like fix_case/augment_case it never mutates its input and returns a (net′, manifest) pair recording every field written.
It writes only bus, terminal_map, configuration, p_min, p_max, and cost; reactive bounds are intentionally left to augment_case's generation pass (this is the ordering constraint from Why this sequence).
Why diverse strategies matter
There is no single correct way to place DERs — the right placement depends on the question the benchmark is meant to probe, and a different question puts a different constraint on the binding edge of the feasible set. This mirrors how synthetic-network and hosting-capacity practice treats DER scenarios as a designed input: EPRI's DRIVE hosting-capacity method deliberately contrasts distributed placement (many small injections spread across the feeder, representative of residential rooftop PV) against centralised placement (a single large interconnection), because the two stress the network in opposite ways (ref. 2); and the NREL SMART-DS effort treats DER siting and sizing as configurable scenario knobs rather than a fixed fact of the network (ref. 3). Offering several placement strategies is therefore a matter of experimental design, not optional decoration — it lets one base feeder generate a family of benchmark instances that exercise distinct physics.
Choose the strategy from the research question:
| Research question | Strategy | What it stresses (binds first) |
|---|---|---|
| Realistic prosumer dispatch, CVR, self-consumption | :load_following | Where demand already is; mild, distributed counter-flow |
| Voltage-rise limits, embedded-generation hosting capacity | :topology_targeted, topology_mode = :leaves | Worst-case voltage rise at feeder ends, far from the source |
| Bulk reverse power flow, upstream thermal limits | :topology_targeted, topology_mode = :near_source or :hosting_capacity | Transformer/head-of-feeder loading and reverse flow |
The point of supporting all three is that a single base feeder can be turned into a family of benchmark instances — a voltage-rise case, a thermal-headroom case, a realistic-dispatch case — each making a different constraint the active one. That diversity is what makes the resulting set useful for comparing OPF formulations and solvers, not just solving one network once.
The recipe knobs
Placement is controlled by a GeneratorRecipe:
strategy—:load_following(one DER per load bus, phasing inherited from the load),:hosting_capacity(DERs sized to a fraction of the feeding transformer'ss_rating), or:topology_targeted(topology_mode = :leavesfor embedded generation at feeder ends, or:near_sourcefor bulk injection).- filters —
voltage_levels(e.g.[:LV]),min_local_load_va, andskip_source_busescompose over any strategy. - sizing —
size_basis(:fraction_of_local_load,:fraction_of_transformer_rating,:fraction_of_downstream_load,:fixed_tiers) withder_p_fraction. - cost —
cost_basis(:cheaper_than_slack,:tiered_by_level,:uniform); pricing DERs below the slack makes dispatch non-trivial — the solver uses local generation until a voltage or thermal constraint binds.
Every generator field written is recorded as a TransformEntry with rule DER_PLACEMENT/<strategy> and confidence :synthetic (a design choice, not a standard), and the run emits I.DER.PLACED / W.DER.NO_CANDIDATES / W.DER.OVERSUPPLY findings into the manifest's findings_after. Because every placement is tagged :synthetic, the manifest doubles as the list of knobs a modeler will most likely want to tune (see below).
BMOPFTools.add_generators — Function
add_generators(net; recipe=default_generator_recipe(), analysis=nothing)
-> (net′::Dict{String,Any}, manifest::TransformationManifest)Place dispatchable DERs into net using an explainable, semantics-driven recipe. net is never mutated; net′ is an independent deep copy.
Writes only bus, terminal_map, configuration, p_min, p_max, cost on each new generator — reactive bounds are intentionally left to augment_case. Every generator field written is recorded in the returned TransformationManifest.
analysis may be the output of analyze (or a dict carrying "voltage_levels" / "connectivity"); if nothing, the needed sub-analyses are run internally.
Example
net1, _ = fix_case(net)
net2, dmf = add_generators(net1; recipe=GeneratorRecipe(strategy=:load_following))
net3, _ = augment_case(net2) # fills q_min/q_max on the new DERs
result = solve_opf(net3)BMOPFTools.GeneratorRecipe — Type
GeneratorRecipeDeclarative configuration for add_generators. Every field is a deliberate, explainable knob — no randomness.
Placement strategy
strategy—:load_following(one DER per load bus),:hosting_capacity(DERs sized to a fraction of the feeding transformer rating), or:topology_targeted(DERs at feeder leaves / near the source).topology_mode—:leavesor:near_source(only for:topology_targeted).
Filters (composable over any strategy)
voltage_levels— restrict placement to these voltage-level families (:LV,:MV,:HV,:EHV);nothing= all levels.min_local_load_va— skip buses whose aggregated local load is below this.skip_source_buses— never place at a voltage-source bus.
Sizing
size_basis—:fraction_of_local_load,:fraction_of_downstream_load,:fraction_of_transformer_rating, or:fixed_tiers.der_p_fraction— fraction applied for the:fraction_of_*bases.fixed_tier_w— voltage-level-family → fixed DER size (W) for:fixed_tiers.p_min_fraction—p_min = p_min_fraction × p_max(0 = curtailable).
Cost (makes the OPF dispatch non-trivial)
cost_basis—:cheaper_than_slack,:uniform, or:tiered_by_level.slack_cost,der_cost_factor—cost = der_cost_factor × slack_cost.der_cost_uniform— used whencost_basis = :uniform.der_cost_tiers— voltage-level-family → cost for:tiered_by_level.
Identity / safety
configuration— generator configuration;nothinginherits from the load.id_prefix— new generator ids are$(id_prefix)$(bus).overwrite_existing— iffalse, buses that already host a generator are skipped (no duplicate / replacement).apply_placement— master enable;falsemakesadd_generatorsa no-op copy.
BMOPFTools.default_generator_recipe — Function
default_generator_recipe() -> GeneratorRecipeThe default DER placement recipe: load-following at LV buses, sized to 80 % of local load, priced at half the slack cost.
Adding IBRs (DER placement)
add_ibrs is the inverter-interfaced counterpart to add_generators. Where a generator is a thin active-power object, an ibr carries a richer model — an apparent-power nameplate s_max, a topology (FOUR_LEG / THREE_LEG / SINGLE_PHASE), a prime_mover, and an apparent-power circle P² + Q² ≤ s_max² in the OPF. Use it when the benchmark should model PV/storage converters with explicit VA headroom rather than plain dispatchable generation.
The division of labour mirrors generators exactly: placement places, augment bounds. add_ibrs writes only the nameplate — bus, terminal_map, topology, prime_mover, s_max, p_avail, cost — and Pass 4 of augment_case then derives the p_max/p_min/q_min/q_max dispatch box from that nameplate. The intended order is therefore the same one-line pipeline with add_ibrs in the generator slot:
net1, _ = fix_case(net)
net2, imf = add_ibrs(net1;
recipe = IBRRecipe(strategy = :load_following))
render_manifest(imf) # every placement is explained
net3, _ = augment_case(net2) # fills p_max/p_min/q_min/q_max on the new IBRsPlacement is controlled by an IBRRecipe. The strategy, filter and cost knobs are identical in meaning to GeneratorRecipe (so the strategy-diversity guidance applies unchanged), with these IBR-specific additions:
prime_mover—:PV(the MVP default; drivesp_min = 0in the augment pass). Battery and grid-forming IBRs are planned but not yet placed.inverter_topology—:infer(FOURLEG when the host load has a neutral terminal, else SINGLEPHASE), or a forced:FOUR_LEG/:THREE_LEG/:SINGLE_PHASE.- sizing targets
s_max, notp_max:size_basisselects how the nameplate is derived. The:fraction_of_*bases (:fraction_of_local_load,:fraction_of_transformer_rating,:fraction_of_downstream_load) multiply a network quantity bys_fraction;:fixed_tiersinstead reads an absolute nameplate in VA from thefixed_tier_vadict (per voltage-level family, defaultDict(:LV => 30_000.0, :MV => 1_000_000.0)).s_to_p_ratiothen setsp_avail = s_to_p_ratio × s_max(1.0 = unity-rated PV; below 1.0 leaves reactive headroom at full irradiance).
The default :fraction_of_local_load basis is convenient for scaling the fleet to a feeder, but it has two awkward edges the reviewer-style questions keep hitting:
- You want to think in kVA, not "× local load." Use
size_basis = :fixed_tiersand set the absolute nameplate directly:IBRRecipe(strategy = :load_following, size_basis = :fixed_tiers, fixed_tier_va = Dict(:LV => 50_000.0))places a 50 kVA unit on every LV load bus regardless of that bus's load. (The generator analogue isGeneratorRecipe(size_basis = :fixed_tiers, fixed_tier_w = Dict(:LV => …)), in W.) - The bus has no local load but you still want a DER there. A
:fraction_of_local_loadsize is zero at a zero-load bus, andmin_local_load_vawill skip a low-load bus entirely. Two fixes: size with:fixed_tiers(absolute, so load-independent), and/or choose candidates by topology rather than by load —strategy = :topology_targeted, topology_mode = :leaves(feeder ends) or:near_source, which place at buses selected from the graph, not from where demand happens to sit.
Two knobs are orthogonal and easy to conflate:
strategydecides where and how many DERs are placed (:load_following= one per load bus,:topology_targeted,:hosting_capacity). The comment "one PV IBR per load bus" describes the:load_followingstrategy — it is not a statement that IBRs are only PV.prime_moverdecides what technology the converter is (:PV,:BATTERY,:GENERIC,:STATCOM).:PVis the current placement default (and the reasonp_min = 0is injected — PV cannot absorb active power).
Mapping technologies onto objects: PV, battery, or any converter-interfaced DER → an ibr with the matching prime_mover; a synchronous / simple dispatchable unit → a generator. An EV charger is demand, so it is a load (optionally time-varying via a charging profile — see the time-series tutorial); model it as an ibr with prime_mover = "BATTERY" only for a vehicle-to-grid (V2G) study where it injects. EV is therefore not a prime_mover value — it is either a load or a bidirectional battery, depending on the physics you mean.
One caveat on automatic placement: add_ibrs currently emits :PV nameplates (the augment pass's p_min = 0 is PV-specific; battery and grid-forming placement is planned). To study other technologies today, author the ibr with the intended prime_mover directly — the OPF engine models it once the P/Q box is set — rather than relying on the placement recipe.
Every field written is recorded as a :synthetic TransformEntry with rule IBR_PLACEMENT/<strategy>, and the run emits I.IBR.PLACED / W.IBR.NO_CANDIDATES / W.IBR.OVERSUPPLY findings into the manifest's findings_after.
solve_opf dispatches placed IBRs once augment_case has filled their P/Q box — the OPF engine fully models IBRs (apparent-power circle, topology-dependent voltage reference, constant-PF coupling). PowerIO v0.9 can import IBR/control data where the source carries it. BMOPFTools still treats to_pmd and to_dss IBR export as an explicit follow up.
BMOPFTools.add_ibrs — Function
add_ibrs(net; recipe=default_ibr_recipe(), analysis=nothing)
-> (net′::Dict{String,Any}, manifest::TransformationManifest)Place inverter-interfaced DERs into net using an explainable, semantics-driven recipe. net is never mutated; net′ is an independent deep copy.
Writes the IBR nameplate — bus, terminal_map, topology, prime_mover, s_max, p_avail, cost — on each new IBR. The active and reactive dispatch box (p_max/p_min/q_min/q_max) is intentionally left to augment_case's IBR pass, which derives it from s_max/p_avail. Every IBR field written is recorded in the returned TransformationManifest with confidence :synthetic.
analysis may be the output of analyze (or a dict carrying "voltage_levels" / "connectivity"); if nothing, the needed sub-analyses are run internally.
The placed IBRs are dispatched by solve_opf once augment_case has filled their P/Q box. The OPF engine supports IBRs, and PowerIO v0.9 can import IBR/control data where the source carries it. BMOPFTools still treats to_pmd and to_dss IBR export as a follow up.
Example
net1, _ = fix_case(net)
net2, imf = add_ibrs(net1; recipe=IBRRecipe(strategy=:load_following))
net3, _ = augment_case(net2) # fills p_max/p_min/q_min/q_max on the new IBRsBMOPFTools.IBRRecipe — Type
IBRRecipeDeclarative configuration for add_ibrs. Mirrors GeneratorRecipe for the shared placement knobs, with IBR-specific fields for topology, prime mover and apparent-power sizing. Every field is a deliberate, explainable knob — no randomness.
Placement strategy
strategy—:load_following(one IBR per load bus),:hosting_capacity(sized to a fraction of the feeding transformer rating), or:topology_targeted(IBRs at feeder leaves / near the source).topology_mode—:leavesor:near_source(only for:topology_targeted).
Filters (composable over any strategy)
voltage_levels— restrict placement to these voltage-level families (:LV,:MV,:HV,:EHV);nothing= all levels.min_local_load_va— skip buses whose aggregated local load is below this.skip_source_buses— never place at a voltage-source bus.
Sizing (targets apparent power s_max)
size_basis—:fraction_of_local_load,:fraction_of_downstream_load,:fraction_of_transformer_rating, or:fixed_tiers.s_fraction— fraction applied for the:fraction_of_*bases (setss_max).fixed_tier_va— voltage-level-family → fixeds_max(VA) for:fixed_tiers.s_to_p_ratio—p_avail = s_to_p_ratio × s_maxper phase (1.0 = unity-rated PV; < 1.0 leaves headroom for reactive support at full irradiance).
IBR model
prime_mover—:PV(MVP). Drivesp_min = 0in the augment pass.inverter_topology—:infer(FOURLEG when the host load has a neutral terminal, else SINGLEPHASE), or a forced:FOUR_LEG/:THREE_LEG/:SINGLE_PHASE.
Cost (makes the OPF dispatch non-trivial)
cost_basis—:cheaper_than_slack,:uniform, or:tiered_by_level.slack_cost,der_cost_factor—cost = der_cost_factor × slack_cost.der_cost_uniform— used whencost_basis = :uniform.der_cost_tiers— voltage-level-family → cost for:tiered_by_level.
Identity / safety
id_prefix— new IBR ids are$(id_prefix)$(bus).overwrite_existing— iffalse, buses that already host an IBR are skipped (no duplicate / replacement).apply_placement— master enable;falsemakesadd_ibrsa no-op copy.
BMOPFTools.default_ibr_recipe — Function
default_ibr_recipe() -> IBRRecipeThe default IBR placement recipe: load-following PV IBRs at LV buses, s_max sized to 80 % of local load, unity-rated (p_avail = s_max), priced at half the slack cost.
STATCOMs and active power circulation
add_statcom! places a single shunt converter (a STATCOM / D-STATCOM) at a named bus as an IBR with prime_mover = "STATCOM". Its dispatch box is left to augment_case, which fills it according to the dc_link_coupled flag:
- reactive-only (default) — per-phase active power is clamped to zero (
p_min = p_max = 0); the full per-phase rating becomes symmetric reactive capability (q_max = s_max,q_min = -s_max). - active power circulation (
dc_link_coupled = true) — per-phase active power is opened to±s_maxand the net DC-side power is bounded byp_dc_min … p_dc_max, defaulted to0 … 0for a STATCOM. The OPF then enforces∑ₖ Pₖ ∈ [p_dc_min, p_dc_max], letting the converter move active power between phases (see the OPF model and the D-STATCOM unbalance study).
The same dc_link_coupled augmentation applies to any IBR: for a non-STATCOM source it defaults the band to 0 … p_avail, so a curtailable PV inverter can redistribute its available power across phases. Every derived bound is recorded as a :standard TransformEntry.
BMOPFTools.add_statcom! — Function
add_statcom!(net, bus; s_max, id=nothing, terminal_map=nothing,
topology=nothing, dc_link_coupled=false, cost=nothing) -> StringAdd a STATCOM (a D-STATCOM in distribution-system parlance) to net at bus and return its id. A STATCOM is a shunt-connected voltage-source converter with no active-power source: it is modelled as an IBR whose prime_mover is "STATCOM", so augment_case exposes the full per-phase converter rating as symmetric reactive capability (q_max = s_max, q_min = -s_max). Converter losses are neglected at this fidelity.
dc_link_coupled selects the active-power behaviour:
false(default) — reactive-only: each phase's active power is clamped to zero (p_min = p_max = 0).true— active power circulation: the phases share one DC link, so per-phase active power is free within thes_maxcircle but the net active power is zero (∑ₖ Pₖ = 0). The converter can then move active power between phases to balance an unbalanced feeder, where reactive support has weak authority because LV networks are resistive (R≫X). See the D-STATCOM unbalance study tutorial.
net is mutated (an entry is written under net["ibr"]); use a deepcopy first if you need to preserve the original.
s_max is the converter apparent-power rating in VA, given either as a scalar (replicated across phases) or as a per-phase vector. terminal_map and topology are inferred from the bus's terminal_names when omitted (last terminal treated as the neutral/reference, as elsewhere in the IBR model).
Reactive limits are intentionally left for augment_case to derive, in keeping with add_ibrs. For a controlled STATCOM, attach a control_profile (e.g. a Volt-var droop) by setting the IBR's control_profile field after this call.
Example
add_statcom!(net, "bus_650"; s_max = 200_000.0) # 200 kVA reactive-only D-STATCOM
net2, _ = augment_case(net) # fills q_min/q_max = ∓s_max
add_statcom!(net, "bus_675"; s_max = 50_000.0, dc_link_coupled = true) # phase balancerA starting point for fine-tuning
The three passes do not claim to produce the one true benchmark. They produce a defensible default — every value is either copied from a published standard or flagged as a deliberate synthetic choice — and the manifest is the map of which is which. That distinction is the whole point: it tells a modeler exactly where to spend their fine-tuning effort.
Read the manifest back by confidence tag:
- Standards-derived entries (EN 50160 voltage windows, IEC 60364 ampacities, EN 50549-1 reactive capability) are safe to keep unless the case targets a jurisdiction with different rules — in which case override the relevant recipe field (
v_declared_lv,q_capability_pf, …) and re-run. :syntheticentries (every DER placement, the slack price) are design choices. These are the first things to revisit: the placement strategy, theder_p_fractionsizing, thecost_basis. Change them and you change what the OPF decides.
The intended loop is therefore iterative, not one-shot:
net3, mf = add_generators(net2;
recipe = GeneratorRecipe(strategy = :topology_targeted,
topology_mode = :leaves))
render_manifest(mf) # see every synthetic placement & its driver
# … decide the feeder-end DERs are oversized; lower the fraction and re-run …
net3, mf = add_generators(net2;
recipe = GeneratorRecipe(strategy = :topology_targeted,
topology_mode = :leaves,
der_p_fraction = 0.3))Because each pass is pure and re-runnable, and because the manifest records the rule behind every field, a modeler can converge on a case tailored to their study without ever reverse-engineering an opaque, pre-cooked benchmark file.
Why augment? — rationale and literature
The approach here is not ad hoc; it follows established practice in two adjacent communities.
Curated benchmarks, not raw data. The AC transmission community learned that raw network snapshots make poor optimisation benchmarks: they lack generation limits, costs and thermal ratings, so an OPF over them is under-constrained and non-comparable across studies. PGLib-OPF answered this by curating a library in which "all networks have reasonable values for key parameters — generation injection limits, generation costs, and branch thermal limits" (ref. 1). augment_case does the four-wire-distribution equivalent, with the added discipline that the source of every value (which standard, or "synthetic") is recorded rather than baked in silently. The companion design-goals analysis for unbalanced OPF benchmarks argues the same point specifically for distribution: a benchmark must make its bounds and assumptions explicit to be reproducible (ref. 4).
Standards as the source of defaults. Where a value can be grounded in a published standard, it should be — so the default is auditable rather than arbitrary. Voltage windows follow EN 50160:2010 and DER reactive capability follows EN 50549-1:2019 (with IEEE 1547-2018 as the ANSI alternative, whose minimum 44 % injecting / 44 % absorbing reactive capability motivates the q_capability_pf = 0.95 preset) (ref. 5). This is why augment_case separates standards-derived fills from synthetic ones in the manifest. Conductor ampacities are the deliberate exception: the thermal pass is a heuristic estimate loosely calibrated to IEC 60364-5-52:2009 / IEC 60228:2004 tables, not a conformant derivation (a bare R₁₁ does not identify the conductor material, class, or installation), and is tagged as such.
DER scenarios as designed inputs. Where a value cannot be standardised — chiefly where to place generation and how large to make it — the literature treats it as a deliberately varied scenario rather than a fixed fact. EPRI's DRIVE hosting-capacity methodology contrasts distributed against centralised DER placement precisely because they stress a feeder differently (ref. 2), and NREL's SMART-DS programme exposes DER siting/sizing as configurable scenario knobs over a fixed base network (ref. 3). add_generators follows this practice directly: its strategies (:load_following, :topology_targeted leaves/near-source, :hosting_capacity) are the BMOPF analogues of those scenario choices, and tagging them :synthetic is the acknowledgement that they are design decisions, not measurements.
Limitations
The following augmentation tasks are not handled by augment_case:
DER placement — handled by the separate
add_generatorspass documented above, not byaugment_case, because where to place generators and how to size them is a deliberate design choice rather than standards-derivable.Zero-sequence voltage bound (
vzero_max) — the appropriate limit depends on the earthing system (TN, TT, IT have fundamentally different zero-sequence behaviour) and cannot be standardised without knowing the system's neutral earthing arrangement. Useprovenance_analysisearthing zone results to choose a value if needed.Reactive load (
q_nom) — existing reactive demand is preserved as-is. If loads were imported withpf = 0.88OpenDSS defaults (flagged byI.PROV.OPENDSS_DEFAULT_PF) the values should be reviewed before treating them as benchmark data.Transformer
s_max—s_ratingalready plays this role; the solver uses it directly. No separates_maxaugmentation is needed.
References
Numbered citations above refer to this list. Power-quality and ampacity standards (EN 50160:2010, EN 50549-1:2019, IEC 60228:2004, IEC 60364-5-52:2009) are cited inline at their point of use in the tables above.
- S. Babaeinejadsarookolaee et al., "The power grid library for benchmarking AC optimal power flow algorithms," arXiv:1908.02788, 2019. (PGLib-OPF, maintained by the IEEE PES Task Force on Benchmarks for Validation of Emerging Power System Algorithms.)
- Electric Power Research Institute, Distribution Resource Integration and Value Estimation (DRIVE), EPRI, Palo Alto, CA, 2016 — hosting-capacity methodology contrasting distributed vs centralised DER placement.
- B. Palmintier et al., "SMART-DS: Synthetic models for advanced, realistic testing — distribution systems and scenarios," National Renewable Energy Laboratory (NREL), Golden, CO, 2017.
- F. Geth, A. C. Chapman, R. Heidari, J. Clark, "Considerations and design goals for unbalanced optimal power flow benchmarks," Electric Power Systems Research 235 (2024) 110646.
- IEEE Std 1547-2018, IEEE Standard for Interconnection and Interoperability of Distributed Energy Resources with Associated Electric Power Systems Interfaces, IEEE, 2018.
Migrating existing augmentation recipes
Synthetic thermal inference now defaults to off. To reproduce the old synthetic rating policy, request apply_thermal=true and retain the generated provenance. The legacy keywords apply_vneg_bounds and vneg_max_pu now control injection of the actual vuf_max ratio on three-phase buses. Existing absolute vneg_max values are preserved; inspect and remove an old synthetic absolute cap explicitly if the ratio is the intended policy. Rebuild split-phase bounds from the source case: augmentation never overwrites previously generated bounds.