Copyright 2023-2026 Udo Schmitz
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
file: docs/lit/workshoptourcontrol.jl purpose: Literate.jl source of the workshop tour part 3, coordinated transformer control: why parallel transformers must not be regulated independently (circulating reactive power), the master/slave group (#322), and the CGMES mapping via shared RegulatingControl objects.
The Sparlectra workshop tour, part 3: coordinated control
Note: This workshop was created with AI assistance and is reviewed and curated by the maintainer; it is not a fully machine-generated text.
Level: Advanced and up. You should have the basic tour behind you (building nets, reading results, chapter 4's tap control); the advanced tour is helpful but not required.
Every real substation has them: two or three transformers in parallel between the same busbars, regulated as ONE unit. This part of the workshop is about why "as one unit" is not optional, what goes wrong when each transformer brings its own controller, and how the master/slave group models the coordinated regulation, down to the CGMES data that describes it.
- The substation, and a first innocent solve
- The circulating-current trap: misaligned taps
- Why two independent controllers make it worse
- The master/slave group
- Where the data comes from: CGMES RegulatingControl
Warm-up and the substation
Example 1.1: the substation, and a first innocent solve. The study network is the classical two-transformer substation: a strong 110-kV grid connection, two identical 110/20-kV units in parallel onto one 20-kV busbar, and a feeder load behind it. All later examples run fresh copies of this network (build_substation).
HV (110 kV, slack 1.02 pu)
/ \
T1 T2 two identical units, ratio taps 0.9..1.1,
\ / step 0.0125
LV (20 kV busbar)
│
Feeder (40 MW / 12 MVAr)using Sparlectra
function build_substation(name::String)
net = Net(name = name, baseMVA = 100.0)
addBus!(net = net, busName = "HV", vn_kV = 110.0)
addBus!(net = net, busName = "LV", vn_kV = 20.0)
addBus!(net = net, busName = "Feeder", vn_kV = 20.0)
addProsumer!(net = net, busName = "HV", type = "EXTERNALNETWORKINJECTION", vm_pu = 1.02, va_deg = 0.0, referencePri = "HV")
addProsumer!(net = net, busName = "Feeder", type = "ENERGYCONSUMER", p = 40.0, q = 12.0)
addPIModelTrafo!(net = net, fromBus = "HV", toBus = "LV", r_pu = 0.006, x_pu = 0.12, b_pu = 0.0, ratio = 1.0, shift_deg = 0.0, status = 1)
addPIModelTrafo!(net = net, fromBus = "HV", toBus = "LV", r_pu = 0.006, x_pu = 0.12, b_pu = 0.0, ratio = 1.0, shift_deg = 0.0, status = 1)
addPIModelACLine!(net = net, fromBus = "LV", toBus = "Feeder", r_pu = 0.01, x_pu = 0.05, b_pu = 0.0, status = 1)
# give both units their ratio-tap machinery
for br in net.branchVec
if br.ratio != 0.0
br.has_ratio_tap = true
br.tap_min = 0.9
br.tap_max = 1.1
br.tap_step = 0.0125
end
end
ok, msg = validate!(net = net)
ok || error("Network validation failed: $msg")
return net
end
solve!(net) = begin
ite, erg = runpf!(net, 30, 1e-8, 0)
erg == 0 || error("power flow did not converge (status $erg)")
calcNetLosses!(net)
ite
end
net0 = build_substation("substation")
trafo_ids = [string(br.branchIdx) for br in net0.branchVec if br.ratio != 0.0]
solve!(net0)
t0 = [br for br in net0.branchVec if br.ratio != 0.0]
println("aligned taps: T1 carries ", round(t0[1].fBranchFlow.pFlow; digits = 2), " MW / ", round(t0[1].fBranchFlow.qFlow; digits = 2), " MVAr, ",
"T2 carries ", round(t0[2].fBranchFlow.pFlow; digits = 2), " MW / ", round(t0[2].fBranchFlow.qFlow; digits = 2), " MVAr")aligned taps: T1 carries 20.11 MW / 6.96 MVAr, T2 carries 20.11 MW / 6.96 MVArReading aid (Example 1.1): identical units, identical taps, so the load splits evenly. This is the healthy state every coordination scheme wants to preserve.
The circulating-current trap
Example 2.1: misaligned taps. Now the deliberate mistake, on a fresh copy of the Example 1.1 substation (diagram above): T1 steps four taps UP, T2 four taps DOWN. The busbar voltage barely moves (the two errors cancel at the LV node), but the RATIO MISMATCH drives a reactive current around the HV-T1-LV-T2-HV loop that does nothing except heat both transformers:
net1 = build_substation("misaligned")
t1 = [br for br in net1.branchVec if br.ratio != 0.0]
t1[1].tap_ratio = 1.05; t1[1].ratio = 1.05
t1[2].tap_ratio = 0.95; t1[2].ratio = 0.95
solve!(net1)
println("misaligned taps: T1 carries ", round(t1[1].fBranchFlow.qFlow; digits = 2), " MVAr, T2 carries ", round(t1[2].fBranchFlow.qFlow; digits = 2), " MVAr")
println("LV busbar voltage: ", round(get_bus_vm_pu(net1, "LV"); digits = 4), " pu (vs ", round(get_bus_vm_pu(net0, "LV"); digits = 4), " pu aligned)")misaligned taps: T1 carries -34.68 MVAr, T2 carries 52.93 MVAr
LV busbar voltage: 1.0135 pu (vs 1.0109 pu aligned)Reading aid (Example 2.1): the load still needs only 12 MVAr, but the loop now pushes tens of MVAr through one unit and pulls them back through the other, with opposite signs: that difference is pure circulation. Nothing in the busbar voltage betrays it; you have to look at the per-unit flows. THIS is why parallel transformers are regulated together: every scheme that can leave the taps apart will eventually produce this picture.
Why two independent controllers make it worse
Example 3.1: two independent controllers. The tempting setup, one voltage controller per transformer on the same busbar target, is exactly such a scheme (again a fresh copy of the Example 1.1 substation). Each controller measures the same voltage but steps its own tap; nothing forces the two taps to move together. Sparlectra warns at registration when a second independent tap controller targets an already-regulated bus:
net2 = build_substation("fighting")
addPowerTransformerControl!(net2; trafo = trafo_ids[1], mode = :voltage, target_bus = "Feeder", target_vm_pu = 1.0)
# the second registration triggers the warning naming the trap
addPowerTransformerControl!(net2; trafo = trafo_ids[2], mode = :voltage, target_bus = "Feeder", target_vm_pu = 1.0)Net: fighting
Base MVA: 100.0
Nodes: 3, Lines: 1, Transformers: 2, Branches: 3, Links: 0, Prosumers: 2, Shunts: 0
Slack buses: [1], flatstart: false, locked: false
Vmin / Vmax: 0.9 / 1.1
cooldown_iters: 0, q_hyst_pu: 0.0
Measurements: 0
Tap controllers: 2
Reading aid (Example 3.1): the warning (look above) says it plainly: two controllers on one voltage fight each other via circulating reactive power. On well-behaved cases they may happen to move in lockstep; on any asymmetry (unequal starting taps, one unit at a range end, measurement ordering) they drift apart, and each tap step apart is one more notch of circulation. The warning is a warning, not an error, because odd but legitimate topologies exist; for parallel units the supported form is the group.
The master/slave group
Example 4.1: the master/slave group. One controller owns the group (once more the substation of Example 1.1): the MASTER runs the ordinary discrete voltage loop, and every accepted master step is mirrored onto the FOLLOWERS step-synchronously (whole steps of each follower's own tap_step, clamped to its range). Followers cannot carry their own ratio controller and cannot follow two groups; both are enforced at registration.
One subtlety deserves attention: the DEADBAND. Two synchronized units move the busbar voltage twice as far per step as one unit, so the group's deadband must cover at least half of that aggregated step effect, or the loop cannot settle between two group steps:
net3 = build_substation("group")
addPowerTransformerControl!(net3; trafo = trafo_ids[1], followers = [trafo_ids[2]], mode = :voltage, target_bus = "Feeder", target_vm_pu = 1.03, deadband_vm_pu = 5e-3)
res = run_control!(net3)
b1 = Sparlectra._find_trafo_branch(net3, trafo_ids[1])
b2 = Sparlectra._find_trafo_branch(net3, trafo_ids[2])
calcNetLosses!(net3)
println("group: status = ", res.status, ", Vm(Feeder) = ", round(get_bus_vm_pu(net3, "Feeder"); digits = 4), " pu (target 1.03)")
println(" taps: master ", round(b1.tap_ratio; digits = 4), ", follower ", round(b2.tap_ratio; digits = 4), " (aligned)")
println(" reactive split: T1 ", round(b1.fBranchFlow.qFlow; digits = 2), " MVAr, T2 ", round(b2.fBranchFlow.qFlow; digits = 2), " MVAr (no circulation)")
for e in controllableElements(net3)
println(" element view: ", e.element, " -> ", e.quantity, " @ ", e.target, ", status = ", e.status)
endgroup: status = converged, Vm(Feeder) = 1.0274 pu (target 1.03)
taps: master 0.975, follower 0.975 (aligned)
reactive split: T1 6.91 MVAr, T2 6.91 MVAr (no circulation)
element view: B_2WT_110_1_2 (+1 follower) -> bus_voltage @ Feeder, status = convergedReading aid (Example 4.1): the master stepped, the follower mirrored, the taps stayed aligned, and the reactive power splits evenly again: the healthy picture from Example 1.1, now WITH the voltage target met. Declaratively the same group is one power_transformer entry with a followers list under control.controllers.
Where the data comes from: CGMES RegulatingControl
CGMES models the group directly. Every tap changer may reference a RegulatingControl object (TapChanger.TapChangerControl) carrying the target (mode, targetValue, targetDeadband, the regulated Terminal); several tap changers referencing ONE such object ARE the regulated parallel group. There is no explicit master in the standard; the per-changer controlEnabled flag expresses master/follower by tool convention.
Sparlectra's importer (importCGMES(...; tap_control = true)) groups by the shared object: the first enabled tap changer of a control becomes the master, every further one joins as a follower, reported in the import messages as follows the group of .... Without that grouping, a delivery with a regulated parallel pair would import as two independent, fighting controllers, the exact trap of Example 3.1, produced straight from the data with no user error involved. Tap changers with controlEnabled = false stay at their fixed position.
Where to go next
- Control Framework: the group's theory section, the hook interface, and the declarative controller configuration.
- Transformer taps notebook: the single-unit tap mechanics this part builds on.
- Workshop tour, advanced: FACTS limits, N-1, state estimation, threads.
- CGMES Import: how deliveries map onto the network model.