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_casestructural 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_ibrsdeliberate DER placement. Create dispatchable generator elements (or richer inverter-interfaced ibr elements) at semantically/topologically chosen buses so the OPF has something to decide. Never random; every placement is explained.
  • augment_caseaudited 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:

  1. fix_case first — 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.
  2. add_generators before the final augment_case — placement writes only p_min/p_max/cost. The reactive capability of each DER (q_min/q_max) is then filled by augment_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.
  3. augment_case last — 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.

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:

  1. bus["v_declared"] — explicit field set at import time (e.g. authored in the BMOPF JSON)
  2. the optional voltage-snap pass (see below) — writes v_declared by snapping v_nom to a standard level; never overrides an explicit value
  3. AugmentationRecipe fields v_declared_lv / v_declared_mv / v_declared_hv — regional fallback (e.g. v_declared_lv = 230.0 for Europe/Australia)
  4. v_nom from voltage_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 preset

A 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.

FieldDefault (pu of v_declared)Purpose
v_min0.85Lower regularisation bound — disable with v_min_pu = nothing
v_max1.15Upper 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.

BoundLevelDefault (pu of v_declared)Standard
vpn_min/vpn_maxLV (≤ 1 kV)0.90 / 1.10EN 50160:2010 §3.5/§3.6, 95 %-of-week ±10 %
vpn_min/vpn_maxMV (1–35 kV)0.94 / 1.06DSO planning practice ±6 %, budgets for LV voltage drop
vpp_min/vpp_maxLV0.90 / 1.10EN 50160:2010 §3.5, same ±10 % band
vpp_min/vpp_maxMV0.94 / 1.06Same ±6 % band as vpn
vpp_min/vpp_maxHV (> 35 kV)0.95 / 1.05Transmission 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.

BoundApplies toDefaultStandard
vuf_maxThree-phase, non-source0.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:

Busva_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)
44.95034
63.30041
101.9805770
161.2407695
250.787104130
350.559134160
500.396170200
700.283220260
950.209277320
1200.164326375
1500.132386430
1850.107451490
2400.082541600

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_max

This 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.

#PassDefaultNotes
1Largest connected componenttrueDrops buses, lines, loads, generators, and shunts that have no path to a voltage source.
2Simplify networktrueMerges consecutive same-linecode series lines; removes dangling stub lines (wraps simplify_network).
3Remove zero loadstrueDeletes loads with p_nom = q_nom = 0 on all phases — electrically inert.
4Low-impedance lines → switchestrueReplaces lines with total series impedance |Z| < threshold (default 10⁻⁴ Ω) with closed switches.
5Source bus boundstrueStrips all voltage bounds from source buses — redundant because the voltage source pins the terminal voltages exactly.
6Adjacent current boundsfalseInfers 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.
7Perfect groundingfalsePromotes grounding shunts whose 1/|Y₁₁| < threshold (default 0.1 Ω) to perfectly_grounded_terminals and removes the shunt. Changes OPF physics (forces V_n = 0).
8Capacitive shunts → capacitorsfalseRe-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.
9Snap placeholder transformer leakagefalseZeroes 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).
Why exact zero, not a small ε (passes 4 and 9)

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_caseFunction
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)

  1. Largest connected component — drop buses, lines, loads, generators, and shunts that belong to components unreachable from any voltage source.
  2. Simplify network — merge consecutive same-linecode series lines and remove dangling stub lines (wraps simplify_network).
  3. Remove zero loads — delete loads with p_nom = q_nom = 0 on all phases.
  4. Low-impedance lines → switches — replace lines whose total per-conductor series impedance is below recipe.low_impedance_threshold_ohm with closed switches.
  5. 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.
  6. 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.
  7. Perfect grounding (opt-in, default off) — promote grounding shunts whose equivalent resistance is below recipe.perfect_grounding_threshold_ohm to perfectly_grounded_terminals entries 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″)
source
BMOPFTools.FixRecipeType
FixRecipe

Parameters 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.

source

augment_case — audited study assumptions

BMOPFTools.augment_caseFunction
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 (from parse_bmopf or from_dss)
  • recipe — an AugmentationRecipe controlling which passes run and what defaults to use; see default_recipe
  • analysis — output of analyze or a dict containing at least "voltage_levels" and "provenance" sub-results; if nothing the relevant sub-analyses are run internally
  • config — tunable thresholds (see load_config); currently drives optional voltage-level snapping ([augment.voltage_snap]), which snaps each bus's derived nominal to a standard IEC/ANSI level and writes v_declared before the bounds pass. Off by default.

Returns

A 2-tuple (net′, manifest) where:

  • net′ is the augmented network dict
  • manifest is a TransformationManifest recording every field written, the standards rule that motivated it, and benchmark-readiness findings before and after augmentation

Passes (in order)

  1. 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] in config; off by default)
  2. Voltage boundsv_min/v_max, vpn_min/vpn_max, vpp_min/vpp_max, vuf_max on supported buses (instantaneous study policies)
  3. Thermal limits — opt-in synthetic i_max estimates from R₁₁; no verified ampacity claim
  4. Generation — slack generator at source buses; q_min/q_max on existing generators with p_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′)
source
BMOPFTools.AugmentationRecipeType
AugmentationRecipe

Parameters 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:

  1. bus["v_declared"] — explicit, set at import time
  2. the optional voltage-snap pass ([augment.voltage_snap] in the TOML config) — snaps v_nom to a standard IEC/ANSI level and writes v_declared; never overrides an explicit v_declared
  3. these recipe fallbacks (v_declared_lv/mv/hv)
  4. v_nom from 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.

source

TransformationManifest

BMOPFTools.TransformationManifestType
TransformationManifest

Complete audit trail for one call to augment_case.

Fields

  • created_at — ISO-8601 timestamp of the augmentation run
  • recipe — the recipe used (AugmentationRecipe, FixRecipe, or GeneratorRecipe); the change detail is also captured in entries
  • entries — ordered list of TransformEntry records
  • findings_beforebenchmark_readiness_check findings on the input case (snapshot)
  • findings_after — findings on the augmented output case
source
BMOPFTools.TransformEntryType
TransformEntry

A single field change made by an augmentation pass.

Fields

  • component_type:bus, :linecode, :generator, :ibr, or :transformer
  • component_id — the dict key of the modified component
  • field — the field name written (e.g. "vpn_min", "i_max")
  • old_value — previous value (nothing if the field was absent)
  • new_value — value written
  • rule — standards citation (e.g. "EN50160:2010§3.5")
  • confidence:standard, :heuristic, :high, :medium, or :low
  • note — human-readable explanation
source
BMOPFTools.manifest_to_dictFunction
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.

source
BMOPFTools.render_manifestFunction
render_manifest(m::TransformationManifest; io=stdout)

Print a human-readable diff of all changes recorded in the manifest, grouped by component type.

source

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))
end

Adding 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 questionStrategyWhat it stresses (binds first)
Realistic prosumer dispatch, CVR, self-consumption:load_followingWhere demand already is; mild, distributed counter-flow
Voltage-rise limits, embedded-generation hosting capacity:topology_targeted, topology_mode = :leavesWorst-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_capacityTransformer/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's s_rating), or :topology_targeted (topology_mode = :leaves for embedded generation at feeder ends, or :near_source for bulk injection).
  • filtersvoltage_levels (e.g. [:LV]), min_local_load_va, and skip_source_buses compose over any strategy.
  • sizingsize_basis (:fraction_of_local_load, :fraction_of_transformer_rating, :fraction_of_downstream_load, :fixed_tiers) with der_p_fraction.
  • costcost_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_generatorsFunction
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)
source
BMOPFTools.GeneratorRecipeType
GeneratorRecipe

