Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,6 @@ Write and reason in English.

1. Read relevant source files and matching tests before editing.
2. Make the smallest change that satisfies the request.
3. Add or update tests when behavior changes.
4. After each update, run both commands:
- `make hooks`
- `make test`
5. If either command fails, fix the issues and rerun both commands.
6. Do not finalize work until both commands pass.

## Code Conventions

Expand All @@ -26,7 +20,6 @@ Write and reason in English.
## Testing Conventions

- Place tests in `tests/` near the relevant domain file.
- For bug fixes, add a regression test first whenever practical.
- Keep tests deterministic and lightweight unless a larger benchmark is explicitly requested.

## Data and Artifacts
Expand All @@ -40,6 +33,4 @@ Write and reason in English.
- Make sure you have the necessary permissions to push to the repository. If you do not have permissions, stop and ask for them, guiding the user to the appropriate process to gain access.
- You can add, commit and push changes to this repository. Never commit to 'main' or 'swopp' branches directly.
- If you are on 'main' or 'swopp', create a new branch for your changes and open a pull request for review.
- Create tests before implementing new features or fixing bugs. Tests should be in the `tests/` directory and follow existing patterns.
- Make sure to run all tests and hooks before pushing your changes. If you encounter any issues, please fix them before pushing.
- Do small commits, preferably one per logical change. This makes it easier to review and understand the history of changes.
145 changes: 145 additions & 0 deletions routetools/analysis_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Shared configuration helpers for SWOPP3 analysis.

This module provides the stable, importable API used by both
``scripts/swopp3_analysis.py`` and its test suite. Keeping these helpers
in a proper library module avoids the fragile ``importlib`` pattern that
would otherwise be needed to test them.
"""

from __future__ import annotations

import tomllib
from dataclasses import dataclass
from functools import cache
from pathlib import Path


@dataclass(frozen=True)
class AnalysisPaths:
"""Filesystem locations used by the SWOPP3 analysis script."""

output_dir: Path
figs_dir: Path
config_path: Path


# ---------------------------------------------------------------------------
# Experiment registry — all known experiments across all profiles
# ---------------------------------------------------------------------------
EXPERIMENTS_REGISTRY: dict[str, dict] = {
# ── No-penalty profile (four-experiment) ────────────────────────────
"no_penalty": {
"folder": "swopp3_no_penalty",
"label": "CMA-ES",
"short": "No Penalty",
"color": "#F23333", # IE law red — unconstrained
"color_light": "#FF9B9B",
"hatch": "",
"order": 1,
},
"no_penalty_fms": {
"folder": "swopp3_no_penalty_fms",
"label": "CMA-ES + FMS",
"short": "No Penalty + FMS",
"color": "#007A3D", # emerald green — high contrast with red
"color_light": "#5CC28A",
"hatch": "///",
"order": 2,
},
"penalty": {
"folder": "swopp3_penalty",
"label": "CMA-ES + Penalty",
"short": "Penalty",
"color": "#000066", # IE primary ocean-blue — constrained
"color_light": "#6080CC",
"hatch": "",
"order": 3,
},
"penalty_fms": {
"folder": "swopp3_penalty_fms",
"label": "CMA-ES + Penalty + FMS",
"short": "Penalty + FMS",
"color": "#E09400", # amber — high contrast with dark navy
"color_light": "#FFCC66",
"hatch": "///",
"order": 4,
},
# ── Sweep-combined profile (two-experiment) ──────────────────────────
"sweep_combined": {
"folder": "sweep_combined",
"label": "CMA-ES",
"short": "Sweep Combined",
"color": "#F23333", # IE law red — unconstrained
"color_light": "#FF9B9B",
"hatch": "",
"order": 1,
},
"sweep_combined_fms": {
"folder": "sweep_combined_fms",
"label": "CMA-ES + FMS",
"short": "Sweep Combined + FMS",
"color": "#007A3D", # emerald green — high contrast with red
"color_light": "#5CC28A",
"hatch": "///",
"order": 2,
},
"sweep_combined_fms_strict": {
"folder": "sweep_combined_fms_strict",
"label": "CMA-ES + FMS (strict)",
"short": "Sweep Combined + FMS Strict",
"color": "#0097DC", # IE business blue
"color_light": "#7FCCEE",
"hatch": "///",
"order": 3,
},
}


@cache
def _configured_output_dirs(config_path: Path) -> dict[str, str]:
"""Return output-folder names declared in the SWOPP3 config file."""
if not config_path.exists():
return {}

with config_path.open("rb") as handle:
config = tomllib.load(handle)

experiments = config.get("swopp3", {}).get("experiments", {})
output_dirs: dict[str, str] = {}
for experiment_name, experiment_config in experiments.items():
output_dir = experiment_config.get("output_dir")
if isinstance(output_dir, str) and output_dir:
output_dirs[experiment_name] = Path(output_dir).name
return output_dirs


def _experiment_folder(exp_key: str, paths: AnalysisPaths) -> str:
"""Return the folder name for one analysis experiment.

Prefer config-driven folder names when the merged SWOPP3 experiment config
defines a matching output directory. Keep the legacy folder names as a
fallback so older result folders remain readable.
"""
metadata = EXPERIMENTS_REGISTRY[exp_key]
configured_dirs = _configured_output_dirs(paths.config_path)
candidates: list[str] = []

config_experiment = metadata.get("config_experiment")
if isinstance(config_experiment, str):
configured = configured_dirs.get(config_experiment)
if configured is not None:
candidates.append(configured)

config_parent = metadata.get("config_parent")
if isinstance(config_parent, str):
configured_parent = configured_dirs.get(config_parent)
if configured_parent is not None:
candidates.append(f"{configured_parent}_fms")

legacy_folder = str(metadata["folder"])
candidates.append(legacy_folder)

for candidate in candidates:
if (paths.output_dir / candidate).exists():
return candidate
return candidates[0]
21 changes: 21 additions & 0 deletions routetools/era5/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from __future__ import annotations

import logging
import re
from collections.abc import Callable, Sequence
from datetime import UTC, datetime
from math import ceil
Expand All @@ -38,6 +39,10 @@

logger = logging.getLogger(__name__)

_ERA5_FILE_RE = re.compile(
r"^(?P<prefix>era5_[^_]+_[^_]+_)(?P<year>\d{4})(?:_(?P<suffix>\d{2}(?:-\d{2})?))?\.nc$"
)


# ── helpers ───────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -76,6 +81,22 @@ def _load_dataset(path: str | Path) -> xr.Dataset:
) from last_exc


def loadable_era5_paths(path: Path) -> list[Path]:
"""Return the base ERA5 file plus any next-year continuation files."""
match = _ERA5_FILE_RE.match(path.name)
if match is None:
return [path]

prefix = match.group("prefix")
next_year = int(match.group("year")) + 1
exact_next_year = path.with_name(f"{prefix}{next_year}.nc")
if exact_next_year.exists():
return [path, exact_next_year]

continuation_paths = sorted(path.parent.glob(f"{prefix}{next_year}_*.nc"))
Comment thread
daniprec marked this conversation as resolved.
return [path, *continuation_paths]


def _normalize_time_coord(ds: xr.Dataset) -> xr.Dataset:
"""Rename the time dimension to ``valid_time`` if it is called ``time``.

Expand Down
Loading