
Aggregating regions without averaging them away
Source:vignettes/articles/region-aggregation.Rmd
region-aggregation.RmdThe problem with a weighted mean
A multi-region model gets expensive in the regions. The usual answer
is to aggregate: pick a coarser geoframe, roll the fine regions up into
it, solve the smaller problem. aggregate_model_regions()
does exactly that – extensive quantities are summed, intensive ones take
a capacity-weighted mean.
That is the right move when the regions are alike, and the wrong one when they are not. A coal fleet built over forty years does not have one efficiency. Its regional spread is the reason the model dispatches in a merit order at all, and a mean erases it.
The spread does not have to be lost. A technology can carry
clusters – technology@cluster describes “a
parallel sub-process of the same technology … with its own capacity,
availability and costs” – so a zone-level technology can keep several
regional variants side by side. Aggregating then has a dial rather than
a switch:
| clusters per zone | what you get |
|---|---|
| one |
aggregate_model_regions(): a single averaged
technology |
| several | fewer variants than regions, but the spread that matters |
| one per region | nothing averaged at all |
This article builds one model, turns one thing at a time, and measures when the dial matters. Two settings leave the answer untouched; three change it, and in one of them the aggregated model comes out cheaper than the truth.
The regions
TOPIA’s eleven regions nest into three zones.
gs <- topia$geoscales$honeycomb
regions <- geoscale_regions(gs, geoframe = "region")
geo <- as.data.frame(geoscale_leaftable(gs))
plot_geoscale(gs, type = "map")The plants
Seven archetypes of coal plant, each a contiguous block inside one
zone. Without real structure there is nothing for a clustering to find,
and every k looks equally bad.
plants <- tibble::tribble(
~region, ~eff, ~invcost, # efficiency, MEUR/GW
"W1", 0.42, 1000,
"W2", 0.33, 1500,
"C1", 0.35, 1100,
"C2", 0.35, 1100,
"C3", 0.42, 1050,
"C4", 0.42, 1050,
"C5", 0.34, 1600,
"C6", 0.34, 1600,
"E1", 0.43, 980,
"E2", 0.43, 980,
"E3", 0.32, 1450
)Real fleets are not that tidy, so the working model puts a ~1% jitter
on both columns. E1a below turns it off to isolate the
exact case.
Load, and the siting limit
One load shape for every region (PJ per timeslice), scaled to a level that rises west to east.
calendar <- topia$modules$calendars$topia_seasons
shape <- topia$demand |>
filter(calendar == "topia_seasons") |>
left_join(
as.data.frame(calendar@timetable) |> select(timeslice, share),
by = "timeslice"
) |>
mutate(w = load * share / sum(load * share))
demand <- plants |>
mutate(annual = seq(80, 120, length.out = n())) |> # PJ/year
select(region, annual) |>
tidyr::crossing(shape |> select(timeslice, w)) |>
mutate(demand = annual * w) |>
select(region, timeslice, demand)Each plant is capped near its own region’s peak. Without that cap the model builds three plants out of twenty-two: once regions share a copperplate their plants compete for one demand and the merit order keeps only the cheapest, so competition erases the heterogeneity before any aggregation happens. The honest reason for a cap is that you cannot put a zone’s entire fleet on one site.
Two technologies, eleven regions each
The region-varying parameters live in the slots, so this is
one object per fuel rather than one per region – and
aggregate_model_regions() clusters either shape.
ECOA <- newTechnology(
name = "ECOA",
desc = "Coal plant, one variant per region",
region = plants$region,
input = list(comm = "COA", unit = "PJ"),
output = list(comm = "ELC", unit = "PJ"),
ceff = plants |> transmute(region, comm = "COA", cinp2use = eff),
invcost = plants |> select(region, invcost),
fixom = plants |> transmute(region, fixom = 0.03 * invcost),
capacity = siting,
af = data.frame(af.up = 0.85),
olife = data.frame(olife = 30)
)
EGAS <- newTechnology(
name = "EGAS",
desc = "Gas plant, one variant per region",
region = plants$region,
input = list(comm = "GAS", unit = "PJ"),
output = list(comm = "ELC", unit = "PJ"),
ceff = plants |> transmute(region, comm = "GAS", cinp2use = round(eff * 1.25, 4)),
invcost = plants |> transmute(region, invcost = round(invcost * 0.6)),
fixom = plants |> transmute(region, fixom = 0.03 * round(invcost * 0.6)),
capacity = siting,
af = data.frame(af.up = 0.85),
olife = data.frame(olife = 30)
)
draw(ECOA)Transmission inside a zone
Adjacent regions in the same zone are linked, amply and losslessly.
That makes each zone a copperplate already – which is the assumption a
cluster aggregation makes anyway, so here it is made explicitly rather
than smuggled in later. E2a removes it and measures what it
was worth.
links <- gs |>
geoscale_geometry(geoframe = "region") |>
sf::st_touches() |>
as.data.frame() |>
transmute(src = regions[row.id], dst = regions[col.id]) |>
filter(src < dst) |>
left_join(geo |> select(src = region, src_zone = zone), by = "src") |>
left_join(geo |> select(dst = region, dst_zone = zone), by = "dst") |>
filter(src_zone == dst_zone) |>
select(src, dst)
routes <- bind_rows(links, links |> rename(src = dst, dst = src))
TBD_ELC <- newTrade(
name = "TBD_ELC",
commodity = "ELC",
routes = routes,
trade = routes |> mutate(teff = 1.0),
capacity = data.frame(stock = 1e4) # GW: ample on purpose
)The model
COA <- newCommodity(name = "COA", unit = "PJ", timeframe = "ANNUAL")
GAS <- newCommodity(name = "GAS", unit = "PJ", timeframe = "ANNUAL")
ELC <- newCommodity(name = "ELC", unit = "PJ", timeframe = "HOUR")
SUP_COA <- newSupply(
name = "SUP_COA", commodity = "COA", unit = "PJ",
supply = data.frame(region = regions, cost = 2.0) # MEUR/PJ
)
SUP_GAS <- newSupply(
name = "SUP_GAS", commodity = "GAS", unit = "PJ",
supply = data.frame(region = regions, cost = 6.0)
)
DEM_ELC <- newDemand(name = "DEM_ELC", commodity = "ELC", demand = demand)
repo <- newRepository(
name = "agg",
COA, GAS, ELC, SUP_COA, SUP_GAS, DEM_ELC, ECOA, EGAS, TBD_ELC
)
mod <- newModel(
name = "AGG",
region = regions,
calendar = calendar,
horizon = newHorizon(2025),
discount = 0.05,
data = repo
) |>
setGeoscale(gs)
autoplot(mod)Solving it
scen_fine <- mod |>
interpolate_model(name = "FINE") |>
solve_scenario(solver = solver_options$glpk)
getData(scen_fine, name = "vObjective", merge = TRUE)$value
#> [1] 185892.859193GLPK is used throughout because every model here is small – eleven
regions and four timeslices – and at that size it is the quickest way to
an answer. Do not carry it to a real one. GLPK stops being usable well
before the sizes that make region clustering worth doing in the first
place, so at any realistic resolution solve with HiGHS through
julia or pyomo:
solve_scenario(scen, solver = solver_options$julia_highs) # or $pyomo_highsThe clustering itself never solves anything –
process_cluster_sweep() and levcost() are
analytic – so the solver only matters for the runs being compared.
What the plants cost, before any clustering
levcost() prices every process the model holds,
analytically – no solver. Sorted, the eleven coal variants fall into the
seven archetypes they were built from, which is exactly the structure a
clustering has to find.
levcost(mod, comm = "ELC", name = "ECOA", by_region = TRUE)
#> levcost_list (11)
#> W1: 119.1202
#> W2: 171.4738
#> C1: 128.3743
#> C2: 128.5894
#> C3: 122.9501
#> C4: 121.8456
#> C5: 179.9411
#> C6: 180.4450
#> E1: 115.6380
#> E2: 113.9066
#> E3: 165.4407E1 is the cheapest archetype and C6 the
dearest – 116 against 180 MEUR/PJ, a 56% spread across plants the model
would otherwise average into one. Priced region by region the eleven
variants fall into seven steps:
| region | LCOE | region | LCOE | |
|---|---|---|---|---|
E2 |
113.9 | C2 |
128.6 | |
E1 |
115.6 | E3 |
165.4 | |
W1 |
119.1 | W2 |
171.5 | |
C4 |
121.8 | C5 |
179.9 | |
C3 |
123.0 | C6 |
180.4 | |
C1 |
128.4 |
Those seven steps are exactly what a clustering has to find, and
levcost() finds them analytically – no solver, no
scenario.
Note by_region = TRUE. levcost() builds a single-region
mini-model, so without it a technology spanning several regions is
priced at just one of them and a bare
levcost(mod, comm = "ELC") reports one number for
ECOA rather than a ladder. by_variant = TRUE
does the same along the other two axes, vintage and cluster – so a fleet
with build years prices per vintage.
Which processes may be merged
Only structurally identical ones. A cluster is a variant of the same process, so a coal plant and a gas plant stay separate however alike their costs look. The class leads the signature, so a supply never joins a technology that happens to touch the same commodity:
get_process_groups(mod)[, c("group", "class", "signature", "n")]
#> group class signature n
#> 1 ECOA technology in:COA|out:ELC|aux:|grp: 11
#> 2 EGAS technology in:GAS|out:ELC|aux:|grp: 11
#> 3 SUP_COA supply comm:COA|unit:PJ 11
#> 4 SUP_GAS supply comm:GAS|unit:PJ 11
#> 5 TRD trade TRD::ELC:: 1Five groups, not two. supply, storage,
import and export carry a
@cluster slot as well, so a region grouping becomes
sub-processes on them too, and n counts the regions they
span. demand is absent by design: it has no
@cluster slot, because a demand is a requirement rather
than a process with archetypes, and summing it over a zone is the right
answer.
TRD is the odd one. A corridor has no
@region slot – its regions are
src/dst pairs – so grouping it means
partitioning corridors between a pair of coarse regions, not
clustering regions, and n counts corridors rather than
regions. Here there is one, so there is nothing to partition; the corridor section uses a model where there
is.
The supplies here are flat – every region pays 2.0 for coal – so there is nothing for a clustering to find, and asking for one says so rather than inventing structure:
aggregate_model_regions(mod, geoscale = gs, level = "zone",
clusters = list(SUP_COA = 7))
#> Error: no usable clustering features: every candidate parameter is constant
#> or absent across the group's regions. Pass `features=`.A supply whose cost really does vary by region is the case worth clustering: plain aggregation gives a zone one capacity-weighted average price, while clusters keep its cheap tranches separate from its dear ones – the same argument as for the plants, one layer upstream.
The geometry constrains the clustering
Two regions may be merged only if they are adjacent, and adjacency drops every edge that crosses a zone boundary:
touching <- gs |>
geoscale_geometry(geoframe = "region") |>
sf::st_touches(sparse = FALSE)
sum(touching) / 2 # pairs on the map
#> [1] 18
zone <- setNames(geo$zone, geo$region)
sum(touching & outer(zone[regions], zone[regions], "==")) / 2
#> [1] 12A cluster has to be connected in that graph, so it cannot
straddle a zone, and the graph has one component per zone –
which puts a floor under k.
Settings are per family, not per model
clusters is keyed by technology group, and a single
number for the whole model is refused when there is more than one:
aggregate_model_regions(mod, geoscale = gs, level = "zone", clusters = 7)
#> Error: `clusters = 7` sets one number for all 5 process groups of this
#> model (ECOA, EGAS, SUP_COA, SUP_GAS, TRD), which is rarely what you want:
#> each family clusters on different parameters and has its own structure, so
#> it has its own k. Name them:
#> clusters = list(ECOA = 7, EGAS = ...)
#> Use get_process_groups() to see what can be merged, and
#> process_cluster_sweep() to choose each k.That is deliberate. A coal fleet groups on cost and efficiency, a
wind fleet on resource quality and the shape of its
availability, and the right number differs with both the spread and what
the family is for. The error makes you look at
get_process_groups() before deciding.
Named, the floor shows itself:
aggregate_model_regions(mod, geoscale = gs, level = "zone",
clusters = list(ECOA = 2, EGAS = 2))
#> Error: k = 2 is out of range for group 'ECOA': clusters cannot straddle a
#> `zone` region, so k runs from 3 (one cluster per `zone` region -- what
#> aggregate_model_regions() produces) to 11 (one per region, nothing averaged).Choosing k by looking
process_cluster_sweep() clusters one family over the
whole admissible range and reports what each k buys, over
the same features and the same contiguity graph the aggregation would
use. Nothing is changed – the point is to see where the structure is
before naming a number:
process_cluster_sweep(mod, "ECOA", geoscale = gs, level = "zone")
#> k within silhouette smallest sizes
#> 1 3 15.57484465 -0.1915882 2 6,3,2
#> 2 4 12.05414650 -0.2325999 1 6,2,2,1
#> 3 5 8.66725287 -0.2220126 1 6,2,1,1,1
#> 4 6 3.29096595 0.1677981 1 4,2,2,1,1,1
#> 5 7 0.30748448 0.5461881 1 2,2,2,2,1,1,1
#> 6 8 0.15692375 0.4535683 1 2,2,2,1,1,1,1,1
#> 7 9 0.08150451 0.2968379 1 2,2,1,1,1,1,1,1,1
#> 8 10 0.01604168 0.1798995 1 2,1,1,1,1,1,1,1,1,1
#> 9 11 0.00000000 0.0000000 1 1,1,1,1,1,1,1,1,1,1,1Read it as a scree plot. Dispersion falls with k and
always will, so take the knee rather than the minimum:
within drops by an order of magnitude between
k = 6 and k = 7 and then only crawls, because
seven is how many distinct archetypes the data has – past that,
k splits members that differ by the jitter alone. The
silhouette peaks in the same place, and sizes says the
grouping is balanced there rather than one big cluster beside a row of
singletons.
ggplot2 makes the knee easier to see than the table
does:
process_cluster_sweep(mod, "ECOA", geoscale = gs, level = "zone") |>
select(k, within, silhouette) |>
pivot_longer(-k) |>
ggplot() +
aes(x = k, y = value) +
geom_line() +
geom_point() +
geom_vline(xintercept = 7, linetype = "dashed") +
facet_wrap(~name, scales = "free_y") +
scale_x_continuous(breaks = 3:11) +
labs(x = "clusters", y = NULL,
title = "ECOA: dispersion and silhouette by k") +
theme_bw()k = "auto" applies a rule – the best average silhouette
over that sweep – and hands the sweep back so the judgement can be
checked rather than trusted. nestedscales ships no
best_k() deliberately: “the numbers inform a judgement that
stays yours.” Sweep and look; use "auto" to reconnoitre,
not to decide.
mod_7 <- aggregate_model_regions(
mod,
geoscale = gs,
level = "zone",
clusters = list(ECOA = 7, EGAS = 7)
)
getObject(mod_7, name = "ECOA", drop = TRUE)@cluster
#> cluster desc region order
#> 1 C1_C2 C1+C2 CENTRAL 1
#> 2 C3_C4 C3+C4 CENTRAL 2
#> 3 C5_C6 C5+C6 CENTRAL 3
#> 4 E1_E2 E1+E2 EAST 4
#> 5 E3 E3 EAST 5
#> 6 W1 W1 WEST 6
#> 7 W2 W2 WEST 7desc records which regions each cluster came from, and
region is the coarse region it lives in – a cluster exists
only where its members did. A cluster of one region takes that region’s
own code, so the 1:1 end reads W1, C3, rather
than c01.
Negative silhouettes at low k are normal here – that is
the contiguity constraint, not a failure. With unstructured data the
rule degenerates: the silhouette climbs monotonically to
k = n and “best” becomes “do not aggregate at all”. A clean
peak means the structure is real.
Seeing the clustering
A grouping you cannot look at is a grouping you cannot check.
model_clusters() hands back the crosswalk, the sweep, and a
geoscale carrying a cluster geoframe – so
the views that already exist show the method with no new plotting
code:
cl <- model_clusters(mod_7)
plot_geoscale(cl$ECOA$geoscale, type = "map", geoframe = "cluster")The atoms are coloured by the cluster they joined. Every cluster is one connected blob and none crosses a zone boundary – that is the contiguity constraint, visible rather than asserted.
plot_geoscale(cl$ECOA$geoscale, type = "icicle")The icicle reads
nation > zone > cluster > region: the cluster
layer is an extra level inserted between the target and the
atoms, which is exactly what a clustered aggregation is.
as.data.frame(geoscale_leaftable(cl$ECOA$geoscale))
#> nation zone cluster region
#> 1 TOPIA WEST W1 W1
#> 2 TOPIA WEST W2 W2
#> 3 TOPIA CENTRAL C1_C2 C1
#> 4 TOPIA CENTRAL C1_C2 C2
#> 5 TOPIA CENTRAL C3_C4 C3
#> 6 TOPIA CENTRAL C3_C4 C4
#> 7 TOPIA CENTRAL C5_C6 C5
#> 8 TOPIA CENTRAL C5_C6 C6
#> 9 TOPIA EAST E1_E2 E1
#> 10 TOPIA EAST E1_E2 E2
#> 11 TOPIA EAST E3 E3This geoscale is a view, not the model’s own. The
aggregated model’s geoscale is pruned to zone, and its
atoms have to keep matching its regions or
interpolate_model() misreads what resolution it is looking
at.
Two families, two settings
Both families have the same eleven regions and the same contiguity
graph, so it is tempting to give them one k. Their sweeps
even agree – here, because EGAS was built as a scaled copy
of ECOA, so its parameters spread the same way:
process_cluster_sweep(mod, "EGAS", geoscale = gs, level = "zone") |>
select(k, within, silhouette) |>
left_join(
process_cluster_sweep(mod, "ECOA", geoscale = gs, level = "zone") |>
select(k, within_coa = within, sil_coa = silhouette),
by = "k"
)
#> k within silhouette within_coa sil_coa
#> 1 3 15.57976856 -0.1919701 15.57484465 -0.1915882
#> 2 4 12.06067920 -0.2329213 12.05414650 -0.2325999
#> 3 5 8.67779012 -0.2236994 8.66725287 -0.2220126
#> 4 6 3.29457713 0.1649621 3.29096595 0.1677981
#> 5 7 0.31202956 0.5430788 0.30748448 0.5461881
#> 6 8 0.15829511 0.4519464 0.15692375 0.4535683
#> 7 9 0.07961517 0.2960823 0.08150451 0.2968379
#> 8 10 0.01433427 0.1801032 0.01604168 0.1798995
#> 9 11 0.00000000 0.0000000 0.00000000 0.0000000And yet the same k is the wrong choice for both, because
what a clustering costs is not how the parameters spread – it is what
the model does with the difference. Four runs settle it:
aggregate_model_regions(mod, geoscale = gs, level = "zone",
clusters = list(ECOA = 7, EGAS = 3)) |>
interpolate_model(name = "K73") |>
solve_scenario(solver = solver_options$glpk) |>
getData(name = "vObjective", merge = TRUE) |>
pull(value)
#> [1] 186705.498894ECOA |
EGAS |
objective | gap to fine |
|---|---|---|---|
| 11 | 11 | 185892.859193 | – (the fine model) |
| 7 | 7 | 186063.417050 | +0.0918% |
| 7 | 3 | 186705.498894 | +0.4372% |
| 3 | 7 | 192627.056726 | +3.6226% |
| 3 | 3 | 193269.138570 | +3.9680% |
Read the last two rows against each other. Coarsening
gas alone costs 0.35 points; coarsening
coal alone costs 3.53 – ten times as much, from the
same k, on the same regions, with the same spread in the
data. Coal is on the margin, where a fraction of a point of efficiency
decides which plant runs; gas sits at its siting ceiling in every zone,
so averaging its cost across a zone changes almost nothing. One
k for the model either wastes variants on gas or ruins
coal.
Which family deserves the resolution is an empirical question about
the model, not a property of the data, and no rule reads it off the
sweep. That is why clusters is keyed by family and why a
bare number is refused.
features is per family for the same reason. It takes the
parameter names to cluster on, or a function of the
member technologies returning one row per region – which is how a
profile shape enters, since scalars cannot see shape.
E2c below is that case: there the clustering has to choose
one availability profile per cluster, and it takes a real member’s
rather than a fabricated mean.
Plain aggregation, and the same thing said with clusters
clusters = NULL is the plain aggregation: each zone
collapses to one value per parameter. On this model that is
exactly k = 3 – one cluster per zone, nothing left
to distinguish:
aggregate_model_regions(mod, geoscale = gs, level = "zone") |>
interpolate_model(name = "PLAIN") |>
solve_scenario(solver = solver_options$glpk) |>
getData(name = "vObjective", merge = TRUE) |>
pull(value)
#> [1] 193269.138570Identical to the 3/3 row above, to the last digit. The
floor under k is the plain aggregation, and the two are the
same model written two ways – so clusters is an argument
you turn up from there, not a different function.
Comparing runs, not just cost
The runs are not keyed alike: the fine model reports eleven regions,
a clustered run three zones and one variant per cluster
(ECOA_CLC1C2 …). No name surgery is needed for either,
because solved output carries the object a variant came from in
base, and its cluster in cluster. The
leaftable maps region to zone.
getData(scen_fine, name = "vTechCap", merge = TRUE) |>
mutate(zone = geo$zone[match(region, geo$region)]) |> # W1 -> WEST
summarise(capacity = sum(value), .by = c(zone, base)) |>
arrange(zone, base)
#> zone base capacity
#> 1 CENTRAL ECOA 191.123
#> 2 CENTRAL EGAS 1083.031
#> 3 EAST ECOA 113.114
#> 4 EAST EGAS 640.978
#> 5 WEST ECOA 53.306
#> 6 WEST EGAS 302.070A clustered run reports zones already, so there zone is
region and nothing but the .by changes. For
the general case compare_scenarios() does this properly –
overview, per-variable difference counts and the largest absolute deltas
– once the keys are aligned.
Solving at the top level
There is no way to solve a fine model at a coarse level
without aggregating. interpolate_model() sorts a
geoscale by how its atoms line up with the model’s regions, and a
geoscale whose atoms are coarser than the model is refused with a
pointer back here. prune_geoscale() is what this function
calls to set the result’s geoscale – it is not an alternative route. The
two real routes upward are to aggregate a fine model, as below, or to
declare the model coarse from the start, which
vignette("space-resolution") demonstrates.
TOPIA’s top frame is nation, one region:
mod_nation <- aggregate_model_regions(
mod,
geoscale = gs,
level = "nation",
clusters = list(ECOA = 11, EGAS = 11)
)
mod_nation@config@region
#> [1] "TOPIA"| level | clusters | objective | gap to fine |
|---|---|---|---|
| region | – | 185892.859193 | – (the fine model) |
| zone | 7 / 7 | 186063.417050 | +0.0918% |
| nation | 11 / 11 | 184380.820211 | -0.8134% |
| nation | plain | 193259.899661 | +3.9631% |
The third row is the one to look at. Eleven clusters over one region
keeps every parameter – nothing is averaged, the 1:1
case – and still comes out 0.81% cheaper than the model
it came from. Nothing about the plants changed; the topology did. The
fine model links regions only inside a zone, and collapsing the three
zones into one nation hands it three copperplates joined into one, so
EAST’s cheap coal can serve WEST’s load over a
line that does not exist. E2a below is the same mechanism
measured on purpose.
That is the general rule for coarsening: a 1:1 clustering proves the parameters survived, never that the aggregation was safe. Whatever the regions were keeping apart is gone as soon as they share a balance.
Sampling a sub-territory
Coarsening the whole model and taking part of it at full resolution
are different moves. subset_model_regions() keeps the
model’s own regions and drops the rest, so it composes with everything
above – sample first, then cluster what is left:
mod_sub <- subset_model_regions(
mod,
region = geo$region[geo$zone %in% c("WEST", "CENTRAL")]
)
#> spatial sample: kept 8 region(s); dropped 0 object(s) and 6 trade route(s)
#> outside the sample (verbose = TRUE lists them)
get_process_groups(mod_sub)[, c("group", "n")]
#> group n
#> 1 ECOA 8
#> 2 EGAS 8The families are the same families, eight regions deep instead of
eleven, and the admissible k moves with them – two zones
now, so the floor is 2 and the ceiling 8:
process_cluster_sweep(mod_sub, "ECOA", level = "zone")
#> k within silhouette smallest sizes
#> 1 2 12.23946641 -0.1520565 2 6,2
#> 2 3 8.74669572 -0.4145140 1 6,1,1
#> 3 4 3.48880116 0.0952621 1 4,2,1,1
#> 4 5 0.16577247 0.6206045 1 2,2,2,1,1
#> 5 6 0.08257609 0.4081067 1 2,2,1,1,1,1
#> 6 7 0.01674316 0.2475210 1 2,1,1,1,1,1,1
#> 7 8 0.00000000 0.0000000 1 1,1,1,1,1,1,1,1The knee is at k = 5 here, not 7: EAST took
two of the eleven archetypes with it. The sub-territory’s structure is
its own, and k chosen on the full model would be wrong for
it.
aggregate_model_regions(mod_sub, level = "zone",
clusters = list(ECOA = 5, EGAS = 5)) |>
interpolate_model(name = "SUB5") |>
solve_scenario(solver = solver_options$glpk) |>
getData(name = "vObjective", merge = TRUE) |>
pull(value)
#> [1] 130655.145340Against the sampled fine model’s 130572.373965 that is +0.0634% – the
same order as 7/7 on the whole model, for the same reason:
five clusters is the resolution these eight regions actually have.
One thing sampling can change under you
A cost declared at a coarse cell is charged once, in
full, at that cell – it does not know how many of the cell’s children
are still in the model. Sample away some of them and the sub-model
carries the whole corridor’s cost, so its objective stops being
comparable with the full model’s. subset_model_regions()
says so:
TBD_PRICED <- newTrade(
name = "TBD_ELC", commodity = "ELC", routes = routes,
trade = routes |> mutate(teff = 1.0),
capacity = data.frame(stock = 1e4),
invcost = data.frame(region = "CENTRAL", invcost = 50) # a ZONE, not a reg.
)
mod_priced <- mod |>
add(TBD_PRICED, overwrite = TRUE) |> # replaces the unpriced corridor
subset_model_regions(region = c("C1", "C2", "C3"))
#> Warning: Sampling regions changes a trade cost declared at a coarse level:
#> TBD_ELC@invcost at 'CENTRAL' (keeps 3 of 6 region(s): dropped C4, C5, C6)
#> A coarse cost is charged ONCE at that cell, whatever is left under it, so
#> this sub-model bears the WHOLE corridor cost rather than the share its
#> remaining endpoints should -- its objective is not comparable with the full
#> model's.
#> Declare the cost on the endpoint regions instead, so it follows the ones
#> that survive.It is silent when the whole cell survives, and silent for endpoint-named and unregioned costs, which follow the endpoints that are left. Only the coarse form has the problem, and naming the endpoint regions is the fix.
Does aggregation change the answer?
Five experiments, each solving the fine eleven-region model, aggregating it to the three zones, and comparing.
E1a – members identical: exact
With no jitter every member of an archetype has the same
efficiency and invcost, so the capacity-weighted mean is that value and
clustering at k = 7 is lossless by construction. Objective
identical to the last digit, capacity and generation identical.
E1b – a realistic spread: near-exact
With the jitter back, members differ by about 1%. Clustering what is genuinely alike costs the spread you chose to ignore and nothing else.
E2a – pooling grants transmission that does not exist
Drop TBD_ELC and each region must serve its own load, so
all plants build. Aggregating to zones hands the model a copperplate it
never had.
mod_isolated <- newModel(
name = "AGG_ISOLATED",
region = regions,
calendar = calendar,
horizon = newHorizon(2025),
discount = 0.05,
data = newRepository("agg_isolated",
COA, GAS, ELC, SUP_COA, SUP_GAS, DEM_ELC, ECOA, EGAS)
) |>
setGeoscale(gs)Every aggregation comes out cheaper than the model
it came from, at every k including 1:1. That is the lesson:
1:1 clusters preserve the parameters perfectly and the topology not at
all. Capacity and generation do not move – both are pinned by the siting
cap and the demand – but fuel burn drops about 9%, and with it
emissions. A modeller reporting capacity sees nothing; one reporting
emissions is wrong by a tenth.
E2b – merging across the real structure
The last two experiments need quantities chosen by economics rather than pinned by a ceiling, which takes one change: gas fuel at 12 instead of 6, at which point gas sits at 78% of its cap rather than 100%.
SUP_GAS_12 <- newSupply(
name = "SUP_GAS", commodity = "GAS", unit = "PJ",
supply = data.frame(region = regions, cost = 12.0)
)
mod_economic <- newModel(
name = "AGG_ECONOMIC",
region = regions,
calendar = calendar,
horizon = newHorizon(2025),
discount = 0.05,
data = newRepository("agg_economic",
COA, GAS, ELC, SUP_COA, SUP_GAS_12, DEM_ELC,
ECOA, EGAS, TBD_ELC)
) |>
setGeoscale(gs)At k = 3 dissimilar archetypes collapse into one average
plant, coal capacity more than halves and gas takes
over – because the averaged coal plant is worse than the good half of
the fleet it replaced. k = 7, clustering along the
structure, stays near-exact. The error is not aggregation as such; it is
merging things that are not alike.
E2c – one profile per cluster
Give each archetype an availability profile peaking at a different
slice of the day, so members differ in shape and not only in
scalars. A profile is a link to another object rather
than a number, so it cannot be averaged:
aggregate_model_regions() gives each cluster its
medoid member’s profile – a real member’s shape rather
than a fabricated mean. That is the better of the two available answers
and still lossy, because the other members’ shapes are gone. Averaging
them would be worse: the mean of two offset peaks is flatter than
either, and the model would believe in availability no site ever
had.
The five together
| experiment | changed | mechanism | objective | quantities |
|---|---|---|---|---|
| E1a | no jitter | members identical | exact | identical |
| E1b | – | ~1% spread | +0.0918% | identical |
| E2a | no TBD_ELC
|
pooling adds transmission | -3.89% | fuel -9% |
| E2b | gas 12, k = 3
|
merged across structure | +5.07% | coal cap -55% |
| E2c | profiles, k = 3
|
one profile per cluster | +4.16% | coal gen -48% |
Read the last two columns rather than the middle one. E2a is the only
case where the objective moves down, and the only one no choice
of k fixes – it is a topology error, not a clustering
error. E2b and E2c are clustering errors, and both vanish at
k = 7, the resolution the data actually has.
What the 1:1 case does and does not prove
At k = 11 the aggregated model reproduces the fine
objective to the last digit in E1a, E1b, E2b and E2c. That is a real
check – every parameter survived the merge, extensive quantities were
summed and intensive ones were not, and no variant was silently
dropped.
E2a keeps it honest. There k = 11 is 3.9% cheaper than
the model it came from with every parameter intact, because the
transmission limits did not survive the aggregation – and the
nation run above is the same thing at 0.81% on the base
model, where every parameter survives and three zones merge into one.
Use the 1:1 invariant to test the parameter carry-over, never
as evidence that the aggregation as a whole is safe.
Caveats
- Clusters are not regions. They share one balance, one demand and one set of trade routes. Everything that made the regions distinct other than the clustered parameters is gone – which is what E2a measures.
- The weights matter. Intensive parameters are averaged with one size per region, taken from capacity. A group whose members declare no capacity gets equal weights, which is rarely what you want.
-
k = "auto"is a rule, not an answer. Sweep withprocess_cluster_sweep()and read it. If the silhouette has no peak, the regions do not cluster. -
One
kis not a model-wide setting. Which family deserves the resolution is an empirical question – here coarsening coal costs ten times what coarsening gas costs – soclustersis keyed by family and a bare number is refused when there is more than one. - Only structurally identical technologies merge. If a family should be merged but is not, compare the signatures – an aux commodity in one member is enough to split it.
- Check the quantities, then check why. If they do not move, find out whether the aggregation is good or a constraint is doing the choosing. In the base model gas builds to exactly 100% of its siting cap in all three zones, which pins the build whatever the parameters say.