Declarative 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:leaves or :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_fractionp_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_factorcost = der_cost_factor × slack_cost.
  • der_cost_uniform — used when cost_basis = :uniform.
  • der_cost_tiers — voltage-level-family → cost for :tiered_by_level.

Identity / safety

  • configuration — generator configuration; nothing inherits from the load.
  • id_prefix — new generator ids are $(id_prefix)$(bus).
  • overwrite_existing — if false, buses that already host a generator are skipped (no duplicate / replacement).
  • apply_placement — master enable; false makes add_generators a no-op copy.
source
BMOPFTools.default_generator_recipeFunction
default_generator_recipe() -> GeneratorRecipe

The default DER placement recipe: load-following at LV buses, sized to 80 % of local load, priced at half the slack cost.

source

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 IBRs

Placement 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; drives p_min = 0 in 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, not p_max: size_basis selects 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 by s_fraction; :fixed_tiers instead reads an absolute nameplate in VA from the fixed_tier_va dict (per voltage-level family, default Dict(:LV => 30_000.0, :MV => 1_000_000.0)). s_to_p_ratio then sets p_avail = s_to_p_ratio × s_max (1.0 = unity-rated PV; below 1.0 leaves reactive headroom at full irradiance).
Sizing in absolute kVA, and placing at zero-load buses

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_tiers and 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 is GeneratorRecipe(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_load size is zero at a zero-load bus, and min_local_load_va will 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.
Modelling PV, batteries, EVs, and other DER technologies

Two knobs are orthogonal and easy to conflate:

  • strategy decides 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_following strategy — it is not a statement that IBRs are only PV.
  • prime_mover decides what technology the converter is (:PV, :BATTERY, :GENERIC, :STATCOM). :PV is the current placement default (and the reason p_min = 0 is 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.

I/O converter support

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_ibrsFunction
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 IBRs
source
BMOPFTools.IBRRecipeType
IBRRecipe

Declarative 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:leaves or :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 (sets s_max).
  • fixed_tier_va — voltage-level-family → fixed s_max (VA) for :fixed_tiers.
  • s_to_p_ratiop_avail = s_to_p_ratio × s_max per phase (1.0 = unity-rated PV; < 1.0 leaves headroom for reactive support at full irradiance).

IBR model

  • prime_mover:PV (MVP). Drives p_min = 0 in 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_factorcost = der_cost_factor × slack_cost.
  • der_cost_uniform — used when cost_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 — if false, buses that already host an IBR are skipped (no duplicate / replacement).
  • apply_placement — master enable; false makes add_ibrs a no-op copy.
source
BMOPFTools.default_ibr_recipeFunction
default_ibr_recipe() -> IBRRecipe

The 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.

source

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_max and the net DC-side power is bounded by p_dc_min … p_dc_max, defaulted to 0 … 0 for 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) -> String

Add 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).
  • trueactive power circulation: the phases share one DC link, so per-phase active power is free within the s_max circle 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 balancer
source

A 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.
  • :synthetic entries (every DER placement, the slack price) are design choices. These are the first things to revisit: the placement strategy, the der_p_fraction sizing, the cost_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_generators pass documented above, not by augment_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. Use provenance_analysis earthing zone results to choose a value if needed.

  • Reactive load (q_nom) — existing reactive demand is preserved as-is. If loads were imported with pf = 0.88 OpenDSS defaults (flagged by I.PROV.OPENDSS_DEFAULT_PF) the values should be reviewed before treating them as benchmark data.

  • Transformer s_maxs_rating already plays this role; the solver uses it directly. No separate s_max augmentation 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.

  1. 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.)
  2. 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.
  3. B. Palmintier et al., "SMART-DS: Synthetic models for advanced, realistic testing — distribution systems and scenarios," National Renewable Energy Laboratory (NREL), Golden, CO, 2017.
  4. 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.
  5. 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.