From faa29e27e09116f8e5f3defa808aac589b602090 Mon Sep 17 00:00:00 2001 From: houmgaor Date: Wed, 15 Jul 2026 11:14:51 +0200 Subject: [PATCH 1/5] feat(converter): import OpenDSS RegControl as a tap controller Transformers were previously imported at their solved OpenDSS tap ratio, baked directly into vn_hv_kv/vn_lv_kv. That freezes any on-load tap changer or RegControl at the operating point OpenDSS happened to solve, so it can never respond during a pandapower power flow -- understating, for example, the hosting capacity headroom a real regulator would create by tapping down as PV export raises voltage. Populate tap_side/tap_neutral/tap_min/tap_max/tap_step_percent/tap_pos from OpenDSS's MinTap/MaxTap/NumTaps instead, so the tap has something to actuate. This always happens and does not change solved voltages (verified against the existing no-regulator feeders to the same tight tolerance); OpenDSS's tap-position axis turns out to be centered on zero independent of whether MinTap/MaxTap are symmetric about 1.0, so tap_neutral is derived rather than assumed to be 0. Add an opt-in import_controllers flag (default False) that additionally imports each RegControl as a DiscreteTapControl, converting vreg/band/ ptratio to per-unit against the monitored bus. Line-drop compensation, reverse-mode regulation, and time delays are not modeled and are reported as warnings rather than guessed at; a RegControl monitoring a bus other than its own tapped winding's terminal is skipped rather than silently regulating the wrong bus. --- CHANGELOG.rst | 1 + doc/converter/opendss.rst | 10 +- pandapower/converter/opendss/from_dss.py | 328 ++++++++++++++++-- .../test/converter/test_from_opendss.py | 240 +++++++++++++ 4 files changed, 553 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 78dbcf0a7..818b86191 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,7 @@ Change Log [upcoming release] - 2026-..-.. ------------------------------- - [ADDED] OpenDSS converter: series (bus-to-bus) ``Reactor`` elements are now imported as a fixed-impedance ``line``, the pattern some feeder libraries (e.g. EPRI's Ckt5/Ckt7) use to model the substation's Thevenin-equivalent source impedance instead of a ``Transformer``. +- [ADDED] OpenDSS converter: ``from_opendss`` imports a transformer's tap changer (``tap_pos``/``tap_min``/``tap_max``/``tap_step_percent``/``tap_neutral``) instead of baking the solved ratio into ``vn_hv_kv``/``vn_lv_kv``, and can import each ``RegControl`` as a ``DiscreteTapControl`` via the new ``import_controllers`` option (default False). [3.5.4] - 2026-07-08 ------------------------------- diff --git a/doc/converter/opendss.rst b/doc/converter/opendss.rst index 0eefc52b0..b28587b8f 100644 --- a/doc/converter/opendss.rst +++ b/doc/converter/opendss.rst @@ -9,9 +9,15 @@ feeder into a **balanced (positive-sequence)** pandapower net. It reads the circ through ``OpenDSSDirect.py`` (an optional dependency, ``pip install pandapower[opendss]``), mapping buses, lines (with their LineCode carried through as ``std_type``), series (bus-to-bus) reactors (the pattern some feeder libraries use to model the -substation's source impedance), two-winding transformers (at their solved tap), +substation's source impedance), two-winding transformers (with their tap changer +imported as an explicit ``tap_pos`` rather than baked into ``vn_hv_kv``/``vn_lv_kv``), center-tapped split-phase service transformers (collapsed to a two-winding -equivalent), loads, shunt capacitors, switches and the source. +equivalent), loads, shunt capacitors, switches and the source. With +``import_controllers=True``, each ``RegControl`` is additionally imported as a +``DiscreteTapControl``, so the tap responds to bus voltage during +``pandapower.control.run_control`` instead of staying pinned at the OpenDSS +operating point; line-drop compensation, reverse-mode regulation and time delays +are not modeled and are reported as warnings when encountered. Positive-sequence is exact for symmetric (e.g. European 3-phase 4-wire) feeders and a documented approximation for unsymmetrical North-American topology (single-phase diff --git a/pandapower/converter/opendss/from_dss.py b/pandapower/converter/opendss/from_dss.py index ffea7d18e..73712dfba 100644 --- a/pandapower/converter/opendss/from_dss.py +++ b/pandapower/converter/opendss/from_dss.py @@ -107,6 +107,7 @@ class _ImportReport: n_switches: int = 0 n_transformers: int = 0 n_split_phase_transformers: int = 0 + n_reg_controls: int = 0 n_loads: int = 0 n_shunts: int = 0 single_phase_lines: int = 0 @@ -125,6 +126,179 @@ def as_dict(self): return dict(self.__dict__) +@dataclass +class _RegControlInfo: + """One OpenDSS ``RegControl``, captured while it was the active element.""" + name: str + transformer: str # lower-cased transformer name + winding: int # 1-based: the monitored (PT/CT) winding + tap_winding: int # 1-based: the winding whose tap actually moves + monitored_bus: str # lower-cased bus name, '' if defaulted to the winding's own terminal + vreg: float + band: float + ptratio: float + forward_r: float + forward_x: float + is_reversible: bool + delay: float + tap_delay: float + is_inverse_time: bool + + +def _collect_regcontrols(): + """Read every OpenDSS RegControl, keyed by the (lower-cased) name of the + transformer it controls. Called once, before transformers are imported, so + a transformer can pick its tapped winding using the RegControl that targets + it (see `_pick_tap_fields`). + """ + by_trafo = {} + i = dss.RegControls.First() + while i: + info = _RegControlInfo( + name=dss.RegControls.Name(), + transformer=dss.RegControls.Transformer().lower(), + winding=int(dss.RegControls.Winding()), + tap_winding=int(dss.RegControls.TapWinding()), + monitored_bus=_busname(dss.RegControls.MonitoredBus()), + vreg=dss.RegControls.ForwardVreg(), + band=dss.RegControls.ForwardBand(), + ptratio=dss.RegControls.PTRatio(), + forward_r=dss.RegControls.ForwardR(), + forward_x=dss.RegControls.ForwardX(), + is_reversible=bool(dss.RegControls.IsReversible()), + delay=dss.RegControls.Delay(), + tap_delay=dss.RegControls.TapDelay(), + is_inverse_time=bool(dss.RegControls.IsInverseTime()), + ) + by_trafo.setdefault(info.transformer, []).append(info) + i = dss.RegControls.Next() + return by_trafo + + +def _has_tap_grid(min_tap, max_tap, num_taps): + """Whether a winding's declared MinTap/MaxTap/NumTaps is usable at all.""" + return int(round(num_taps)) > 0 and max_tap > min_tap + + +def _tap_fields_from_dss(min_tap, max_tap, num_taps, ratio): + """Translate one OpenDSS winding's declared tap range and a solved ratio + into pandapower's (tap_step_percent, tap_min, tap_max, tap_neutral, tap_pos). + + OpenDSS's tap-position axis is always centered on zero -- from + ``-(NumTaps // 2)`` to ``-(NumTaps // 2) + NumTaps`` -- *independent* of + whether MinTap/MaxTap happen to be symmetric about 1.0. This isn't + documented anywhere in OpenDSS help (which just says "16 raise and 16 + lower taps about the neutral position"); it was verified empirically + against ``RegControls.TapNumber()`` with an asymmetric MinTap/MaxTap pair. + So tap_neutral -- the position where the ratio is exactly 1.0 -- is + derived from where 1.0 falls inside [MinTap, MaxTap], and is *not* always + the middle of the range. + + Returns None if the winding has no usable tap range (NumTaps <= 0 or a + degenerate/inverted MinTap/MaxTap). + """ + if not _has_tap_grid(min_tap, max_tap, num_taps): + return None + num_taps = int(round(num_taps)) + step_pu = (max_tap - min_tap) / num_taps + tap_min = -(num_taps // 2) + tap_max = tap_min + num_taps + tap_neutral = round(tap_min + (1.0 - min_tap) / step_pu) + tap_pos = round(tap_min + (ratio - min_tap) / step_pu) + tap_pos = int(min(max(tap_pos, tap_min), tap_max)) + return step_pu * 100.0, tap_min, tap_max, tap_neutral, tap_pos + + +def _pick_tap_fields(name, report, regcontrols_by_trafo, hv_w, lv_w, tap, min_tap, max_tap, + num_taps, split_phase): + """Pick the transformer's tapped winding and translate its OpenDSS tap + range into pandapower tap_* fields, so the solved tap becomes an explicit, + movable ``tap_pos`` instead of being folded into ``vn_hv_kv``/``vn_lv_kv``. + + Returns ``(tap_kwargs, legacy)``: + + * ``tap_kwargs`` is a kwargs dict for ``create_transformer_from_parameters`` + (empty if neither winding shows any sign of being tapped: no RegControl + and both windings solved at ratio 1.0, i.e. nothing beyond OpenDSS's + meaningless tap-range defaults that every transformer carries regardless + of use). + * ``legacy`` is normally None, but is ``(side, factor)`` when the winding + *is* genuinely off-ratio (RegControl or solved tap != 1.0) yet OpenDSS's + own MinTap/MaxTap/NumTaps for it is degenerate (e.g. ``NumTaps=0``, used + to fix a tap at a value outside the steppable grid): there is no valid + tap_pos to represent it, so the caller must fall back to multiplying + ``vn__kv`` directly -- the behaviour this feature replaces -- so a + real ratio is never silently dropped. + """ + eps = 1e-9 + hv_dev = abs(tap[hv_w] - 1.0) > eps + lv_dev = abs(tap[lv_w] - 1.0) > eps + + reg = None + regctrls = regcontrols_by_trafo.get(name.lower()) + if regctrls and not split_phase: + reg = regctrls[0] + if len(regctrls) > 1: + report.warn(f"transformer {name!r} has {len(regctrls)} RegControls; only " + f"{reg.name!r} is imported") + + if reg is None and not hv_dev and not lv_dev: + return {}, None + + if reg is not None and reg.tap_winding - 1 not in (hv_w, lv_w): + report.warn(f"transformer {name!r}: RegControl {reg.name!r} has an invalid " + f"tap_winding ({reg.tap_winding}); falling back to whichever winding's " + "solved tap deviates from neutral") + reg = None + + if reg is not None: + tap_w = reg.tap_winding - 1 + elif hv_dev and not lv_dev: + tap_w = hv_w + elif lv_dev and not hv_dev: + tap_w = lv_w + else: + # Both windings are off ratio with no RegControl to say which one is the + # "real" tap changer: prefer whichever has a usable OpenDSS tap grid to + # represent, defaulting to lv only if both (or neither) do. + tap_w = hv_w if (_has_tap_grid(min_tap[hv_w], max_tap[hv_w], num_taps[hv_w]) + and not _has_tap_grid(min_tap[lv_w], max_tap[lv_w], num_taps[lv_w])) \ + else lv_w + if hv_dev and lv_dev: + report.warn(f"transformer {name!r} has a non-unity tap on both windings; combining " + f"them into a single pandapower tap on the {'hv' if tap_w == hv_w else 'lv'} side") + other_w = hv_w if tap_w == lv_w else lv_w + tap_side = "hv" if tap_w == hv_w else "lv" + factor = tap[tap_w] / tap[other_w] + + grid = _tap_fields_from_dss(min_tap[tap_w], max_tap[tap_w], num_taps[tap_w], factor) + if grid is None: + if abs(factor - 1.0) <= eps: + return {}, None + report.warn( + f"transformer {name!r} has no usable tap range on the {tap_side} winding " + "(NumTaps<=0 or a degenerate MinTap/MaxTap); solved tap ratio " + f"{factor:.4f} baked into vn_{tap_side}_kv instead of tap_pos") + return {}, (tap_side, factor) + tap_step_percent, tap_min, tap_max, tap_neutral, tap_pos = grid + + if tap_pos != tap_neutral: + report.warn( + f"transformer {name!r} imported with tap ratio {factor:.4f} on the {tap_side} side " + f"-> tap_pos={tap_pos} (tap_min={tap_min}, tap_max={tap_max}, " + f"tap_step_percent={tap_step_percent:.4f})") + + return dict( + tap_side=tap_side, + tap_neutral=tap_neutral, + tap_min=tap_min, + tap_max=tap_max, + tap_step_percent=tap_step_percent, + tap_pos=tap_pos, + tap_changer_type="Ratio", + ), None + + def _busname(token): """Strip the node-connection suffix from an OpenDSS bus token. @@ -145,7 +319,7 @@ def _connected_phases(token): return len(phases) -def from_opendss(path: str, solve: bool=True): +def from_opendss(path: str, solve: bool=True, import_controllers: bool=False): """Build a balanced (positive-sequence) pandapower net from an OpenDSS feeder. The OpenDSS circuit is compiled through ``OpenDSSDirect.py`` and its elements @@ -157,18 +331,30 @@ def from_opendss(path: str, solve: bool=True): * Line (switch / 0 km) -> ``switch``: open status respected * Reactor (series, bus-to-bus) -> ``line``: fixed impedance from ``R``/``X``; a shunt reactor (single bus) is not a branch and is skipped - * Transformer (2W) -> ``trafo``: imported at the solved tap + * Transformer (2W) -> ``trafo``: tap changer imported live (see below) * Transformer (3W CT) -> ``trafo``: center-tapped split-phase mapped to a 2W equivalent * Load -> ``load``: kW/kvar -> p_mw/q_mvar * Capacitor -> ``shunt``: kvar -> -q_mvar (injection) - - Transformers are imported at their *solved* tap ratio, so on-load tap changers - and RegControls are captured as the operating point; the controllers themselves - are not re-implemented. Three-winding center-tapped service transformers (two - LV windings on the same secondary bus) are collapsed to a balanced two-winding - equivalent. Because positive-sequence modeling cannot represent 120/240 V - split-phase operation (#873), those LV voltages carry the largest approximation - error. + * RegControl -> ``DiscreteTapControl`` (only if ``import_controllers=True``) + + A transformer's solved OpenDSS tap is translated into pandapower's + ``tap_pos``/``tap_min``/``tap_max``/``tap_step_percent``/``tap_neutral`` + rather than being folded into ``vn_hv_kv``/``vn_lv_kv``, so the tap has + something to actuate; this happens for every tapped winding regardless of + ``import_controllers`` and does not change the solved voltages. With + ``import_controllers=True``, each ``RegControl`` additionally becomes a + ``DiscreteTapControl`` on the mapped trafo, so the tap responds to bus + voltage during ``pandapower.control.run_control``/timeseries instead of + being pinned at the OpenDSS operating point. Line-drop compensation, + reverse-mode regulation and time delays are not modeled; when a RegControl + uses them, or monitors a bus other than its own tapped winding's terminal, + this is reported as a warning rather than guessed at (and, for an + unsupported monitored bus, the controller is skipped entirely rather than + silently regulating the wrong bus). Three-winding center-tapped service + transformers (two LV windings on the same secondary bus) are collapsed to + a balanced two-winding equivalent and are not matched to a RegControl. + Because positive-sequence modeling cannot represent 120/240 V split-phase + operation (#873), those LV voltages carry the largest approximation error. This function requires the optional dependency ``OpenDSSDirect.py`` (``pip install pandapower[opendss]``). @@ -181,6 +367,12 @@ def from_opendss(path: str, solve: bool=True): per-bus voltage magnitudes (pu, phase-averaged) in the import report so that a round-trip can be validated without re-solving OpenDSS. Defaults to True. + import_controllers (bool): If True, import each ``RegControl`` as a + ``DiscreteTapControl`` so its tap responds during + ``pandapower.control.run_control``. Defaults to False, so a plain + ``pandapower.runpp`` on the returned net (which never looks at + ``net.controller``) sees the same voltages as before this option + existed. Returns: pandapowerNet: A balanced net carrying an ``opendss_import`` diagnostics @@ -208,7 +400,9 @@ def from_opendss(path: str, solve: bool=True): _add_source(net, bus_map, report) _add_lines(net, bus_map, report) _add_reactors(net, bus_map, report) - _add_transformers(net, bus_map, report) + regcontrols_by_trafo = _collect_regcontrols() + trafo_index_by_name = _add_transformers(net, bus_map, report, regcontrols_by_trafo) + _add_reg_controls(net, report, regcontrols_by_trafo, trafo_index_by_name, import_controllers) _add_loads(net, bus_map, report) _add_capacitors(net, bus_map, report) @@ -347,25 +541,30 @@ def _add_reactors(net, bus_map, report): i = dss.Reactors.Next() -def _add_transformers(net, bus_map, report): +def _add_transformers(net, bus_map, report, regcontrols_by_trafo): + trafo_index_by_name = {} i = dss.Transformers.First() while i: - _add_one_transformer(net, bus_map, report) + _add_one_transformer(net, bus_map, report, regcontrols_by_trafo, trafo_index_by_name) i = dss.Transformers.Next() + return trafo_index_by_name -def _add_one_transformer(net, bus_map, report): +def _add_one_transformer(net, bus_map, report, regcontrols_by_trafo, trafo_index_by_name): name = dss.Transformers.Name() dss.Circuit.SetActiveElement("Transformer." + name) wbus = [_busname(b) for b in dss.CktElement.BusNames()] nwdg = dss.Transformers.NumWindings() - kva, pct_r, tap = [], [], [] + kva, pct_r, tap, min_tap, max_tap, num_taps = [], [], [], [], [], [] for w in range(1, nwdg + 1): dss.Transformers.Wdg(w) kva.append(dss.Transformers.kVA()) pct_r.append(dss.Transformers.R()) # %R of this winding tap.append(dss.Transformers.Tap()) # solved tap ratio (captures RegControl) + min_tap.append(dss.Transformers.MinTap()) + max_tap.append(dss.Transformers.MaxTap()) + num_taps.append(dss.Transformers.NumTaps()) xhl = dss.Transformers.Xhl() # HV-LV leakage reactance, % # Pick the HV winding and the LV winding. @@ -400,21 +599,31 @@ def _add_one_transformer(net, bus_map, report): # Use the connected buses' vn_kv (already kVBase*sqrt(3)) as the winding # ratings: OpenDSS propagates voltage bases through transformers, so the bus - # ratio already equals the turns ratio -- this sidesteps line-to-line vs - # line-to-neutral / sqrt(3) ambiguity. The solved tap multiplies on top. - vn_hv = net.bus.at[bus_hv, "vn_kv"] * tap[hv_w] - vn_lv = net.bus.at[bus_lv, "vn_kv"] * tap[lv_w] + # ratio already equals the nominal turns ratio -- this sidesteps line-to-line + # vs line-to-neutral / sqrt(3) ambiguity. The solved tap is no longer folded + # in here: it becomes an explicit tap_pos below, so a tap changer or + # RegControl has something to actuate instead of being pinned into vn_*_kv. + vn_hv = net.bus.at[bus_hv, "vn_kv"] + vn_lv = net.bus.at[bus_lv, "vn_kv"] if vn_hv <= 0 or vn_lv <= 0: report.warn(f"transformer {name!r} has a zero-base winding; skipped") return - if any(abs(t - 1.0) > 1e-6 for t in tap): - report.warn( - f"transformer {name!r} imported at solved tap {tuple(round(t, 4) for t in tap)} " - "(RegControl baked in as a fixed tap)") + + tap_fields, legacy_tap = _pick_tap_fields(name, report, regcontrols_by_trafo, hv_w, lv_w, tap, + min_tap, max_tap, num_taps, split_phase) + if legacy_tap is not None: + # No usable OpenDSS tap grid for an off-ratio winding: fall back to + # baking the ratio into vn_*_kv (this feature's previous behaviour) + # rather than silently dropping it. + side, factor = legacy_tap + if side == "hv": + vn_hv *= factor + else: + vn_lv *= factor vkr = pct_r[hv_w] + pct_r[lv_w] # copper/short-circuit R, % (= %loadloss) vk = math.hypot(vkr, xhl) # short-circuit voltage: hypot of the R and X parts - pp.create_transformer_from_parameters( + tid = pp.create_transformer_from_parameters( net, hv_bus=bus_hv, lv_bus=bus_lv, sn_mva=max(kva) / 1000.0, vn_hv_kv=vn_hv, vn_lv_kv=vn_lv, @@ -422,10 +631,81 @@ def _add_one_transformer(net, bus_map, report): pfe_kw=0.0, i0_percent=0.0, # core losses dropped in v1 shift_degree=0.0, # vector-group shift: no effect on balanced |V| name=name, + **tap_fields, ) report.n_transformers += 1 if split_phase: report.n_split_phase_transformers += 1 + trafo_index_by_name[name.lower()] = tid + + +def _add_reg_controls(net, report, regcontrols_by_trafo, trafo_index_by_name, import_controllers): + """Create a ``DiscreteTapControl`` for each RegControl whose transformer was + imported, so the tap responds to voltage instead of staying pinned at the + OpenDSS-solved position. Only called with effect when ``import_controllers`` + is True; regardless of that flag, an unreachable transformer is still + reported so the omission isn't silent. + """ + for trafo_name, regctrls in regcontrols_by_trafo.items(): + tid = trafo_index_by_name.get(trafo_name) + if tid is None: + report.warn(f"RegControl on transformer {trafo_name!r} references a transformer " + "that was not imported; skipped") + continue + + if not import_controllers: + continue + + reg = regctrls[0] + row = net.trafo.loc[tid] + tap_side = row["tap_side"] + if tap_side not in ("hv", "lv"): + report.warn(f"RegControl {reg.name!r}: transformer {trafo_name!r} has no usable tap " + "range; not imported as a controller") + continue + + controlled_bus = row["hv_bus"] if tap_side == "hv" else row["lv_bus"] + controlled_bus_name = net.bus.at[controlled_bus, "name"].lower() + if reg.monitored_bus: + if reg.monitored_bus != controlled_bus_name: + report.warn( + f"RegControl {reg.name!r} monitors bus {reg.monitored_bus!r}, not the tapped " + f"winding's own terminal {controlled_bus_name!r}; DiscreteTapControl can only " + "regulate its own terminal, so it was not imported as a controller") + continue + elif reg.winding != reg.tap_winding: + report.warn( + f"RegControl {reg.name!r} monitors winding {reg.winding} but taps winding " + f"{reg.tap_winding}; this configuration is not supported, so it was not imported " + "as a controller") + continue + + notes = [] + if reg.forward_r or reg.forward_x: + notes.append(f"line-drop compensation (R={reg.forward_r}, X={reg.forward_x}) ignored") + if reg.is_reversible: + notes.append("reverse-mode settings ignored") + if reg.delay or reg.tap_delay or reg.is_inverse_time: + notes.append("time-delay/inverse-time settings ignored") + if notes: + report.warn(f"RegControl {reg.name!r}: " + "; ".join(notes) + + " (steady-state power flow has no time/current dimension)") + + # OpenDSS regulates the PT secondary in volts (vreg +/- band/2), referred + # to the primary by ptratio; the PT is line-to-neutral, so converting to + # a per-unit value against the (line-to-line) bus vn_kv needs the sqrt(3) + # -- the classic place to be off by 1.73x if skipped. + vn_kv = net.bus.at[controlled_bus, "vn_kv"] + vm_center_pu = reg.vreg * reg.ptratio * _SQRT3 / 1000.0 / vn_kv + vm_half_band_pu = reg.band / 2.0 * reg.ptratio * _SQRT3 / 1000.0 / vn_kv + + pp.control.DiscreteTapControl( + net, element_index=tid, + vm_lower_pu=vm_center_pu - vm_half_band_pu, + vm_upper_pu=vm_center_pu + vm_half_band_pu, + side=tap_side, + ) + report.n_reg_controls += 1 def _add_loads(net, bus_map, report): diff --git a/pandapower/test/converter/test_from_opendss.py b/pandapower/test/converter/test_from_opendss.py index a930a1b1a..88a184102 100644 --- a/pandapower/test/converter/test_from_opendss.py +++ b/pandapower/test/converter/test_from_opendss.py @@ -368,3 +368,243 @@ def test_reactor_feeder_converges_with_real_voltage_drop(reactor_net): # 1.05 pu (no power flowing past sourcebus) -- guard against that regression. assert reactor_net.res_bus.vm_pu.nunique() > 1 assert reactor_net.res_bus.vm_pu.min() < 1.05 + + +# An LTC-style transformer with an OpenDSS RegControl. ptratio is chosen so the PT +# secondary's nominal 120 V corresponds to the LV bus's actual line-to-neutral +# voltage (0.48 kV / sqrt(3) / 120 V = 2.3094): unlike an arbitrary ptratio, this +# makes vreg=122 a physically sensible ~1.017 pu target, so the solved tap lands +# inside the tap range instead of pinned at a limit, and the regulated band is a +# realistic sanity check rather than a degenerate one. +REGCONTROL_FEEDER = """ +clear +new circuit.reg basekv=12.47 pu=1.0 phases=3 bus1=sourcebus +new linecode.lc1 nphases=3 r1=0.1 x1=0.2 c1=3.0 units=km normamps=400 +new line.l1 bus1=sourcebus bus2=b1 linecode=lc1 length=1.0 units=km +new transformer.t1 phases=3 windings=2 xhl=5.0 +~ wdg=1 bus=b1 conn=wye kv=12.47 kva=500 %r=0.5 +~ wdg=2 bus=b2 conn=wye kv=0.48 kva=500 %r=0.5 maxtap=1.1 mintap=0.9 numtaps=32 +new regcontrol.reg1 transformer=t1 winding=2 vreg=122 band=2 ptratio=2.3094 +new line.l2 bus1=b2 bus2=b3 r1=0.05 x1=0.08 c1=0 length=0.2 units=km normamps=600 phases=3 +new load.load1 bus1=b3 phases=3 kv=0.48 kw=200 kvar=80 conn=wye +set voltagebases=[12.47, 0.48] +calcvoltagebases +solve +""" + +# Same RegControl, but with line-drop compensation (R/X) and reverse mode enabled. +# Neither is implemented, so importing this must warn about both explicitly +# instead of silently ignoring or guessing at them. +REGCONTROL_LDC_REVERSIBLE_FEEDER = """ +clear +new circuit.regldc basekv=12.47 pu=1.0 phases=3 bus1=sourcebus +new transformer.t1 phases=3 windings=2 xhl=5.0 +~ wdg=1 bus=sourcebus conn=wye kv=12.47 kva=500 %r=0.5 +~ wdg=2 bus=b2 conn=wye kv=0.48 kva=500 %r=0.5 maxtap=1.1 mintap=0.9 numtaps=32 +new regcontrol.reg1 transformer=t1 winding=2 vreg=122 band=2 ptratio=2.3094 R=3 X=1 reversible=yes +new load.load1 bus1=b2 phases=3 kv=0.48 kw=200 kvar=80 conn=wye +set voltagebases=[12.47, 0.48] +calcvoltagebases +solve +""" + +# The RegControl's "bus=" (-> RegControls.MonitoredBus) points at a bus downstream +# of the transformer's own LV terminal, not the terminal itself. DiscreteTapControl +# can only regulate its own trafo terminal, so this must be skipped rather than +# silently imported as if it were regulating the wrong bus. +REGCONTROL_REMOTE_BUS_FEEDER = """ +clear +new circuit.regremote basekv=12.47 pu=1.0 phases=3 bus1=sourcebus +new line.l1 bus1=sourcebus bus2=b1 r1=0.1 x1=0.2 c1=0 length=1.0 units=km phases=3 normamps=400 +new transformer.t1 phases=3 windings=2 xhl=5.0 +~ wdg=1 bus=b1 conn=wye kv=12.47 kva=500 %r=0.5 +~ wdg=2 bus=b2 conn=wye kv=0.48 kva=500 %r=0.5 maxtap=1.1 mintap=0.9 numtaps=32 +new regcontrol.reg1 transformer=t1 winding=2 vreg=122 band=2 ptratio=2.3094 bus=b3 +new line.l2 bus1=b2 bus2=b3 r1=0.05 x1=0.08 c1=0 length=0.2 units=km normamps=600 phases=3 +new load.load1 bus1=b3 phases=3 kv=0.48 kw=200 kvar=80 conn=wye +set voltagebases=[12.47, 0.48] +calcvoltagebases +solve +""" + + +@pytest.fixture +def regcontrol_feeder_path(tmp_path): + p = tmp_path / "regcontrol.dss" + p.write_text(REGCONTROL_FEEDER) + return str(p) + + +@pytest.fixture +def regcontrol_net(regcontrol_feeder_path): + return from_opendss(regcontrol_feeder_path) + + +@pytest.fixture +def regcontrol_net_controlled(regcontrol_feeder_path): + return from_opendss(regcontrol_feeder_path, import_controllers=True) + + +def test_regcontrol_tap_fields_populated_not_baked_into_vn(regcontrol_net): + # OpenDSS solves this RegControl to tap ratio 1.025 (verified independently + # against RegControls.TapNumber()): tap_step_percent=(1.1-0.9)/32*100=0.625, + # tap_min=-16, tap_max=16, tap_neutral=0 (symmetric range), and + # tap_pos=(1.025-0.9)/0.00625-16=4. vn_hv_kv/vn_lv_kv stay at nominal -- the + # ratio now lives in tap_pos, not folded into the winding voltages. + t = regcontrol_net.trafo.iloc[0] + assert t["vn_hv_kv"] == pytest.approx(12.47) + assert t["vn_lv_kv"] == pytest.approx(0.48) + assert t["tap_side"] == "lv" + assert t["tap_step_percent"] == pytest.approx(0.625) + assert t["tap_min"] == -16 + assert t["tap_max"] == 16 + assert t["tap_neutral"] == 0 + assert t["tap_pos"] == 4 + assert t["tap_changer_type"] == "Ratio" + + +def test_regcontrol_default_has_no_controller(regcontrol_net): + # import_controllers defaults to False: nothing beyond the tap_* fields + # changes, so existing code that calls from_opendss(...) then pp.runpp(net) + # -- never touching net.controller -- sees the same voltages as before. + assert len(regcontrol_net.controller) == 0 + + +def test_regcontrol_roundtrip_voltage_matches_opendss(regcontrol_net): + # The regression gate for turning the baked-in tap ratio into an explicit + # tap_pos: with no controller running, runpp must still reproduce the + # OpenDSS-solved voltages as tightly as the no-RegControl feeder does. + pp.runpp(regcontrol_net) + odss = regcontrol_net["opendss_import"]["vm_pu_opendss"] + diffs = [ + abs(odss[row["name"].lower()] - regcontrol_net.res_bus.vm_pu[idx]) + for idx, row in regcontrol_net.bus.iterrows() + if row["name"].lower() in odss + ] + assert np.max(diffs) < 1e-3 + + +def test_regcontrol_creates_discrete_tap_control(regcontrol_net_controlled): + from pandapower.control import DiscreteTapControl + + assert regcontrol_net_controlled["opendss_import"]["n_reg_controls"] == 1 + assert len(regcontrol_net_controlled.controller) == 1 + ctrl = regcontrol_net_controlled.controller.object.iloc[0] + assert isinstance(ctrl, DiscreteTapControl) + assert ctrl.element_index == regcontrol_net_controlled.trafo.index[0] + assert ctrl.side == "lv" + # vreg=122, band=2, ptratio=2.3094: PT secondary volts referred to the + # primary by ptratio, then to pu via sqrt(3) (the PT is line-to-neutral) + # over the (line-to-line) bus vn_kv -- independently computed, not read + # back from the code under test. + assert ctrl.vm_lower_pu == pytest.approx(1.008333, rel=1e-4) + assert ctrl.vm_upper_pu == pytest.approx(1.025000, rel=1e-4) + + +def test_regcontrol_pv_export_taps_down(regcontrol_feeder_path): + # The whole point of the feature: with no PV, run_control holds the + # baseline tap; adding enough PV export at the far bus raises the monitored + # voltage above the band, so run_control must step the tap DOWN relative to + # baseline -- a frozen tap (today's behaviour without this feature) could + # never do this. + baseline = from_opendss(regcontrol_feeder_path, import_controllers=True) + pp.control.run_control(baseline) + baseline_tap = baseline.trafo.tap_pos.iloc[0] + + net = from_opendss(regcontrol_feeder_path, import_controllers=True) + b3 = net.bus[net.bus["name"].str.lower() == "b3"].index[0] + pp.create_sgen(net, b3, p_mw=0.9, q_mvar=0.0, name="pv") + pp.control.run_control(net) + + assert net.trafo.tap_pos.iloc[0] < baseline_tap + + +def test_regcontrol_ldc_and_reverse_mode_warn_not_silently_ignored(tmp_path): + p = tmp_path / "regldc.dss" + p.write_text(REGCONTROL_LDC_REVERSIBLE_FEEDER) + net = from_opendss(str(p), import_controllers=True) + + warnings = " ".join(net["opendss_import"]["warnings"]) + assert "line-drop compensation" in warnings + assert "reverse-mode" in warnings + # LDC/reverse mode are unsupported, but the controller is still created + # (regulating its own terminal, without compensation): an imperfect + # regulator beats a frozen tap, provided the user is told what's missing. + assert len(net.controller) == 1 + + +def test_regcontrol_remote_monitored_bus_skips_controller(tmp_path): + p = tmp_path / "regremote.dss" + p.write_text(REGCONTROL_REMOTE_BUS_FEEDER) + net = from_opendss(str(p), import_controllers=True) + + # DiscreteTapControl can only regulate the trafo's own terminal: silently + # regulating the wrong (remote) bus would be worse than not importing it. + assert len(net.controller) == 0 + assert any("not the tapped winding's own terminal" in w + for w in net["opendss_import"]["warnings"]) + + +# A transformer with a manually fixed tap (no RegControl) but NumTaps=0 -- a +# degenerate/unusable OpenDSS tap grid (used, in practice, to fix a tap at a +# value that isn't meant to be steppable). tap_pos has nothing valid to +# represent here, so this must fall back to the pre-this-feature behaviour +# (baking the ratio into vn_lv_kv) instead of silently dropping a real 5% ratio. +DEGENERATE_TAP_RANGE_FEEDER = """ +clear +new circuit.deg basekv=12.47 pu=1.0 phases=3 bus1=sourcebus +new line.l1 bus1=sourcebus bus2=b1 r1=0.1 x1=0.2 c1=0 length=1.0 units=km phases=3 normamps=400 +new transformer.t1 phases=3 windings=2 xhl=5.0 +~ wdg=1 bus=b1 conn=wye kv=12.47 kva=500 %r=0.5 +~ wdg=2 bus=b2 conn=wye kv=0.48 kva=500 %r=0.5 tap=1.05 numtaps=0 +new load.load1 bus1=b2 phases=3 kv=0.48 kw=200 kvar=80 conn=wye +set voltagebases=[12.47, 0.48] +calcvoltagebases +solve +""" + + +def test_degenerate_tap_range_falls_back_to_baked_in_ratio(tmp_path): + p = tmp_path / "degtap.dss" + p.write_text(DEGENERATE_TAP_RANGE_FEEDER) + net = from_opendss(str(p)) + + t = net.trafo.iloc[0] + assert np.isnan(t["tap_pos"]) + assert t["vn_hv_kv"] == pytest.approx(12.47) + assert t["vn_lv_kv"] == pytest.approx(0.48 * 1.05) + assert any("baked into vn_lv_kv" in w for w in net["opendss_import"]["warnings"]) + + pp.runpp(net) + assert net["converged"] + + +# tapwinding=3 references a winding that doesn't exist on this 2-winding +# transformer. OpenDSS itself does not validate TapWinding against the +# transformer's actual winding count (verified: this feeder solves without +# error), so an out-of-range value silently reaches the converter -- this must +# be reported, not dropped without a trace, unlike every other malformed-input +# case in this feature (LDC, reverse mode, remote monitored bus all warn). +INVALID_TAP_WINDING_FEEDER = """ +clear +new circuit.badtapwdg basekv=12.47 pu=1.0 phases=3 bus1=sourcebus +new line.l0 bus1=sourcebus bus2=b1 r1=0.01 x1=0.02 c1=0 length=0.1 units=km phases=3 normamps=400 +new transformer.t1 phases=3 windings=2 xhl=5.0 +~ wdg=1 bus=b1 conn=wye kv=12.47 kva=500 %r=0.5 +~ wdg=2 bus=b2 conn=wye kv=0.48 kva=500 %r=0.5 maxtap=1.1 mintap=0.9 numtaps=32 +new regcontrol.reg1 transformer=t1 winding=2 tapwinding=3 vreg=122 band=2 ptratio=2.3094 +new load.load1 bus1=b2 phases=3 kv=0.48 kw=200 kvar=80 conn=wye +set voltagebases=[12.47, 0.48] +calcvoltagebases +solve +""" + + +def test_invalid_tap_winding_warns_instead_of_silently_falling_back(tmp_path): + p = tmp_path / "badtapwdg.dss" + p.write_text(INVALID_TAP_WINDING_FEEDER) + net = from_opendss(str(p)) + + assert any("invalid" in w and "tap_winding" in w for w in net["opendss_import"]["warnings"]) + pp.runpp(net) + assert net["converged"] From df26d9580b4407dcf21af4cd15a37695bb8b9ed2 Mon Sep 17 00:00:00 2001 From: houmgaor Date: Wed, 15 Jul 2026 13:31:29 +0200 Subject: [PATCH 2/5] docs(converter): fix pydocstyle violations in from_dss docstrings --- pandapower/converter/opendss/from_dss.py | 38 +++++++++++++++--------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/pandapower/converter/opendss/from_dss.py b/pandapower/converter/opendss/from_dss.py index 73712dfba..4681e9e47 100644 --- a/pandapower/converter/opendss/from_dss.py +++ b/pandapower/converter/opendss/from_dss.py @@ -128,7 +128,9 @@ def as_dict(self): @dataclass class _RegControlInfo: + """One OpenDSS ``RegControl``, captured while it was the active element.""" + name: str transformer: str # lower-cased transformer name winding: int # 1-based: the monitored (PT/CT) winding @@ -146,10 +148,12 @@ class _RegControlInfo: def _collect_regcontrols(): - """Read every OpenDSS RegControl, keyed by the (lower-cased) name of the - transformer it controls. Called once, before transformers are imported, so - a transformer can pick its tapped winding using the RegControl that targets - it (see `_pick_tap_fields`). + """ + Read every OpenDSS RegControl, keyed by the (lower-cased) name of the transformer it controls. + + Called once, before transformers are imported, so a transformer can pick + its tapped winding using the RegControl that targets it (see + `_pick_tap_fields`). """ by_trafo = {} i = dss.RegControls.First() @@ -181,8 +185,10 @@ def _has_tap_grid(min_tap, max_tap, num_taps): def _tap_fields_from_dss(min_tap, max_tap, num_taps, ratio): - """Translate one OpenDSS winding's declared tap range and a solved ratio - into pandapower's (tap_step_percent, tap_min, tap_max, tap_neutral, tap_pos). + """ + Translate an OpenDSS winding's tap range and solved ratio into pandapower tap fields. + + Returns (tap_step_percent, tap_min, tap_max, tap_neutral, tap_pos). OpenDSS's tap-position axis is always centered on zero -- from ``-(NumTaps // 2)`` to ``-(NumTaps // 2) + NumTaps`` -- *independent* of @@ -211,9 +217,11 @@ def _tap_fields_from_dss(min_tap, max_tap, num_taps, ratio): def _pick_tap_fields(name, report, regcontrols_by_trafo, hv_w, lv_w, tap, min_tap, max_tap, num_taps, split_phase): - """Pick the transformer's tapped winding and translate its OpenDSS tap - range into pandapower tap_* fields, so the solved tap becomes an explicit, - movable ``tap_pos`` instead of being folded into ``vn_hv_kv``/``vn_lv_kv``. + """ + Pick the transformer's tapped winding and translate its OpenDSS tap range into pandapower tap_* fields. + + This lets the solved tap become an explicit, movable ``tap_pos`` instead of + being folded into ``vn_hv_kv``/``vn_lv_kv``. Returns ``(tap_kwargs, legacy)``: @@ -640,11 +648,13 @@ def _add_one_transformer(net, bus_map, report, regcontrols_by_trafo, trafo_index def _add_reg_controls(net, report, regcontrols_by_trafo, trafo_index_by_name, import_controllers): - """Create a ``DiscreteTapControl`` for each RegControl whose transformer was - imported, so the tap responds to voltage instead of staying pinned at the - OpenDSS-solved position. Only called with effect when ``import_controllers`` - is True; regardless of that flag, an unreachable transformer is still - reported so the omission isn't silent. + """ + Create a ``DiscreteTapControl`` for each RegControl whose transformer was imported. + + This makes the tap respond to voltage instead of staying pinned at the + OpenDSS-solved position. Only called with effect when + ``import_controllers`` is True; regardless of that flag, an unreachable + transformer is still reported so the omission isn't silent. """ for trafo_name, regctrls in regcontrols_by_trafo.items(): tid = trafo_index_by_name.get(trafo_name) From f31ed190584aa2937d8db3660f3698b575c96a2d Mon Sep 17 00:00:00 2001 From: houmgaor Date: Sat, 8 Aug 2026 14:43:18 +0200 Subject: [PATCH 3/5] refactor(converter): address static analysis findings on from_dss Split _pick_tap_fields and _add_reg_controls into focused helpers to bring their cognitive complexity under SonarCloud's threshold (29 -> ~9 and 25 -> ~4), drop two redundant int() casts around round(), and build the tap kwargs as a dict literal (ruff RUF046, C408 / sonar S7498). Also restore the pydocstyle style the analyser actually enforces: no blank line before a class docstring (D211) and the summary on the first line (D212). Behaviour is unchanged; the converter suite passes unchanged. --- pandapower/converter/opendss/from_dss.py | 258 +++++++++++++---------- 1 file changed, 149 insertions(+), 109 deletions(-) diff --git a/pandapower/converter/opendss/from_dss.py b/pandapower/converter/opendss/from_dss.py index d014a000f..1075964d5 100644 --- a/pandapower/converter/opendss/from_dss.py +++ b/pandapower/converter/opendss/from_dss.py @@ -71,6 +71,9 @@ _SQRT3 = math.sqrt(3.0) +# Below this, a solved OpenDSS tap ratio counts as "sitting at neutral". +_TAP_RATIO_EPS = 1e-9 + def _kron_positive_sequence(rmat, xmat, n): """Positive-sequence (R1, X1) for a matrix-defined (``rmatrix``/``xmatrix``) @@ -144,7 +147,6 @@ def as_dict(self): @dataclass class _RegControlInfo: - """One OpenDSS ``RegControl``, captured while it was the active element.""" name: str @@ -164,8 +166,7 @@ class _RegControlInfo: def _collect_regcontrols(): - """ - Read every OpenDSS RegControl, keyed by the (lower-cased) name of the transformer it controls. + """Read every OpenDSS RegControl, keyed by the (lower-cased) name of the controlled transformer. Called once, before transformers are imported, so a transformer can pick its tapped winding using the RegControl that targets it (see @@ -197,12 +198,16 @@ def _collect_regcontrols(): def _has_tap_grid(min_tap, max_tap, num_taps): """Whether a winding's declared MinTap/MaxTap/NumTaps is usable at all.""" - return int(round(num_taps)) > 0 and max_tap > min_tap + return round(num_taps) > 0 and max_tap > min_tap + + +def _is_off_ratio(ratio): + """Whether a winding's solved tap ratio deviates from neutral (1.0).""" + return abs(ratio - 1.0) > _TAP_RATIO_EPS def _tap_fields_from_dss(min_tap, max_tap, num_taps, ratio): - """ - Translate an OpenDSS winding's tap range and solved ratio into pandapower tap fields. + """Translate an OpenDSS winding's tap range and solved ratio into pandapower tap fields. Returns (tap_step_percent, tap_min, tap_max, tap_neutral, tap_pos). @@ -221,7 +226,7 @@ def _tap_fields_from_dss(min_tap, max_tap, num_taps, ratio): """ if not _has_tap_grid(min_tap, max_tap, num_taps): return None - num_taps = int(round(num_taps)) + num_taps = round(num_taps) step_pu = (max_tap - min_tap) / num_taps tap_min = -(num_taps // 2) tap_max = tap_min + num_taps @@ -231,10 +236,57 @@ def _tap_fields_from_dss(min_tap, max_tap, num_taps, ratio): return step_pu * 100.0, tap_min, tap_max, tap_neutral, tap_pos +def _pick_regcontrol(name, report, regcontrols_by_trafo, split_phase): + """Return the RegControl that governs a transformer's tap, or None if there is none to use. + + A split-phase transformer is collapsed into a 2-winding equivalent, so a + RegControl can no longer be tied to one of its original windings. + """ + regctrls = regcontrols_by_trafo.get(name.lower()) + if not regctrls or split_phase: + return None + reg = regctrls[0] + if len(regctrls) > 1: + report.warn(f"transformer {name!r} has {len(regctrls)} RegControls; only " + f"{reg.name!r} is imported") + return reg + + +def _pick_tapped_winding(name, report, reg, hv_w, lv_w, hv_dev, lv_dev, min_tap, max_tap, num_taps): + """Return the index of the winding whose tap becomes pandapower's tap changer. + + A RegControl names it outright; failing that it is inferred from which + winding solved off ratio. + """ + if reg is not None and reg.tap_winding - 1 not in (hv_w, lv_w): + report.warn(f"transformer {name!r}: RegControl {reg.name!r} has an invalid " + f"tap_winding ({reg.tap_winding}); falling back to whichever winding's " + "solved tap deviates from neutral") + reg = None + + if reg is not None: + return reg.tap_winding - 1 + if hv_dev and not lv_dev: + return hv_w + if lv_dev and not hv_dev: + return lv_w + + # Both windings are off ratio with no RegControl to say which one is the + # "real" tap changer: prefer whichever has a usable OpenDSS tap grid to + # represent, defaulting to lv only if both (or neither) do. + hv_has_grid = _has_tap_grid(min_tap[hv_w], max_tap[hv_w], num_taps[hv_w]) + lv_has_grid = _has_tap_grid(min_tap[lv_w], max_tap[lv_w], num_taps[lv_w]) + tap_w = hv_w if hv_has_grid and not lv_has_grid else lv_w + side = "hv" if tap_w == hv_w else "lv" + if hv_dev and lv_dev: + report.warn(f"transformer {name!r} has a non-unity tap on both windings; combining them " + f"into a single pandapower tap on the {side} side") + return tap_w + + def _pick_tap_fields(name, report, regcontrols_by_trafo, hv_w, lv_w, tap, min_tap, max_tap, num_taps, split_phase): - """ - Pick the transformer's tapped winding and translate its OpenDSS tap range into pandapower tap_* fields. + """Pick the tapped winding and translate its OpenDSS tap range into pandapower tap_* fields. This lets the solved tap become an explicit, movable ``tap_pos`` instead of being folded into ``vn_hv_kv``/``vn_lv_kv``. @@ -254,50 +306,22 @@ def _pick_tap_fields(name, report, regcontrols_by_trafo, hv_w, lv_w, tap, min_ta ``vn__kv`` directly -- the behaviour this feature replaces -- so a real ratio is never silently dropped. """ - eps = 1e-9 - hv_dev = abs(tap[hv_w] - 1.0) > eps - lv_dev = abs(tap[lv_w] - 1.0) > eps - - reg = None - regctrls = regcontrols_by_trafo.get(name.lower()) - if regctrls and not split_phase: - reg = regctrls[0] - if len(regctrls) > 1: - report.warn(f"transformer {name!r} has {len(regctrls)} RegControls; only " - f"{reg.name!r} is imported") + hv_dev = _is_off_ratio(tap[hv_w]) + lv_dev = _is_off_ratio(tap[lv_w]) + reg = _pick_regcontrol(name, report, regcontrols_by_trafo, split_phase) if reg is None and not hv_dev and not lv_dev: return {}, None - if reg is not None and reg.tap_winding - 1 not in (hv_w, lv_w): - report.warn(f"transformer {name!r}: RegControl {reg.name!r} has an invalid " - f"tap_winding ({reg.tap_winding}); falling back to whichever winding's " - "solved tap deviates from neutral") - reg = None - - if reg is not None: - tap_w = reg.tap_winding - 1 - elif hv_dev and not lv_dev: - tap_w = hv_w - elif lv_dev and not hv_dev: - tap_w = lv_w - else: - # Both windings are off ratio with no RegControl to say which one is the - # "real" tap changer: prefer whichever has a usable OpenDSS tap grid to - # represent, defaulting to lv only if both (or neither) do. - tap_w = hv_w if (_has_tap_grid(min_tap[hv_w], max_tap[hv_w], num_taps[hv_w]) - and not _has_tap_grid(min_tap[lv_w], max_tap[lv_w], num_taps[lv_w])) \ - else lv_w - if hv_dev and lv_dev: - report.warn(f"transformer {name!r} has a non-unity tap on both windings; combining " - f"them into a single pandapower tap on the {'hv' if tap_w == hv_w else 'lv'} side") + tap_w = _pick_tapped_winding(name, report, reg, hv_w, lv_w, hv_dev, lv_dev, + min_tap, max_tap, num_taps) other_w = hv_w if tap_w == lv_w else lv_w tap_side = "hv" if tap_w == hv_w else "lv" factor = tap[tap_w] / tap[other_w] grid = _tap_fields_from_dss(min_tap[tap_w], max_tap[tap_w], num_taps[tap_w], factor) if grid is None: - if abs(factor - 1.0) <= eps: + if not _is_off_ratio(factor): return {}, None report.warn( f"transformer {name!r} has no usable tap range on the {tap_side} winding " @@ -312,15 +336,15 @@ def _pick_tap_fields(name, report, regcontrols_by_trafo, hv_w, lv_w, tap, min_ta f"-> tap_pos={tap_pos} (tap_min={tap_min}, tap_max={tap_max}, " f"tap_step_percent={tap_step_percent:.4f})") - return dict( - tap_side=tap_side, - tap_neutral=tap_neutral, - tap_min=tap_min, - tap_max=tap_max, - tap_step_percent=tap_step_percent, - tap_pos=tap_pos, - tap_changer_type="Ratio", - ), None + return { + "tap_side": tap_side, + "tap_neutral": tap_neutral, + "tap_min": tap_min, + "tap_max": tap_max, + "tap_step_percent": tap_step_percent, + "tap_pos": tap_pos, + "tap_changer_type": "Ratio", + }, None def _busname(token): @@ -663,9 +687,78 @@ def _add_one_transformer(net, bus_map, report, regcontrols_by_trafo, trafo_index trafo_index_by_name[name.lower()] = tid -def _add_reg_controls(net, report, regcontrols_by_trafo, trafo_index_by_name, import_controllers): +def _regulates_own_terminal(net, report, reg, controlled_bus): + """Whether a RegControl regulates the terminal its own tapped winding sits on. + + ``DiscreteTapControl`` can only regulate that terminal, so anything else + (an explicit remote monitored bus, or monitoring one winding while tapping + another) is reported and skipped rather than silently regulating the wrong + bus. """ - Create a ``DiscreteTapControl`` for each RegControl whose transformer was imported. + controlled_bus_name = net.bus.at[controlled_bus, "name"].lower() + if reg.monitored_bus and reg.monitored_bus != controlled_bus_name: + report.warn( + f"RegControl {reg.name!r} monitors bus {reg.monitored_bus!r}, not the tapped " + f"winding's own terminal {controlled_bus_name!r}; DiscreteTapControl can only " + "regulate its own terminal, so it was not imported as a controller") + return False + if not reg.monitored_bus and reg.winding != reg.tap_winding: + report.warn( + f"RegControl {reg.name!r} monitors winding {reg.winding} but taps winding " + f"{reg.tap_winding}; this configuration is not supported, so it was not imported " + "as a controller") + return False + return True + + +def _warn_unmodelled_regcontrol_settings(report, reg): + """Report the RegControl settings that a steady-state tap controller cannot represent.""" + notes = [] + if reg.forward_r or reg.forward_x: + notes.append(f"line-drop compensation (R={reg.forward_r}, X={reg.forward_x}) ignored") + if reg.is_reversible: + notes.append("reverse-mode settings ignored") + if reg.delay or reg.tap_delay or reg.is_inverse_time: + notes.append("time-delay/inverse-time settings ignored") + if notes: + report.warn(f"RegControl {reg.name!r}: " + "; ".join(notes) + + " (steady-state power flow has no time/current dimension)") + + +def _add_one_reg_control(net, report, trafo_name, reg, tid): + """Create the ``DiscreteTapControl`` for one RegControl; return whether it was created.""" + row = net.trafo.loc[tid] + tap_side = row["tap_side"] + if tap_side not in ("hv", "lv"): + report.warn(f"RegControl {reg.name!r}: transformer {trafo_name!r} has no usable tap " + "range; not imported as a controller") + return False + + controlled_bus = row["hv_bus"] if tap_side == "hv" else row["lv_bus"] + if not _regulates_own_terminal(net, report, reg, controlled_bus): + return False + + _warn_unmodelled_regcontrol_settings(report, reg) + + # OpenDSS regulates the PT secondary in volts (vreg +/- band/2), referred + # to the primary by ptratio; the PT is line-to-neutral, so converting to + # a per-unit value against the (line-to-line) bus vn_kv needs the sqrt(3) + # -- the classic place to be off by 1.73x if skipped. + vn_kv = net.bus.at[controlled_bus, "vn_kv"] + vm_center_pu = reg.vreg * reg.ptratio * _SQRT3 / 1000.0 / vn_kv + vm_half_band_pu = reg.band / 2.0 * reg.ptratio * _SQRT3 / 1000.0 / vn_kv + + pp.control.DiscreteTapControl( + net, element_index=tid, + vm_lower_pu=vm_center_pu - vm_half_band_pu, + vm_upper_pu=vm_center_pu + vm_half_band_pu, + side=tap_side, + ) + return True + + +def _add_reg_controls(net, report, regcontrols_by_trafo, trafo_index_by_name, import_controllers): + """Create a ``DiscreteTapControl`` for each RegControl whose transformer was imported. This makes the tap respond to voltage instead of staying pinned at the OpenDSS-solved position. Only called with effect when @@ -677,61 +770,8 @@ def _add_reg_controls(net, report, regcontrols_by_trafo, trafo_index_by_name, im if tid is None: report.warn(f"RegControl on transformer {trafo_name!r} references a transformer " "that was not imported; skipped") - continue - - if not import_controllers: - continue - - reg = regctrls[0] - row = net.trafo.loc[tid] - tap_side = row["tap_side"] - if tap_side not in ("hv", "lv"): - report.warn(f"RegControl {reg.name!r}: transformer {trafo_name!r} has no usable tap " - "range; not imported as a controller") - continue - - controlled_bus = row["hv_bus"] if tap_side == "hv" else row["lv_bus"] - controlled_bus_name = net.bus.at[controlled_bus, "name"].lower() - if reg.monitored_bus: - if reg.monitored_bus != controlled_bus_name: - report.warn( - f"RegControl {reg.name!r} monitors bus {reg.monitored_bus!r}, not the tapped " - f"winding's own terminal {controlled_bus_name!r}; DiscreteTapControl can only " - "regulate its own terminal, so it was not imported as a controller") - continue - elif reg.winding != reg.tap_winding: - report.warn( - f"RegControl {reg.name!r} monitors winding {reg.winding} but taps winding " - f"{reg.tap_winding}; this configuration is not supported, so it was not imported " - "as a controller") - continue - - notes = [] - if reg.forward_r or reg.forward_x: - notes.append(f"line-drop compensation (R={reg.forward_r}, X={reg.forward_x}) ignored") - if reg.is_reversible: - notes.append("reverse-mode settings ignored") - if reg.delay or reg.tap_delay or reg.is_inverse_time: - notes.append("time-delay/inverse-time settings ignored") - if notes: - report.warn(f"RegControl {reg.name!r}: " + "; ".join(notes) + - " (steady-state power flow has no time/current dimension)") - - # OpenDSS regulates the PT secondary in volts (vreg +/- band/2), referred - # to the primary by ptratio; the PT is line-to-neutral, so converting to - # a per-unit value against the (line-to-line) bus vn_kv needs the sqrt(3) - # -- the classic place to be off by 1.73x if skipped. - vn_kv = net.bus.at[controlled_bus, "vn_kv"] - vm_center_pu = reg.vreg * reg.ptratio * _SQRT3 / 1000.0 / vn_kv - vm_half_band_pu = reg.band / 2.0 * reg.ptratio * _SQRT3 / 1000.0 / vn_kv - - pp.control.DiscreteTapControl( - net, element_index=tid, - vm_lower_pu=vm_center_pu - vm_half_band_pu, - vm_upper_pu=vm_center_pu + vm_half_band_pu, - side=tap_side, - ) - report.n_reg_controls += 1 + elif import_controllers and _add_one_reg_control(net, report, trafo_name, regctrls[0], tid): + report.n_reg_controls += 1 def _add_loads(net, bus_map, report): From df4aed78cc7f52afd8ceef68eb8b54fa0ec12e95 Mon Sep 17 00:00:00 2001 From: houmgaor Date: Sat, 8 Aug 2026 14:48:22 +0200 Subject: [PATCH 4/5] style(converter): keep the codebase docstring convention in from_dss Codacy has both D212 and D213 enabled, which no multi-line docstring can satisfy, so follow the codebase instead: summary on the second line, as in 677 of pandapower's 818 multi-line docstrings (59 of 74 in converter/). The D211 fix from the previous commit stands, since D203 is not enabled. --- pandapower/converter/opendss/from_dss.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/pandapower/converter/opendss/from_dss.py b/pandapower/converter/opendss/from_dss.py index 1075964d5..277f0e37a 100644 --- a/pandapower/converter/opendss/from_dss.py +++ b/pandapower/converter/opendss/from_dss.py @@ -166,7 +166,8 @@ class _RegControlInfo: def _collect_regcontrols(): - """Read every OpenDSS RegControl, keyed by the (lower-cased) name of the controlled transformer. + """ + Read every OpenDSS RegControl, keyed by the (lower-cased) name of the controlled transformer. Called once, before transformers are imported, so a transformer can pick its tapped winding using the RegControl that targets it (see @@ -207,7 +208,8 @@ def _is_off_ratio(ratio): def _tap_fields_from_dss(min_tap, max_tap, num_taps, ratio): - """Translate an OpenDSS winding's tap range and solved ratio into pandapower tap fields. + """ + Translate an OpenDSS winding's tap range and solved ratio into pandapower tap fields. Returns (tap_step_percent, tap_min, tap_max, tap_neutral, tap_pos). @@ -237,7 +239,8 @@ def _tap_fields_from_dss(min_tap, max_tap, num_taps, ratio): def _pick_regcontrol(name, report, regcontrols_by_trafo, split_phase): - """Return the RegControl that governs a transformer's tap, or None if there is none to use. + """ + Return the RegControl that governs a transformer's tap, or None if there is none to use. A split-phase transformer is collapsed into a 2-winding equivalent, so a RegControl can no longer be tied to one of its original windings. @@ -253,7 +256,8 @@ def _pick_regcontrol(name, report, regcontrols_by_trafo, split_phase): def _pick_tapped_winding(name, report, reg, hv_w, lv_w, hv_dev, lv_dev, min_tap, max_tap, num_taps): - """Return the index of the winding whose tap becomes pandapower's tap changer. + """ + Return the index of the winding whose tap becomes pandapower's tap changer. A RegControl names it outright; failing that it is inferred from which winding solved off ratio. @@ -286,7 +290,8 @@ def _pick_tapped_winding(name, report, reg, hv_w, lv_w, hv_dev, lv_dev, min_tap, def _pick_tap_fields(name, report, regcontrols_by_trafo, hv_w, lv_w, tap, min_tap, max_tap, num_taps, split_phase): - """Pick the tapped winding and translate its OpenDSS tap range into pandapower tap_* fields. + """ + Pick the tapped winding and translate its OpenDSS tap range into pandapower tap_* fields. This lets the solved tap become an explicit, movable ``tap_pos`` instead of being folded into ``vn_hv_kv``/``vn_lv_kv``. @@ -688,7 +693,8 @@ def _add_one_transformer(net, bus_map, report, regcontrols_by_trafo, trafo_index def _regulates_own_terminal(net, report, reg, controlled_bus): - """Whether a RegControl regulates the terminal its own tapped winding sits on. + """ + Whether a RegControl regulates the terminal its own tapped winding sits on. ``DiscreteTapControl`` can only regulate that terminal, so anything else (an explicit remote monitored bus, or monitoring one winding while tapping @@ -758,7 +764,8 @@ def _add_one_reg_control(net, report, trafo_name, reg, tid): def _add_reg_controls(net, report, regcontrols_by_trafo, trafo_index_by_name, import_controllers): - """Create a ``DiscreteTapControl`` for each RegControl whose transformer was imported. + """ + Create a ``DiscreteTapControl`` for each RegControl whose transformer was imported. This makes the tap respond to voltage instead of staying pinned at the OpenDSS-solved position. Only called with effect when From 4fe5cd52f8150a25469baccdeb0b91ab308abd0d Mon Sep 17 00:00:00 2001 From: houmgaor Date: Sat, 8 Aug 2026 14:53:04 +0200 Subject: [PATCH 5/5] style(converter): shorten the _collect_regcontrols docstring summary It was the only summary line in the file past 100 characters, and the only one Codacy still reported (as D212). --- pandapower/converter/opendss/from_dss.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandapower/converter/opendss/from_dss.py b/pandapower/converter/opendss/from_dss.py index 277f0e37a..9338c56f4 100644 --- a/pandapower/converter/opendss/from_dss.py +++ b/pandapower/converter/opendss/from_dss.py @@ -167,7 +167,7 @@ class _RegControlInfo: def _collect_regcontrols(): """ - Read every OpenDSS RegControl, keyed by the (lower-cased) name of the controlled transformer. + Read every OpenDSS RegControl, keyed by the lower-cased controlled transformer name. Called once, before transformers are imported, so a transformer can pick its tapped winding using the RegControl that targets it (see