Area Interchange Control
Motivation
Distributed slack drives one number to one target: the slack's output to its own schedule. Interconnected systems schedule more than that. Each control area agrees a net position with its neighbours — how much it will export across its tie lines over the hour — and dispatches its own generators to meet it. An area importing 300 MW when it agreed to import 200 has every tie-line flow around it wrong, for exactly the reason a single slack absorbing an unscheduled few hundred megawatts does.
The formulation
An area's interchange is the active power leaving it across its own boundary branches, measured at its own side of each:
\[ X_a = \sum_{b \in \partial a} P_b^{(a\text{-side})} \]
A branch is a boundary of area \(a\) when exactly one of its ends is in \(a\) — including the
case where the other end is in no area at all. The knob is the one distributed slack already uses:
the participating generators' p_spec.
One area's position is dependent, and it has to be
Both ends of a tie are measured into the branch, so they do not cancel — they sum to what the tie dissipates:
\[ \sum_a X_a = +\ell_{\text{tie}} \]
This is the easy thing to get backwards. It is not that one area's export is the other's import: the loss falls between them and belongs to neither.
The consequence is structural. A set of agreed net positions sums to zero, but the achievable sum is \(\ell_{\text{tie}}\), which nobody knows before the solve. Counting the freedom:
| Knobs | each area's aggregate participant schedule — \(N\) |
| Conditions wanted | \(N\) interchange targets, plus the slack on its own schedule — \(N+1\) |
Over-determined by one, always. So the area holding the slack is the dependent one: its own target is not enforced, and its condition is that the slack produces its schedule instead. Every other area's position is met exactly, and the slack's area takes the residual.
That residual is the tie losses when the areas partition the network — but only then. A bus in no
area is on the boundary of whichever areas it touches, so anything it injects lands in the residual
too. CGMES makes this the normal case rather than an edge one: a TieFlow names the boundary node,
which in a merged model is the X-node two areas' lines meet at, and that node belongs to neither.
On MicroGrid the five X-nodes inject nothing, so the whole 53 MW residual is the boundary lines'
own losses — which are large because it is a truncated-boundary model, not because anything is
wrong.
That is what a real interconnection does — one area's position ends up a residual rather than an
agreement — and gridoxide reports it as such rather than implying it. AreaInterchangeReport::dependent
names the area, and its residual entry is how far its nominal target was missed.
The sign
For an area that is not the slack's, exporting above target means it should generate less, so \(X_a - X_a^{target}\) is subtracted from its participants by normalized weight.
For the slack's area, write \(\delta_s\) for the slack's excess over its own schedule. Its participants must take that excess up, so the quantity subtracted is \(-\delta_s\).
It generalizes distributed slack rather than sitting beside it
One area covering the whole network has no boundary branches, so \(X = 0\); that area is the slack's area, so its condition is \(-\delta_s\); and the loop becomes distributed slack exactly.
That is asserted, not asserted-about: area_interchange_with_one_area_is_distributed_slack runs both
loops on one network and compares the per-bus shifts and the solved voltages. A flipped sign in the
derivation above makes it diverge instead of agree.
It is also why the two must not both be configured — run_power_flow refuses rather than
dispatching the same generators twice — and why powsybl-open-loadflow's own
AcAreaInterchangeControlOuterLoop constructs a DistributedSlackOuterLoop as its no-area fallback
and its filterInconsistentOuterLoops removes the latter when the former is present.
Where the areas come from
All three importers supply them, and they supply different amounts.
CGMES states both halves. ControlArea carries the schedule as
netInterchange, and a TieFlow per boundary terminal states the boundary.
cgmes::cgmes_control_areas turns that into an assignment and a target.
CGMES defines an area by its boundary and never lists what is inside, so membership is derived: take the branches the tie flows name, cut them, and compute the connected components of what remains. Each component containing an area's seed is that area. A component reached from two areas' seeds is a contradiction in the file's own boundary, reported rather than arbitrated.
One detail is worth stating because getting it wrong is quiet. A TieFlow names
the terminal at the boundary, not inside the area — in a merged model that
is the X-node where two areas' lines meet, and TN_Border_AL11 carries both
NL-Line_1's and BE-Line_3's tie flow. What belongs to the area is the
equipment; the node is shared. So the seed is the cut branch's far end.
Seeding from the named end put every area's seed on the same border node, which
showed up as five contested buses and one area owning nothing at all.
The sign matters as much. CGMES states netInterchange as an import —
"positive sign means flow in to the area" — and AreaDefinition::targets is an
export, so the importer negates. Checked against the fixture's own data rather
than against the specification alone: SmallGrid declares a 210 MW position and
its published state already sits at 210.271 MW measured, which it could not with
the sign flipped.
IIDM states membership directly, and it is the only one that does.
<iidm:area> lists the voltage levels inside it as <voltageLevelRef>, so
nothing is derived — a bus belongs to whichever area claims any of its nodes'
voltage levels, and a bus merging nodes from two areas is a contradiction the
report counts. interchangeTarget gives the schedule where a file states one,
in the same load sign convention CGMES uses ("negative is export, positive is
import", per Area.java's own javadoc), so the same negation applies.
<areaBoundary> elements are counted and not read: gridoxide derives the
boundary from membership, so a stated one is redundant here. An areaType other
than ControlArea — a BiddingZone, say — partitions the network for a
different purpose and is skipped, counted so its absence is visible.
UCTE states membership only. ##Z<cc> sub-headers are a bus-to-area
assignment and nothing more, exposed as UcteImport::country_areas. No UCTE
file anywhere states a scheduled net position, so the caller supplies the
targets — and the default of zero asks each country to serve its own load, which
is the honest reading of "no interchange agreed". The twelve-node case is a real
four-country interconnection whose own state has BE exporting 2000 MW and DE
importing 2500.
Both are reachable from the command line:
gridoxide solve <network> --area-interchange
A cross-format check worth having
The twelve-node case exists as both .uct and .xiidm, and
ucte_and_iidm_agree_exactly_on_the_twelve_node_case already establishes they
are the same network. So the areas must agree too — derived from ##Z country
codes on one side and stated as <voltageLevelRef> on the other, by entirely
separate code. They do: BE +2000 MW, DE −2500, FR +1000, NL −500, to 1e-6.
Which participants, and why it differs from distributed slack
AreaDefinition::uniform participates Slack and PV buses only, matching
SlackDistribution::uniform. That policy is right for distributed slack, which
is frequency response: a machine not under governor control does not pick up
imbalance.
A net position is not frequency response. It is met by redispatch, and a
generator held at a fixed active set-point is precisely the machine an operator
redispatches. AreaDefinition::by_generation weights by p_spec at every bus
instead, and the command line uses it.
What the wrong policy costs is visible in powsybl's own two_area_case.xiidm:
both of AREA2's generators are voltageRegulatorOn="false" and so arrive as
PQ buses, leaving that area with no participant at all under uniform and its
−400 MW schedule unreachable. Under by_generation it reaches −400.000 exactly.
AreaInterchange::measure is public for a related reason, and is useful without
the loop: "what is this area actually exchanging" is worth asking of a solved
network without controlling it.
Deliberately out of scope
- Several slacks per area, and areas spanning islands. The first area found holding a slack is the dependent one; an island with its own reference balances through its own participants.
- Boundary-point areas, where a bus sits on a tie whose flow the area does not count toward its own position. powsybl handles this by attributing the slack across the areas that do count it.
- A per-area tolerance, and powsybl's separate
areaInterchangePMaxMismatchbesideslackBusPMaxMismatch. One tolerance covers both here.
See also
- Outer Loops — the layer this runs on
- Distributed Slack — the degenerate case