Skip to content

External-region representations: generator-based imports and boundary-metered CPUC contracted resources - #810

Merged
ktehranchi merged 14 commits into
developfrom
feat/external-regions
Sep 7, 2026
Merged

ktehranchi merged 14 commits into
developfrom
feat/external-regions

Conversation

@ktehranchi

Copy link
Copy Markdown
Collaborator

Stacked on #809 (targets its branch to avoid a duplicated diff; retarget to develop once #809 merges). Related: #807.

Motivation

Regionally scoped runs (California) had three overlapping representations of out-of-footprint supply:

  1. trim_network — a priced import_{bus} generator at real boundary buses. Dead code: the schema types model_topology.trim as boolean while the code indexes trim_topology['zone'], so it cannot run.
  2. add_elec_imports_exports — bottomless Store behind each external flowgate zone, with the import price on the interface links.
  3. CPUC contracted out-of-state units (Palo Verde, Intermountain, Hoover, …) attached at California buses in add_electricity — invisible to the import volume cap and interface limits, and the cause of a consense crash in cluster_network (non-extendable remote units sharing (bus, carrier) groups with extendable in-state units, with p_nom_max=inf polluting land-cap sums).

What this does

New module workflow/scripts/external_regions.py — the single home for external-region construction, selected by a new config key electricity.imports.representation:

  • store (default): today's behavior, pure code motion — byte-identical construction, protecting existing western/usa configs.
  • generator: the external zone is modeled as a place with generation rather than an infinite energy tank. A generic import Generator (carrier unspecified_imports, carrying the import emission factor) sized at the zone's inbound interface capacity and priced with the same machinery; interface links become unpriced transfer capacity; and the CPUC contracted units are attached behind the boundary at the external bus of the flowgate zone they physically sit in (state-matched via membership.csv, largest-interface fallback with a warning). Their deliveries traverse carrier-imports links, so opts/interchange.py (untouched) automatically meters them against volume_limit, bounded by interface capacity.

Design notes (full reasoning in the module docstring):

  • Separate {zone}_imports/{zone}_exports buses are load-bearing in generator mode: a shared bus would create a pure arbitrage loop (import generator selling into the export sink).
  • Export construction identical in both modes; no double-charge exists on the export side.
  • add_electricity now splits remote-contract handling into data derivation vs. network attachment; in generator mode it serializes the unit bundle (remote_units_s{simpl}.pkl) instead of attaching, which also fixes the cluster_network consense crash at the root — the p_nom_extendable: any workaround is removed from the CA config.
  • trim_network and model_topology.trim are deleted.

Files

  • workflow/scripts/external_regions.py (new): both representations, pricing machinery (moved), zone mapping, public add_external_regions() dispatcher.
  • workflow/scripts/add_extra_components.py: −442 lines (moved/deleted), calls the module.
  • workflow/scripts/add_electricity.py: remote-contract split + bundle output, gated on the new param.
  • workflow/rules/build_electricity.smk, workflow/schemas/config.schema.yaml, config.default.yaml, config.california.yaml, docs/source/configtables/electricity.csv.
  • workflow/scripts/test/test_interfaces.py: +12 tests covering both representations, pricing placement, and the zone-mapping rules.

Validation

  • Full unit suite: 250 passed; 17 failures are pre-existing glpk not installed solver tests, none related.
  • tests/static + tests/docs: 131 passed; DAG dry-run tests (including both California variants): 7 passed.
  • CA zone mapping sanity: AZ contracts → p28, NV contracts (Hoover, Copper Mountain, Apex, …) → p13, UT (Intermountain/IPP) → p13 via the documented fallback (no CA-adjacent UT zone in NARIS).

Known behavioral change

With the CA config on generator, contracted out-of-state deliveries now consume the 25% import volume headroom and are bounded by interface capacity — the intent of the design, but a real tightening relative to previous CA runs.

🤖 Generated with Claude Code

ktehranchi and others added 13 commits August 31, 2026 19:11
Ten rules in build_electricity.smk and add_sectors in build_sector.smk had no
benchmark: directive, so snakemake recorded no wall time or max RSS for them.
That included cluster_resources (the largest walltime budget in the config) and
add_extra_components (the terminal rule for network-only builds), which are
exactly the rules needed to tune Slurm resource requests.

Benchmark paths mirror each rule's log path under BENCHMARKS, and carry every
wildcard the rule takes so concurrent jobs of the same rule cannot collide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
run_slurm.sh took a single hard-coded configfile and passed -A {cluster.account},
which Sherlock rejects (it has no accounts). It now takes a list of overlay
configs, runs each as its own `snakemake --cluster` invocation with bounded
concurrency, and reads each build target from config/weather_years/manifest.tsv.

slurm_submit.sh wraps sbatch because several rules compute mem_mb as a float
(e.g. (input.size // 150000) * attempt * 1.5) and `sbatch --mem 4500.0` is
rejected; it rounds up and floors at 2000 MB.

Note: the target must precede --configfile, which is nargs='+' and would
otherwise swallow it as a third config file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The uv-managed CPython ships no CA bundle -- ssl.get_default_verify_paths()
returns cafile=None -- so retrieve_cpuc_servm_load died with
CERTIFICATE_VERIFY_FAILED. A curl reachability test does not catch this,
because curl reads the system trust store that Python never consults.

run_slurm.sh now derives SSL_CERT_FILE/REQUESTS_CA_BUNDLE from certifi and
relies on sbatch --export=ALL to propagate them to every rule job.

Also adds probe_2019.sbatch, the driver job for the resource-tuning probe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sacct splits what we need across two rows: the allocation row has JobName and
ReqMem, the .batch row has MaxRSS. `sacct -X` shows MaxRSS blank, which is a
quiet trap. This joins them on the base job id and aggregates per rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dations

Complements collect_benchmarks.sh: reads snakemake benchmark TSVs, resolves
rule names against rules/*.smk, and prints recommended mem_mb (1.5x peak RSS)
and walltime (3x peak runtime) per rule. Offered upstream in issue #808.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds `electricity.imports.representation` (store | generator) and moves all
import/export construction into a new self-contained `external_regions` module.

* `store` (default) is the existing behaviour, byte-for-byte: a bottomless
  Store per external zone and PRICED interface links.
* `generator` puts a priced, non-extendable import generator (carrier
  `unspecified_imports`, carrying `imports.co2_emissions`) on the external bus,
  leaves the interface link unpriced, and moves the CPUC contracted
  out-of-state units BEHIND the boundary. Their deliveries then traverse a
  carrier-`imports` link, so `opts/interchange.py` meters them against the
  import volume cap and the interface capacity bounds them — which is the point.

`add_electricity` now splits the contracted-unit derivation from its
attachment: in `generator` mode it serializes the fully-derived bundle (unit
table, borrowed VRE profiles, cost table) to a new `remote_units_s{simpl}.pkl`
output that `add_extra_components` attaches at the external buses. Each unit is
placed behind the boundary zone whose state matches its plants, preferring the
zone with the largest inbound interface capacity, falling back (with a warning)
to the largest boundary zone overall.

Also deletes the dead `trim_network` path: the schema typed
`model_topology.trim` as a boolean while the code indexed `trim_topology["zone"]`,
so it could never run.

The California config switches to `generator` and drops the now-obsolete
`p_nom_extendable: any` clustering workaround — remote units no longer exist
inside the footprint at clustering time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extends test_interfaces.py with a toy one-zone footprint and a NARIS-shaped
flowgate frame (two NV zones, one AZ zone, one outbound-only interface):

* generator mode creates the external buses, a non-extendable import generator
  sized at the zone's inbound interface capacity, and UNPRICED interface links;
  store mode keeps the priced links and the bottomless Store;
* the CO2 factor moves onto `unspecified_imports` in generator mode and stays on
  `imports` in store mode;
* exports are byte-identical across representations, and nothing but export
  links injects into an export bus;
* a toy contracted-unit bundle attaches at the state-matched external bus
  (AZ -> the AZ boundary zone), falls back with a warning when the unit's state
  has no direct interface (UT -> the largest inbound zone), and carries its
  borrowed VRE profile across.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cement

At county resolution the boundary zones are 'p'+FIPS ids, absent from the
ReEDS BA membership table, so every contracted unit fell through to the
largest-inbound fallback (all 49 CPUC units behind Clark County). Derive
the state from the FIPS prefix instead; BA zones still use membership.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…chema; CA imports at 60 USD/MWh

The costs code path has always accepted a float or a carrier name, but the
schema enum only admitted the literal strings 'wholesale'/'carrier'/'float',
so neither actually validated. California switches off the mislabeled
'wholesale' (retail-priced, #807) EIA series to a flat 60 USD/MWh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EIA coal/shipments/receipts has no data before 2008 (2007 returns only
spillover from the inclusive end bound). Snapshot years <2008 crashed
build_fuel_prices with AttributeError on the empty payload; 2007 would
have silently produced all-NaN coal prices. Fetch 2008 prices instead
and relabel the month-start index onto the snapshot year.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same failure class as the coal clamp: EIA electric-power gas prices
begin 2002-01, so weather years 2000-2001 got an empty (or
spillover-only) payload -- wy2000 crashed in format_data and wy2001
would have KeyError'd at the snapshot filter. Shift years rather than
replace to keep the inclusive-end spillover January collision-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ricing

Third EIA data floor: retail-sales electricity prices begin 2001, so
the exports 'wholesale' path in add_extra_components crashed for
weather year 2000 on an empty payload. No index relabel needed --
_build_cost_timeseries already maps the data onto investment periods.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ktehranchi
ktehranchi changed the base branch from feat/benchmark-directives to develop September 7, 2026 06:20
@ktehranchi

Copy link
Copy Markdown
Collaborator Author

@copilot resolve the merge conflicts in this pull request

# Conflicts:
#	workflow/repo_data/config/config.california.yaml
#	workflow/report_benchmarks.py

Co-authored-by: ktehranchi <83722342+ktehranchi@users.noreply.github.com>

Copilot AI commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved by merging origin/develop into this branch and fixing all conflicts in merge commit d7f8fc9.

@ktehranchi
ktehranchi merged commit 6bae1ee into develop Sep 7, 2026
3 of 4 checks passed
@trevorb1

trevorb1 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

I haven't looked at the implementation, but I am assuming its good! My question is about supporting two different implementations for the same functionality (I believe its the same, but please correct me if it isn't!). Was there a reason the store/link combo didn't work well?

The reason I implemented it like that (store/link rather than generator) was for emission accounting purposes (ie. easier to track emissions from different sectors). Im not against switching to just generators, but do think it would be good to standardize across the code base!

@ktehranchi

Copy link
Copy Markdown
Collaborator Author

@trevorb1 So the behavior of the two implementations is actually a little different.

So for my model I needed to have a representation of external buses which have both generic imports generation AND have externally contracted resources (solar, wind, BESS). I also wanted to constrain the amount of total imports different out of state buses in-addition to the total imports from out of state buses.

We could unify the two different implementations by placing a generic link+store for unspecified imports behind other regional buses which also have the contracted resources.....

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants