Skip to content

Config reorganization: auto-loaded base config, tracked layers, schema validation - #794

Merged
ktehranchi merged 15 commits into
developfrom
refactor/config-organization
Aug 31, 2026
Merged

Config reorganization: auto-loaded base config, tracked layers, schema validation#794
ktehranchi merged 15 commits into
developfrom
refactor/config-organization

Conversation

@ktehranchi

@ktehranchi ktehranchi commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Closes # (n/a — follow-up to the config-organization review; drift class of #793).

Changes proposed in this Pull Request

Reorganizes how configuration is loaded and maintained, and fixes the shipped-default breakage that the reorganization surfaced.

Config reorganization (results-neutral by construction — see Testing):

  • config.default.yaml is auto-loaded as the base layer; configs passed via --configfile are now sparse overlays. config.tutorial.yaml shrank 368→93 lines, config.test.yaml 109→43 (the latter had drifted badly — it was missing the entire solving: section that cluster_simpl dereferences). The two equivalence configs deliberately remain full copies: the equivalence anchor worktree doesn't load the default layer, so sparse overlays would feed the two sides different configs.
  • Layered configs (common/plotting/sector/default) load directly from tracked repo_data/config/ — the copy-into-workflow/config/ drift mechanism (root cause of Restore renewable keys dropped by #791 (fixes fresh-clone parse crash) #793) is gone. Only user-owned files live in git-ignored workflow/config/: your scenario config, config.api.yaml, and config.slurm.yaml (both existence-guarded, so a bare clone runs without init_pypsa_usa.sh). policy_constraints/ CSVs are referenced from repo_data/config/ directly.
  • One owning file per top-level config keyrenewable:/lines: no longer split across common and default; renewable_weather_years/renewable_scenarios deduplicated.
  • config.cluster.yamlconfig.slurm.yaml (the old name collided with the clusters wildcard / clustering: section) and per-rule walltimes unified into the single consumed block; the dead copies removed. Two rules (aggregate_egs, solve_network_validation) whose walltimes silently resolved to None now have values.
  • EIA API key resolvable from $EIA_API_KEY, yaml fallback.
  • JSON-schema validation of the merged config at parse time (workflow/schemas/config.schema.yaml): typos like retirment: or foresight: perfetc now fail loudly at load instead of silently falling back to code defaults.
  • Load-bearing nulls (focus_weights, model_topology.include) documented in-file; model_topology.aggregate defaults to {}.
  • Docs swept to match: config-configuration.md, about-usage.md, configtables, release-notes.md migration list. CLAUDE.md gains a branch-policy section (work against develop, not master).

Shipped-default fix (results-affecting for godeeep DAGs):

  • renewable_land_access now defaults to reference. The templates shipped renewable.dataset: godeeep with the key null — a pair build_renewable_profiles hard-rejects, so no shipped config could build renewable profiles from a clean checkout (same drift class as Restore renewable keys dropped by #791 (fixes fresh-clone parse crash) #793: an untracked user config was papering over a broken tracked default). reference is NREL's central land-access assumption and the value both equivalence configs already pin. Adds retrieve_nrel_exclusion_artifact jobs to every godeeep DAG (6 for the tutorial). With a working default in place, the schema's godeeep conditional requires non-null (null stays valid for dataset: atlite).
  • CI Tier B un-broken: the e2e job cached data/cutouts at the repo root while tests/integration/conftest.py checks workflow/data/workflow/cutouts, so Tier B has been silently skipping — which is how the broken default went unnoticed. Cache paths fixed, DATA_CACHE_NUMBER bumped so stale root-level archives aren't restored. Note the CI workflow is currently disabled at the repo level; once re-enabled, the first run will rebuild the cache and exercise Tier B for real.

Testing

  • Merged-config equality proof: a replay of snakemake.utils.update_config (snakemake 7.32.4) over the full layer order asserts deep equality of the merged dict for all five shipped configs vs origin/develop. Intentional differences only: 7 policy_constraints path repoints, 9 dead per-rule walltime keys removed, 2 missing walltimes filled, model_topology.aggregate: None→{} (behavior-identical; verified against all consumers), the additions the default layer contributes to previously-drifted overlays (each audited; one solver-affecting key patched explicitly into the equivalence configs so the anchor side stays identical), and — from the fix — renewable_land_access: null→reference for default/tutorial/test (the equivalence configs already pinned reference, so their merged dicts are unchanged).
  • Equivalence harness not run — the equivalence configs' merged dicts are byte-identical to develop, established by the proof above.
  • Tier A pytest -m fast: 90 passed, 57 deselected. tests/docs: 40 passed. workflow/scripts/test: 57 passed, 1 pre-existing skip.
  • Dry-runs (tutorial rule all, default data_model, test, and a bare workflow/config/ clone-state) all resolve; the tutorial DAG picks up exactly the 6 expected retrieve_nrel_exclusion_artifact jobs.
  • Schema: all five configs validate; injected typos and a null renewable_land_access under godeeep fail with clear ValidationErrors.
  • pre-commit on all changed files: pass. Sphinx docs build (-n, nitpicky): zero warnings.

Checklist

  • I tested my contribution locally and it seems to work fine.
  • pre-commit and pytest -m fast pass locally.
  • Code and workflow changes are sufficiently documented.
  • Changed dependencies are added to workflow/envs/environment.yaml. (n/a — no dependency changes)
  • Changes in configuration options are added in workflow/repo_data/config/config.default.yaml.
  • Changes in configuration options are also documented in docs/source/configtables/*.csv.

🤖 Generated with Claude Code

ktehranchi and others added 12 commits August 29, 2026 14:39
config.default.yaml documented every user-facing option but was never
loaded, so every scenario config (tutorial, test, equivalence) had to be a
full copy of it and drifted structurally. config.test.yaml had drifted far
enough that it no longer carried `solving:` or `costs.atb`, which
cluster_simpl and build_cost_data dereference directly.

Snakefile now loads repo_data/config/config.default.yaml as the last
layered configfile, beneath whatever the user passes with --configfile, and
config.tutorial.yaml / config.test.yaml become sparse overlays carrying only
the keys that differ from the merged base (plus scenario.interconnect, which
is pinned deliberately so the footprint cannot follow a change to the
default scenario). Lists are replaced wholesale by snakemake's merge, so any
differing list is restated in full.

The two equivalence configs stay self-contained on purpose: the harness
copies them into an anchor worktree (upstream e7f8bd7) that does not load
config.default.yaml, so a sparse overlay there would feed the two sides
different configs. GURO_PAR_BARDENSETHRESH is added to their gurobi-default
block for the same reason - the candidate now inherits it from the default
layer and the anchor never would.

Verified by replaying snakemake's update_config merge for all five configs
against the origin/develop baseline: no key changes value; the only diffs
are additions from the default layer, plus model_topology.include/aggregate
going from {} to None (update_config maps an empty dict onto a null parent as
null). Every consumer of those two keys is a falsy check or an `is not None`
guard that skips the same work either way.

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

init_pypsa_usa.sh copied the whole template tree into the gitignored
workflow/config/ once and refused to refresh, so every user's layered base
(common, plotting, sector, cluster) silently froze at whatever the checkout
looked like on the day they ran it. Only API keys and HPC account settings
are genuinely per-user.

The Snakefile now reads its entire layered base out of the tracked
repo_data/config/ templates, and treats config/config.api.yaml and
config/config.cluster.yaml as OPTIONAL overlays on top of tracked
placeholders - a fresh clone resolves without running init at all. The
policy_constraints/ CSVs move the same way: configs, rules and the opts
registry now reference repo_data/config/policy_constraints/ so nothing has
to be copied.

init_pypsa_usa.sh now seeds three files (config.default.yaml as a scenario
starting point, config.api.yaml, config.cluster.yaml), is idempotent
per-file, and says what it is not copying and why. .gitignore's bare
`config/` pattern also matched workflow/repo_data/config, forcing every
template edit through `git add -f`; it is now scoped to /workflow/config/*.

test_config_tree_sync asserted a copy relationship that no longer exists.
It is replaced by two tests that guard the new invariant instead: init may
only seed known per-user files, and no `configfile:` may point back into
config/. Tier A/B and the Tier C harness are updated for the new paths; the
harness keeps feeding the anchor worktree from config/, since that pinned
upstream checkout still reads its configs from there.

Merged-config diff vs origin/develop: only the seven policy_constraints
paths, all repointed from config/ to repo_data/config/ where the tracked
CSVs actually live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
config.common.yaml and config.default.yaml both defined renewable_weather_years,
renewable_scenarios and co2: with identical values, and split renewable: and
lines: across the two files - so answering "where is this configured?" meant
reading both and knowing the load order.

Each top-level key now lives in exactly one file, following where the bulk of
each block already was:

  common  owns renewable:  (per-tech resource/land screens, plus `dataset`,
                            which was the only thing default contributed)
  default owns lines:      (the line-type catalog moves over to join
                            s_max_pu / s_nom_max / max_extension / length_factor),
          plus co2:, renewable_weather_years and renewable_scenarios, whose
          duplicates in common are deleted.

Both file headers now state the rule and the ownership split. Nothing moves
out of the loaded set, so the merged config is byte-identical: the merge
replay reports zero new differences against the origin/develop baseline, and
a cross-layer scan finds no remaining duplicate top-level keys.

No docs directive referenced the deleted # docs : RENEWABLE_DATASET or
# docs : LINES_TYPES markers; the literalinclude slices that neighbour them
still resolve to the same key sets (verified by tests/docs).

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

The file has always been about the job scheduler, but its name collided with
the `{clusters}` wildcard and the `clustering:` config section, so "the
cluster config" was ambiguous every time it came up.

Per-rule wall times were specified in three places. config.default.yaml's
top-level `walltime:` block is the one the rules actually read
(`config_provider("walltime", "<rule>")` -> `resources: walltime=` ->
`sbatch --time {resources.walltime}`). config.cluster.yaml ALSO carried
per-rule `{rule}: {walltime: ...}` blocks with different values, which
nothing read: run_slurm.sh only dereferences `{cluster.account/email/
partition/output/error}` from `__default__`, and takes the time limit from
resources. Those dead blocks were still injected into the merged config as
top-level keys named after rules.

config.slurm.yaml now holds `__default__` (for --cluster-config) plus the one
`walltime:` block, and config.default.yaml's copy is deleted with a comment
distinguishing it from `solving.walltime`, which caps the solver rather than
the scheduler job. Live values are preserved exactly (config.default.yaml's
won); the two rules that only existed in the dead blocks, aggregate_egs and
solve_network_validation, are folded in - solve_network_validation is read
with no fallback and had been resolving to None.

Merged-config diff: the nine dead `<rule>.walltime` top-level keys are gone,
`walltime.aggregate_egs` and `walltime.solve_network_validation` are added,
and every previously-live walltime value is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The key was reachable only by pasting it into a yaml file inside the
checkout. It is now read once in workflow/Snakefile, where the environment
wins over config/config.api.yaml, so the secret can stay out of the repo
entirely. A dozen rules dereference config["api"]["eia"] at parse time, so
the override has to land before the include: statements - it does.

The tracked repo_data/config/config.api.yaml keeps the key defined (as null)
and documents both routes; the per-user copy under config/ is now an optional
overlay. The two __main__ debug blocks in eia.py and build_natural_gas.py
follow the same precedence, and no longer hard-require a config/ copy that a
fresh clone does not have.

Verified: with EIA_API_KEY set, the merged config carries the environment
value; unset, it carries the yaml value (null on a bare checkout).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Config typos and out-of-range values had no early failure mode: an unknown
key just sat in the merged dict while the rule that wanted it fell back to
its `config_provider(..., default=...)` value, so the run completed with
quietly wrong numbers.

workflow/schemas/config.schema.yaml is checked by snakemake.utils.validate
right after the configfile chain merges. Scope is deliberately partial:

  * the TOP LEVEL stays open - snakemake, the scenarios feature and user
    configs all inject keys there, and closing it would make legitimate
    extensions fatal;
  * well-bounded subtrees are closed (electricity, model_topology, run,
    scenario, clustering, solving, costs, conventional, lines, links, co2,
    dac, custom_files, snapshots, renewable_snapshots), which is where typos
    actually land;
  * enum knobs are enumerated from the inline comments in the configs;
  * set_default=False, and the schema declares no defaults, so it cannot
    become a second invisible source of configuration.

The godeeep conditional requires renewable_land_access to be DECLARED and, if
set, to be one of reference|limited|open. It cannot also require non-null:
config.common.yaml ships it null while selecting godeeep, which
build_renewable_profiles rejects at runtime - a pre-existing defect that is
out of scope here and deliberately left for its own change.

Verified: all five shipped configs validate; `electricity: retirment:` and
each bad enum value fail with a clear ValidationError and exit 1. Tier A
gains tests/static/test_config_schema.py, which replays the merge and the
validation without spawning snakemake.

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

Bare nulls in the config read as "unfinished" but several of them are
meaningful, and blanket-converting them to {} would change behaviour. Each
was checked against its consumer:

  model_topology.include  STAYS NULL. build_base_network reads "not None" as
      "a footprint filter was requested" and then asserts topological_boundaries
      != 'state'; {} would trip that assertion on every state-resolution run.
  focus_weights           STAYS NULL. cluster_network branches on `is not None`,
      so {} would take the custom-weights path, re-normalise every region's
      factor and log a misleading warning.
  costs.max_growth        stays null; {} is equivalent (the consumer
      short-circuits on None and would otherwise iterate nothing), so there is
      nothing to gain by changing it.
  model_topology.aggregate  BECOMES {}. Its only consumer is a truthiness check
      in cluster_network (the parameter is not even referenced inside
      convert_to_transport), so {} and None behave identically - and {} is what
      config.test.yaml and both equivalence configs merged to before
      config.default.yaml sat underneath them, because snakemake's
      update_config maps an empty mapping onto a null parent as null.

Net effect on the merged config: config.test.yaml and both equivalence configs
go back to their pre-refactor value ({}), and config.default.yaml/tutorial move
from None to {} with identical behaviour.

Also documents, without changing, that config.common.yaml ships
renewable_land_access null while selecting the godeeep dataset -
build_renewable_profiles rejects that combination, so the shipped templates
cannot build profiles until the user picks a value. Choosing one here would add
the NREL exclusion artifacts to every DAG and move the numbers, so it is left
for its own change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Formatting only, plus @pytest.mark.fast on
test_init_copies_only_user_owned_files and
test_snakefile_reads_layered_base_from_repo_data so CI's `pytest -m fast`
job actually runs them - without the marker they would never execute in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The working tree often sits on master, which lags develop badly; an
agent analyzing "current state" from it recently reported config issues
develop had already fixed. Make the branch policy explicit: all model
work — changes, reviews, audits, analysis — must reference
origin/develop, and feature branches/PRs base on develop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reorganization commits updated the prose pages but left three
statements behind that the new loading order makes false:

- The configtables still pointed the five `electricity:` policy-CSV
  paths (and the `REM` opts row) at `config/policy_constraints/`. The
  rules now read those CSVs straight out of
  `repo_data/config/policy_constraints/`; there is no copy step.
- about-usage.md's troubleshooting note claimed every invocation must
  pass `--configfile` and that omitting it dies with
  `KeyError: 'scenario'`. `config.default.yaml` is now a loaded layer,
  so a bare `snakemake -n` resolves and builds the shipped defaults
  (verified by dry-run). Replaced with what actually happens.
- The same page told users the `--configfile` in run_slurm.sh defaults
  to `config/config.default.yaml`; the script has always shipped a
  personal path. Stated it as the example it is, and named the
  `--cluster-config config/config.slurm.yaml` argument next to it.

Also completes two things the sweep asked for and the pages only half
said: about-usage.md now spells out the two ways to author a run (copy
the seeded default, or write a bare overlay) and the wholesale-list
replacement rule; config-configuration.md notes that $EIA_API_KEY wins
over config/config.api.yaml. release-notes.md gains the four
user-visible migration items that were missing from a section that
promises to list them: the config.slurm.yaml rename, sparse overlays,
schema validation at parse time, and the API-key environment variable.

Nothing else in docs/source needed changing: the per-section YAML is
pulled in by literalinclude, so the in-file comments documenting the
load-bearing nulls render on their own, and no configtable attributes a
key to a file that the one-file-per-key split moved.

pytest tests/docs: 40 passed (unchanged). Sphinx -n build: no warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shipped templates carried renewable.dataset: godeeep with
renewable_land_access null, a pair build_renewable_profiles hard-rejects
- so no shipped config could build renewable profiles from a clean
checkout. Same drift class as #793: the maintainer's untracked config
was papering over a broken tracked default. Set 'reference', NREL's
central land-access assumption and the value the equivalence harness
validates against (both equivalence configs already pin it, so their
merged configs are unchanged). Adds retrieve_nrel_exclusion_artifact
jobs to every godeeep DAG (6 for the tutorial).

With a working default in place, tighten the schema's godeeep
conditional to require non-null (null stays valid for dataset: atlite).

This went unnoticed because CI's e2e job caches data/cutouts at the
repo root while tests/integration/conftest.py checks workflow/data and
workflow/cutouts, so Tier B silently skipped every run. Point the cache
at the workflow/ paths and bump DATA_CACHE_NUMBER so stale root-level
archives are not restored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Update the nrel_exclusion configtable (default column, null now
atlite-only, correct the restrictiveness ordering: open > reference >
limited) and add the new default to the release-notes migration list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ktehranchi ktehranchi closed this Aug 29, 2026
@ktehranchi ktehranchi reopened this Aug 29, 2026
The repo-root conftest auto-marks every workflow/scripts/test/ test as
'fast', and those tests solve with GLPK - a system binary the pip-only
fast-tests job never had. All 13 Tier A CI failures on re-enabling the
workflow were 'Solver glpk not installed' (or its optimize-status
variant); they pass locally where glpsol exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ktehranchi and others added 2 commits August 31, 2026 16:04
…organization

Conflicts were two comment blocks in config.default.yaml where the
reorg's one-owning-file pointer notes met #806's new registry prose;
resolved by combining both texts, keeping renewable.dataset owned by
config.common.yaml. The registry block itself and the _125m wind-height
change auto-merged into common.yaml alongside the reference land-access
default. Also declares the new top-level godeeep_cf_registry key in the
config schema (permissive - the registry module does its own strict
validation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ktehranchi
ktehranchi merged commit 51e1719 into develop Aug 31, 2026
2 of 4 checks passed
ktehranchi added a commit that referenced this pull request Aug 31, 2026
Adapts the California/SERVM feature set to the auto-loaded layered base
and schema validation from #794:

- schema: admit our config surface in the closed sections —
  run.benchmark_cpuc / benchmark_cpuc_horizons;
  electricity.honor_planned_retirements, remote_contracted_resources,
  demand.profile enum += servm, demand.scenario.servm_weather_years;
  conventional.ambient_derate.
- config.california.yaml and config.test.california.yaml rewritten as
  SPARSE OVERLAYS (programmatic deep-diff against the merged base); the
  test overlay drops its now-redundant solving/costs/land-access blocks
  and its stale pre-fix clustering strategies, inheriting the corrected
  defaults (start_up_cost: sum, capacity-weighted ramps).
- policy_constraints paths follow the tracked repo_data/config/ layout
  (solve rules' interface_limits input, configtables, conftest, docs).
- test_dag_dryrun: their repo_data paths + our California cases (incl.
  county-mode override) as a union.
- configtables: their reference-default land-access row (ordering now
  consistent); our un-deadified transmission_interface_limits
  description kept, path-updated.

Verified: both CA overlays parse against the schema (35-job / 26-job
data_model DAGs); fast tier 350 passed, docs 41 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant