From 66192104d8163c608fe0366b0d1562c595b92698 Mon Sep 17 00:00:00 2001 From: daniprec Date: Fri, 22 May 2026 16:49:40 +0200 Subject: [PATCH 1/4] Add SWOPP3 analysis and routing workflow updates --- .github/copilot-instructions.md | 36 + .github/workflows/pre-commit-pr.yml | 10 +- .gitignore | 14 + README.md | 170 +- codabench/README.md | 121 + codabench/build_bundle.sh | 92 + codabench/competition.yaml | 105 + codabench/compute_worker_patched.py | 1293 ++++++++++ codabench/logo.png | Bin 0 -> 223127 bytes codabench/pages/data.html | 283 +++ codabench/pages/data.md | 175 ++ codabench/pages/evaluation.html | 96 + codabench/pages/evaluation.md | 70 + codabench/pages/overview.html | 167 ++ codabench/pages/overview.md | 72 + codabench/pages/submission.html | 173 ++ codabench/pages/submission.md | 112 + codabench/pages/terms.html | 41 + codabench/pages/terms.md | 26 + codabench/reference_data/config.json | 4 + codabench/scoring_program/Dockerfile | 10 + codabench/scoring_program/metadata.yaml | 2 + codabench/scoring_program/requirements.txt | 5 + codabench/scoring_program/scoring.py | 1624 +++++++++++++ codabench/starting_kit/starting_kit.py | 244 ++ config.toml | 470 +++- config_land.toml | 2 +- config_noland.toml | 2 +- docs/cmaes_diagnostic.md | 280 +++ docs/parametric_model.md | 395 +++ docs/parametric_model.pdf | Bin 0 -> 97067 bytes pyproject.toml | 73 +- routetools/_cost/haversine.py | 118 + routetools/_ports.py | 115 + routetools/analysis_config.py | 145 ++ routetools/benchmark.py | 61 +- routetools/circumnavigate.py | 199 ++ routetools/cmaes.py | 375 ++- routetools/cost.py | 465 +++- routetools/era5/__init__.py | 37 + routetools/era5/download_cds.py | 263 ++ routetools/era5/download_gcs.py | 467 ++++ routetools/era5/loader.py | 995 ++++++++ routetools/fms.py | 901 ++++++- routetools/land.py | 68 + routetools/performance.py | 370 +++ routetools/swopp3.py | 256 ++ routetools/swopp3_output.py | 344 +++ routetools/swopp3_runner.py | 575 +++++ routetools/swopp3_validate.py | 466 ++++ routetools/violations.py | 611 +++++ routetools/weather.py | 548 +++++ routetools/wrr_bench/__init__.py | 23 + routetools/wrr_bench/dataset.py | 97 + routetools/wrr_bench/interpolate.py | 161 ++ routetools/wrr_bench/load.py | 208 ++ routetools/wrr_bench/ocean.py | 561 +++++ routetools/wrr_bench/polygons.py | 213 ++ scripts/compare_era5_sources.py | 403 ++++ scripts/compare_scorers.py | 342 +++ scripts/download_era5.py | 161 ++ scripts/era5_benchmark.py | 218 ++ scripts/parametric_benchmark.py | 328 +++ scripts/realworld/figures.py | 2 +- scripts/realworld/results.py | 4 +- scripts/results_land_avoidance.py | 2 +- scripts/run_fms_sweep_combined.sh | 107 + scripts/run_fms_sweep_combined_strict.sh | 112 + scripts/single_run.py | 2 +- scripts/sweep_ww_analysis.py | 317 +++ scripts/swopp3_analysis.py | 2525 ++++++++++++++++++++ scripts/swopp3_apply_fms.py | 753 ++++++ scripts/swopp3_plot_routes.py | 611 +++++ scripts/swopp3_run.py | 743 ++++++ scripts/swopp3_slurm.sh | 85 + scripts/swopp3_slurm_atlantic_k10.sh | 104 + scripts/swopp3_slurm_gpu.sh | 96 + scripts/swopp3_slurm_hard_penalty.sh | 103 + scripts/swopp3_slurm_k15_popsize400.sh | 103 + scripts/swopp3_slurm_k15_sweep.sh | 112 + scripts/swopp3_slurm_max_penalty_sweep.sh | 105 + scripts/swopp3_slurm_no_penalty.sh | 98 + scripts/swopp3_slurm_optimal_params.sh | 117 + scripts/swopp3_slurm_pacific_k15_p400.sh | 103 + scripts/swopp3_slurm_penalty_sweep.sh | 111 + scripts/swopp3_slurm_split_penalty.sh | 107 + scripts/swopp3_slurm_wind_wave_sweep.sh | 133 ++ scripts/swopp_demo.py | 108 + scripts/validate_routes.py | 312 +++ tests/test_benchmark_load.py | 225 ++ tests/test_cmaes.py | 229 ++ tests/test_compare_era5_sources.py | 81 + tests/test_cost.py | 46 + tests/test_era5.py | 617 +++++ tests/test_era5_loader_helpers.py | 131 + tests/test_fms.py | 309 +++ tests/test_land.py | 53 + tests/test_land_waves.py | 63 + tests/test_parametric_model.py | 618 +++++ tests/test_resample_track.py | 773 ++++++ tests/test_swopp3.py | 275 +++ tests/test_swopp3_analysis.py | 181 ++ tests/test_swopp3_output.py | 253 ++ tests/test_swopp3_run.py | 352 +++ tests/test_swopp3_runner.py | 436 ++++ tests/test_swopp3_validate.py | 313 +++ tests/test_validate_routes.py | 144 ++ tests/test_violations.py | 234 ++ tests/test_weather.py | 569 +++++ uv.lock | 1514 +++++------- 110 files changed, 29389 insertions(+), 1263 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 codabench/README.md create mode 100755 codabench/build_bundle.sh create mode 100644 codabench/competition.yaml create mode 100644 codabench/compute_worker_patched.py create mode 100644 codabench/logo.png create mode 100644 codabench/pages/data.html create mode 100644 codabench/pages/data.md create mode 100644 codabench/pages/evaluation.html create mode 100644 codabench/pages/evaluation.md create mode 100644 codabench/pages/overview.html create mode 100644 codabench/pages/overview.md create mode 100644 codabench/pages/submission.html create mode 100644 codabench/pages/submission.md create mode 100644 codabench/pages/terms.html create mode 100644 codabench/pages/terms.md create mode 100644 codabench/reference_data/config.json create mode 100644 codabench/scoring_program/Dockerfile create mode 100644 codabench/scoring_program/metadata.yaml create mode 100644 codabench/scoring_program/requirements.txt create mode 100755 codabench/scoring_program/scoring.py create mode 100755 codabench/starting_kit/starting_kit.py create mode 100644 docs/cmaes_diagnostic.md create mode 100644 docs/parametric_model.md create mode 100644 docs/parametric_model.pdf create mode 100644 routetools/_ports.py create mode 100644 routetools/analysis_config.py create mode 100644 routetools/circumnavigate.py create mode 100644 routetools/era5/__init__.py create mode 100644 routetools/era5/download_cds.py create mode 100644 routetools/era5/download_gcs.py create mode 100644 routetools/era5/loader.py create mode 100644 routetools/performance.py create mode 100644 routetools/swopp3.py create mode 100644 routetools/swopp3_output.py create mode 100644 routetools/swopp3_runner.py create mode 100644 routetools/swopp3_validate.py create mode 100644 routetools/violations.py create mode 100644 routetools/weather.py create mode 100644 routetools/wrr_bench/__init__.py create mode 100644 routetools/wrr_bench/dataset.py create mode 100644 routetools/wrr_bench/interpolate.py create mode 100644 routetools/wrr_bench/load.py create mode 100644 routetools/wrr_bench/ocean.py create mode 100644 routetools/wrr_bench/polygons.py create mode 100755 scripts/compare_era5_sources.py create mode 100755 scripts/compare_scorers.py create mode 100755 scripts/download_era5.py create mode 100755 scripts/era5_benchmark.py create mode 100755 scripts/parametric_benchmark.py create mode 100755 scripts/run_fms_sweep_combined.sh create mode 100755 scripts/run_fms_sweep_combined_strict.sh create mode 100755 scripts/sweep_ww_analysis.py create mode 100755 scripts/swopp3_analysis.py create mode 100755 scripts/swopp3_apply_fms.py create mode 100755 scripts/swopp3_plot_routes.py create mode 100755 scripts/swopp3_run.py create mode 100755 scripts/swopp3_slurm.sh create mode 100755 scripts/swopp3_slurm_atlantic_k10.sh create mode 100755 scripts/swopp3_slurm_gpu.sh create mode 100755 scripts/swopp3_slurm_hard_penalty.sh create mode 100755 scripts/swopp3_slurm_k15_popsize400.sh create mode 100755 scripts/swopp3_slurm_k15_sweep.sh create mode 100755 scripts/swopp3_slurm_max_penalty_sweep.sh create mode 100755 scripts/swopp3_slurm_no_penalty.sh create mode 100755 scripts/swopp3_slurm_optimal_params.sh create mode 100755 scripts/swopp3_slurm_pacific_k15_p400.sh create mode 100755 scripts/swopp3_slurm_penalty_sweep.sh create mode 100755 scripts/swopp3_slurm_split_penalty.sh create mode 100755 scripts/swopp3_slurm_wind_wave_sweep.sh create mode 100644 scripts/swopp_demo.py create mode 100755 scripts/validate_routes.py create mode 100644 tests/test_benchmark_load.py create mode 100644 tests/test_compare_era5_sources.py create mode 100644 tests/test_era5.py create mode 100644 tests/test_era5_loader_helpers.py create mode 100644 tests/test_fms.py create mode 100644 tests/test_land_waves.py create mode 100644 tests/test_parametric_model.py create mode 100644 tests/test_resample_track.py create mode 100644 tests/test_swopp3.py create mode 100644 tests/test_swopp3_analysis.py create mode 100644 tests/test_swopp3_output.py create mode 100644 tests/test_swopp3_run.py create mode 100644 tests/test_swopp3_runner.py create mode 100644 tests/test_swopp3_validate.py create mode 100644 tests/test_validate_routes.py create mode 100644 tests/test_violations.py create mode 100644 tests/test_weather.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..aba5dd97 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,36 @@ +# Copilot Instructions for routetools + +This repository implements weather-routing optimization with JAX and CMA-ES. Keep changes focused, reproducible, and validated. + +Write and reason in English. + +## Required Workflow + +1. Read relevant source files and matching tests before editing. +2. Make the smallest change that satisfies the request. + +## Code Conventions + +- Use the repository toolchain (`uv` via `make` targets). +- Preserve existing public APIs unless the request explicitly requires a breaking change. +- Follow `ruff` and `pytest` settings in `pyproject.toml`. +- Keep docstrings in NumPy style for public functions. +- Prefer vectorized/JAX-friendly implementations in performance-sensitive code paths. + +## Testing Conventions + +- Place tests in `tests/` near the relevant domain file. +- Keep tests deterministic and lightweight unless a larger benchmark is explicitly requested. + +## Data and Artifacts + +- Do not commit large generated outputs. +- Treat `data/` contents as potentially large and optional in local environments. +- Fail with clear error messages when optional datasets are missing. + +## Permissions + +- 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. +- Do small commits, preferably one per logical change. This makes it easier to review and understand the history of changes. diff --git a/.github/workflows/pre-commit-pr.yml b/.github/workflows/pre-commit-pr.yml index a53dff2d..da94a8e8 100644 --- a/.github/workflows/pre-commit-pr.yml +++ b/.github/workflows/pre-commit-pr.yml @@ -1,14 +1,14 @@ -name: pre-commit-pr +name: post-commit-push on: - pull_request: + push: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - pre-commit: + post-commit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -30,5 +30,5 @@ jobs: uv pip install pip - uses: pre-commit/action@v3.0.1 with: - # Ejecutar solo en ficheros que hayan cambiado https://github.com/pre-commit/action/issues/7 - extra_args: --color=always --from-ref ${{ github.event.pull_request.base.sha }} --to-ref ${{ github.event.pull_request.head.sha }} + # Run hooks after commits are pushed + extra_args: --color=always --all-files --hook-stage post-commit diff --git a/.gitignore b/.gitignore index 30186be7..5abe4bfc 100644 --- a/.gitignore +++ b/.gitignore @@ -167,3 +167,17 @@ output* # User files nohup.out *.zip +output/ + +# CodaBench competition — large data & build artifacts +codabench/reference_data/*.nc +codabench/reference_data/*.shp +codabench/reference_data/*.shx +codabench/reference_data/*.dbf +codabench/reference_data/*.prj +codabench/logo/ +codabench/test_submission/ + +# Separate repo +routingviz/ +*.whl diff --git a/README.md b/README.md index cd9cab29..f33afd78 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@ Tools: - [prettier](https://prettier.io/): format YAML and Markdown - [codespell](https://github.com/codespell-project/codespell): check spelling in source code +## Documentation + +- [Parametric Performance Model](docs/parametric_model.md) — closed-form RISE model for ship power prediction (hull, wind, wave, and wingsail components). + ## Installation ### Application @@ -38,7 +42,18 @@ Install package and pinned dependencies with the [`uv`](https://docs.astral.sh/u uv sync ``` -4. Run any command or Python script with `uv run`, for instance: +4. (Optional) Install the SWOPP3 performance model: + + ```{bash} + uv sync --extra swopp3 --find-links release_package/wheels + ``` + + If the pre-built wheels are available locally in `release_package/wheels/`, + `uv` will resolve `swopp3-performance-model` from that directory. Wheels are + available for Python 3.10 – 3.13 on Linux (manylinux) and Windows + (win_amd64). + +5. Run any command or Python script with `uv run`, for instance: ```{bash} uv run routetools/cmaes.py @@ -50,63 +65,6 @@ Install package and pinned dependencies with the [`uv`](https://docs.astral.sh/u source .venv/bin/activate ``` -### Git credentials for VCS dependencies - -When `uv` installs a package from a git repository (VCS dependency), Git may need credentials to fetch the remote. On non-interactive environments this commonly fails with: - -```bash -fatal: could not read Username for 'https://github.com': terminal prompts disabled -``` - -Use one of the following approaches to make VCS fetches non-interactive. - -**Option A: SSH (preferred)** - -Generate an SSH key (WSL / Linux): - -```bash -ssh-keygen -t ed25519 -C "your_email@example.com" -f ~/.ssh/id_ed25519 -N "" -eval "$(ssh-agent -s)" -ssh-add ~/.ssh/id_ed25519 -cat ~/.ssh/id_ed25519.pub -``` - -Add the printed public key to GitHub (Settings → SSH and GPG keys). Test access: - -```bash -ssh -T git@github.com -git ls-remote git@github.com:Weather-Routing-Research/weather-routing-benchmarks.git refs/heads/main -``` - -Then use an SSH pip URL when adding or declaring the dependency: - -```bash -uv add 'git+ssh://git@github.com/Weather-Routing-Research/weather-routing-benchmarks.git@main#egg=wrr_bench' -uv sync -``` - -If you run `uv` from PowerShell on Windows, ensure the Windows SSH agent is running and the key is loaded (Start-Service ssh-agent; ssh-add $env:USERPROFILE\\.ssh\\id_ed25519), or run `uv` from WSL where the key was created. - -**Option B: HTTPS with credentials (fallback)** - -Use Git Credential Manager or GitHub CLI to cache credentials so Git won't prompt: - -PowerShell (Windows): - -```powershell -git config --global credential.helper manager-core -gh auth login --hostname github.com --git-protocol https -``` - -WSL / Linux (use gh or configure a credential helper that works in your environment): - -```bash -gh auth login --hostname github.com --git-protocol https -# or configure `git config --global credential.helper cache` for short-term caching -``` - -After configuring credentials, retry the `uv add` / `uv sync` command. - ### Library Install a specific version of the package with `pip` or `uv pip`: @@ -157,6 +115,102 @@ If your computer does not have a GPU, you can force JAX to use the CPU with `JAX JAX_PLATFORMS=cpu uv run scripts/single_run.py ``` +### ERA5 weather data pipeline + +The `routetools.era5` module provides real-world ERA5 wind and wave fields +for weather routing. Two download backends are available: + +- **GCS** (default) — Google Cloud archive, no API key required. +- **CDS** — Copernicus Climate Data Store (requires `cdsapi` + API key). + +**1. Download ERA5 data** for the Atlantic corridor (USNYC ↔ DEHAM): + +```bash +uv run scripts/download_era5.py --corridor atlantic --year 2023 +``` + +This creates `data/era5/era5_wind_atlantic_2023.nc` and +`data/era5/era5_waves_atlantic_2023.nc`. + +**2. Run the real-world benchmark** (New York → Hamburg, Jan 8 2023): + +```bash +uv run scripts/era5_benchmark.py --departure 2023-01-08T00:00:00 +``` + +See `scripts/download_era5.py --help` and `scripts/era5_benchmark.py --help` +for all available options. + +### SWOPP3 ERA5 pipeline + +The default SWOPP3 competition pipeline is: + +```bash +uv run scripts/download_era5.py +uv run scripts/swopp3_run.py +``` + +These two commands line up without extra path flags. The downloader writes the +four default 2024 files that `scripts/swopp3_run.py` expects: + +```text +data/era5/era5_wind_atlantic_2024.nc +data/era5/era5_waves_atlantic_2024.nc +data/era5/era5_wind_pacific_2024.nc +data/era5/era5_waves_pacific_2024.nc +``` + +`scripts/swopp3_run.py` validates these files before running any case. If one +or more inputs are missing, it exits immediately with a precise error message +instead of silently substituting a great-circle route or running without +weather data. This is intentional: + +- GC cases still require wind and wave data for SWOPP3 energy evaluation. +- Optimised cases require wind data for the CMA-ES vectorfield and wind/wave + data for the final SWOPP3 energy evaluation. + +If you download a different year or only one corridor, pass matching +`--wind-path*` and `--wave-path*` options to `scripts/swopp3_run.py`. + +### Reproducible SWOPP3 experiment profiles + +`scripts/swopp3_run.py` supports named experiment profiles stored in +`config.toml`. + +Run a named experiment: + +```bash +uv run scripts/swopp3_run.py k15_p400_w1000 +``` + +Use another TOML file if needed: + +```bash +uv run scripts/swopp3_run.py k15_p400_w1000 --config-path path/to/experiments.toml +``` + +Relative paths inside a profile are resolved from the directory that contains +the TOML file, not from your current working directory. + +Each profile can define shared defaults plus one or more runs. + +The runner writes a resolved manifest to: + +```text +output//experiment_manifest.json +``` + +This records the experiment name, config file, source script, and resolved run +parameters used for the launch. + +To add a new experiment: + +1. Add a new `[swopp3.experiments.]` section to `config.toml`. +2. Put shared parameters under `[swopp3.experiments..defaults]`. +3. Add one or more `[[swopp3.experiments..runs]]` entries. +4. Set `source_script` to the script or workflow the profile replaces. +5. Run `uv run scripts/swopp3_run.py `. + ## Reproduce the results (paper) To reproduce the results from the paper, run the following command: diff --git a/codabench/README.md b/codabench/README.md new file mode 100644 index 00000000..9140f5d5 --- /dev/null +++ b/codabench/README.md @@ -0,0 +1,121 @@ +# SWOPP3 Weather Routing Benchmark — CodaBench Setup + +This directory contains everything needed to host the SWOPP3 Weather Routing +Benchmark on [CodaBench](https://www.codabench.org/). + +## Directory Structure + +``` +codabench/ +├── README.md ← You are here +├── competition.yaml ← Competition configuration (title, phases, leaderboard) +├── scoring_program/ +│ ├── scoring.py ← Evaluates submissions (validation + scoring) +│ └── metadata.yaml ← CodaBench scoring program metadata +├── pages/ +│ ├── overview.md ← Competition description +│ ├── data.md ← Data download & API instructions +│ ├── submission.md ← Submission format specification +│ ├── evaluation.md ← How submissions are scored +│ └── terms.md ← Terms and conditions +├── starting_kit/ +│ └── starting_kit.py ← Great-circle baseline (example submission) +└── logo.png ← Competition logo +``` + +## Step-by-Step Setup + +### 1. Create the Competition Bundle + +CodaBench requires a `.zip` bundle containing the competition definition. +Build it from this directory: + +```bash +cd codabench +bash build_bundle.sh +``` + +This creates `scoring_program.zip`, `starting_kit.zip`, `reference_data.zip`, +and a combined `competition_bundle.zip`. See `build_bundle.sh` for details. + +### 2. Create the Competition on CodaBench + +1. Go to **[codabench.org](https://www.codabench.org/)** and sign in. +2. Click **Benchmarks/Competitions** → **Create**. +3. Fill in the **Details** tab: + - **Title:** `SWOPP3 Weather Routing Benchmark` + - **Logo:** Upload a logo (PNG) + - **Description:** Copy from `pages/overview.md` or write a summary + - **Competition Docker Image:** Use `fjsuarez/swopp3-scorer:latest` + - **Competition Type:** Competition +4. **Pages** tab: + - Add pages from the `pages/` directory (Overview, Data, Submission, Evaluation, Terms) +5. **Phases** tab: + - Create one phase ("Main Phase") + - Upload `scoring_program.zip` as the Scoring Program + - Upload `reference_data.zip` as the Reference Data + - Set start/end dates, max submissions per day (3), total max (100) +6. **Leaderboard** tab: + - Add columns matching the keys in `competition.yaml` → `leaderboard` + - Primary ranking column: `total_energy_mwh` (ascending) +7. **Publish** the competition. + +### 3. Reference Data + +The `reference_data/` directory must contain the 6-hourly ERA5 NetCDF files +(~3.1 GB total) and the Natural Earth land shapefile before building the +bundle. See `build_bundle.sh` for the full list of required files. + +The scoring program is **self-contained** — it uses only `numpy`, `netCDF4`, +`pyshp`, `shapely`, and `matplotlib` (listed in `scoring_program/requirements.txt`). +No `routetools` or JAX installation is needed on the CodaBench worker. +The Docker image `fjsuarez/swopp3-scorer:latest` has all dependencies pre-installed. + +### 4. Starting Kit + +Upload `starting_kit.zip` to CodaBench so participants can download a working +baseline. The `starting_kit.py` script generates a valid submission using +great-circle routes. + +## Scoring + +The scoring program validates submission format and **re-evaluates every +route** using the RISE performance model with the official ERA5 data from +`reference_data/`. This guarantees all energy values are computed with the +same model and weather data. + +If the ERA5 files are not present in `reference_data/`, the scorer falls back +to self-reported energy values from the participants' CSVs. + +## Testing Locally + +Test the scoring program locally before deploying: + +```bash +# Create a mock submission using the starting kit +cd starting_kit +python starting_kit.py --output-dir /tmp/test_submission + +# Simulate CodaBench's invocation +mkdir -p /tmp/codabench_input/res /tmp/codabench_input/ref /tmp/codabench_output +cp -r /tmp/test_submission/* /tmp/codabench_input/res/ +cp reference_data/config.json /tmp/codabench_input/ref/ + +cd ../scoring_program +python scoring.py /tmp/codabench_input /tmp/codabench_output + +# Check results +cat /tmp/codabench_output/scores.json +cat /tmp/codabench_output/scoring_log.txt +``` + +## Key CodaBench Concepts + +| Concept | Meaning in SWOPP3 | +| ------------------- | ------------------------------------------------------ | +| **Phase** | Single evaluation phase (all 366 departures × 8 cases) | +| **Scoring Program** | `scoring.py` — validates and scores submissions | +| **Reference Data** | ERA5 NetCDF files + Natural Earth shapefile (~3.1 GB) | +| **Input Data** | The submission zip uploaded by participants | +| **Starting Kit** | `starting_kit.py` — great-circle baseline code | +| **Leaderboard** | Ranked by total energy (MWh), lower = better | diff --git a/codabench/build_bundle.sh b/codabench/build_bundle.sh new file mode 100755 index 00000000..2da078fc --- /dev/null +++ b/codabench/build_bundle.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Build CodaBench competition bundle zip files. +# +# Usage: +# cd codabench && bash build_bundle.sh +# +# Creates: +# scoring_program.zip — upload as Scoring Program +# starting_kit.zip — upload as Starting Kit +# reference_data.zip — upload as Reference Data +# competition_bundle.zip — full bundle (alternative upload method) + +set -euo pipefail +cd "$(dirname "$0")" + +echo "Building CodaBench bundles..." + +# Scoring program +echo " → scoring_program.zip" +(cd scoring_program && zip -r ../scoring_program.zip . -x '__pycache__/*' '*.pyc') + +# Starting kit +echo " → starting_kit.zip" +(cd starting_kit && zip -r ../starting_kit.zip . -x '__pycache__/*' '*.pyc') + +# Reference data — ERA5 NetCDF files + Natural Earth land shapefile +# These files must be placed in reference_data/ before building. +# See the list below for required files. +echo " → reference_data.zip" +mkdir -p reference_data + +REQUIRED_FILES=( + # ERA5 weather data (2024) + "era5_wind_atlantic_2024.nc" + "era5_waves_atlantic_2024.nc" + "era5_wind_pacific_2024.nc" + "era5_waves_pacific_2024.nc" + # ERA5 weather data (January 2025, for late-2024 departures) + "era5_wind_atlantic_2025_01.nc" + "era5_waves_atlantic_2025_01.nc" + "era5_wind_pacific_2025_01.nc" + "era5_waves_pacific_2025_01.nc" + # Natural Earth land shapefile (for land crossing checks) + "ne_10m_land.shp" + "ne_10m_land.shx" + "ne_10m_land.dbf" + "ne_10m_land.prj" +) + +MISSING=0 +for f in "${REQUIRED_FILES[@]}"; do + if [[ ! -f "reference_data/$f" ]]; then + echo " ⚠ Missing: reference_data/$f" + MISSING=1 + fi +done +if [[ $MISSING -eq 1 ]]; then + echo "" + echo " Some reference data files are missing." + echo " Place them in codabench/reference_data/ and re-run." + echo " The bundle will be built without them (scoring will use self-reported values)." + echo "" +fi + +(cd reference_data && zip -r ../reference_data.zip . -x '__pycache__/*' '*.pyc') + +# Lightweight bundle (no ERA5 .nc files — upload reference_data.zip separately) +# CodaBench v2 expects directories, not nested zips. +echo " → competition_bundle.zip (lightweight, no ERA5 data)" +rm -f competition_bundle.zip +zip -r competition_bundle.zip \ + competition.yaml \ + logo.png \ + scoring_program/ \ + starting_kit/ \ + reference_data/ \ + pages/ \ + -x '__pycache__/*' '*.pyc' '*.nc' + +echo "" +echo "Done!" +echo "" +echo "Lightweight bundle (for CodaBench upload wizard):" +echo " competition_bundle.zip — everything except ERA5 .nc files" +echo "" +echo "After the competition is created, upload the full reference data:" +echo " reference_data.zip → Edit Competition → Tasks tab → Reference Data" +echo "" +echo "Individual zips (for manual upload via competition editor):" +echo " scoring_program.zip → Tasks tab → Scoring Program" +echo " reference_data.zip → Tasks tab → Reference Data" +echo " starting_kit.zip → Participation tab → Starting Kit" diff --git a/codabench/competition.yaml b/codabench/competition.yaml new file mode 100644 index 00000000..e7329985 --- /dev/null +++ b/codabench/competition.yaml @@ -0,0 +1,105 @@ +# ───────────────────────────────────────────────────────────────────── +# SWOPP3 Weather Routing Benchmark — CodaBench Competition Bundle +# ───────────────────────────────────────────────────────────────────── +# Upload this bundle at https://www.codabench.org/competitions/create/ +# See README.md in this directory for setup instructions. +# ───────────────────────────────────────────────────────────────────── + +version: 2 + +title: "SWOPP3 Weather Routing Benchmark" +description: "Minimize total energy consumption for an 88 m cargo ship across 366 daily departures on two ocean routes (Trans-Atlantic and Trans-Pacific) using ERA5 weather data and the RISE performance model." +image: logo.png +docker_image: "fjsuarez/swopp3-scorer:latest" +registration_auto_approve: true + +# ── Pages (Markdown files in pages/) ───────────────────────────────── +terms: pages/terms.md +pages: + - title: Overview + file: pages/overview.md + - title: Data & Performance Model + file: pages/data.md + - title: Submission Format + file: pages/submission.md + - title: Evaluation + file: pages/evaluation.md + +# ── Tasks ───────────────────────────────────────────────────────────── +tasks: + - index: 0 + name: "SWOPP3 Evaluation" + description: "Evaluate optimised routes against ERA5 weather data using the RISE performance model." + is_public: false + reference_data: reference_data + scoring_program: scoring_program + +# ── Phases ──────────────────────────────────────────────────────────── +phases: + - index: 0 + name: "Main Phase" + description: "Submit optimised routes for all 8 SWOPP3 cases." + start: 4-1-2026 + end: 4-1-2027 + max_submissions_per_day: 3 + max_submissions: 100 + tasks: + - 0 + +# ── Leaderboard ────────────────────────────────────────────────────── +leaderboards: + - index: 0 + title: "Overall" + key: "Results" + columns: + - title: "Total Energy (MWh)" + key: "total_energy_mwh" + index: 0 + sorting: asc + computation: null + computation_indexes: null + + - title: "Atlantic Opt WPS (MWh)" + key: "AO_WPS_energy_mwh" + index: 1 + sorting: asc + + - title: "Atlantic Opt noWPS (MWh)" + key: "AO_noWPS_energy_mwh" + index: 2 + sorting: asc + + - title: "Atlantic GC WPS (MWh)" + key: "AGC_WPS_energy_mwh" + index: 3 + sorting: asc + + - title: "Atlantic GC noWPS (MWh)" + key: "AGC_noWPS_energy_mwh" + index: 4 + sorting: asc + + - title: "Pacific Opt WPS (MWh)" + key: "PO_WPS_energy_mwh" + index: 5 + sorting: asc + + - title: "Pacific Opt noWPS (MWh)" + key: "PO_noWPS_energy_mwh" + index: 6 + sorting: asc + + - title: "Pacific GC WPS (MWh)" + key: "PGC_WPS_energy_mwh" + index: 7 + sorting: asc + + - title: "Pacific GC noWPS (MWh)" + key: "PGC_noWPS_energy_mwh" + index: 8 + sorting: asc + + - title: "Validation Errors" + key: "validation_errors" + index: 9 + sorting: asc diff --git a/codabench/compute_worker_patched.py b/codabench/compute_worker_patched.py new file mode 100644 index 00000000..448cfa25 --- /dev/null +++ b/codabench/compute_worker_patched.py @@ -0,0 +1,1293 @@ +import asyncio +import glob +import hashlib +import json +import os +import shutil +import signal +import socket +import tempfile +import time +import uuid +from shutil import make_archive +from urllib.error import HTTPError +from urllib.parse import urlparse +from urllib.request import urlretrieve +from zipfile import ZipFile, BadZipFile +import docker +from rich.progress import Progress +from rich.pretty import pprint +import requests + +import websockets +import yaml +from billiard.exceptions import SoftTimeLimitExceeded +from celery import Celery, shared_task, utils +from kombu import Queue, Exchange +from urllib3 import Retry + +# This is only needed for the pytests to pass +import sys + +sys.path.append("/app/src/settings/") + +from celery import signals +import logging + +logger = logging.getLogger(__name__) +from logs_loguru import configure_logging, colorize_run_args +import json + + +# ----------------------------------------------- +# Logging +# ----------------------------------------------- +configure_logging( + os.environ.get("LOG_LEVEL", "INFO"), os.environ.get("SERIALIZED", "false") +) + +# ----------------------------------------------- +# Initialize Docker or Podman depending on .env +# ----------------------------------------------- +if os.environ.get("USE_GPU", "false").lower() == "true": + logger.info( + "Using " + + os.environ.get("CONTAINER_ENGINE_EXECUTABLE", "docker").upper() + + "with GPU capabilites : " + + os.environ.get("GPU_DEVICE", "nvidia.com/gpu=all") + ) +else: + logger.info( + "Using " + + os.environ.get("CONTAINER_ENGINE_EXECUTABLE", "docker").upper() + + " without GPU capabilities" + ) + +if os.environ.get("CONTAINER_ENGINE_EXECUTABLE", "docker").lower() == "docker": + client = docker.APIClient( + base_url=os.environ.get("CONTAINER_SOCKET", "unix:///var/run/docker.sock"), + version="auto", + ) +elif os.environ.get("CONTAINER_ENGINE_EXECUTABLE").lower() == "podman": + client = docker.APIClient( + base_url=os.environ.get( + "CONTAINER_SOCKET", "unix:///run/user/1000/podman/podman.sock" + ), + version="auto", + ) + + +# ----------------------------------------------- +# Show Progress bar on downloading images +# ----------------------------------------------- +tasks = {} + + +def show_progress(line, progress): + try: + if "Status: Image is up to date" in line["status"]: + logger.info(line["status"]) + + completed = False + if line["status"] == "Download complete": + description = ( + f"[blue][Download complete, waiting for extraction {line['id']}]" + ) + completed = True + elif line["status"] == "Downloading": + description = f"[bold][Downloading {line['id']}]" + elif line["status"] == "Pull complete": + description = f"[green][Extraction complete {line['id']}]" + completed = True + elif line["status"] == "Extracting": + description = f"[blue][Extracting {line['id']}]" + + else: + # skip other statuses, but show extraction progress + return + + task_id = line["id"] + if task_id not in tasks.keys(): + if completed: + # some layers are really small that they download immediately without showing + # anything as Downloading in the stream. + # For that case, show a completed progress bar + tasks[task_id] = progress.add_task( + description, total=100, completed=100 + ) + else: + tasks[task_id] = progress.add_task( + description, total=line["progressDetail"]["total"] + ) + else: + if completed: + # due to the stream, the Download complete output can happen before the Downloading + # bar outputs the 100%. So when we detect that the download is in fact complete, + # update the progress bar to show 100% + progress.update( + tasks[task_id], description=description, total=100, completed=100 + ) + else: + progress.update( + tasks[task_id], + completed=line["progressDetail"]["current"], + total=line["progressDetail"]["total"], + ) + except Exception as e: + logger.error("There was an error showing the progress bar") + logger.error(e) + + +# ----------------------------------------------- +# Celery + Rabbit MQ +# ----------------------------------------------- +@signals.setup_logging.connect +def setup_celery_logging(**kwargs): + pass + + +# Init celery + rabbit queue definitions +app = Celery() +app.config_from_object("celery_config") # grabs celery_config.py +app.conf.task_queues = [ + # Mostly defining queue here so we can set x-max-priority + Queue( + "compute-worker", + Exchange("compute-worker"), + routing_key="compute-worker", + queue_arguments={"x-max-priority": 10}, + ), +] +# ----------------------------------------------- +# Directories +# ----------------------------------------------- +# Setup base directories used by all submissions +# note: we need to pass this directory to docker/podman so it knows where to store things! +HOST_DIRECTORY = os.environ.get("HOST_DIRECTORY", "/tmp/codabench/") +BASE_DIR = "/codabench/" # base directory inside the container +CACHE_DIR = os.path.join(BASE_DIR, "cache") +MAX_CACHE_DIR_SIZE_GB = float(os.environ.get("MAX_CACHE_DIR_SIZE_GB", 10)) + + +# ----------------------------------------------- +# Submission status +# ----------------------------------------------- +# Status options for submissions +STATUS_NONE = "None" +STATUS_SUBMITTING = "Submitting" +STATUS_SUBMITTED = "Submitted" +STATUS_PREPARING = "Preparing" +STATUS_RUNNING = "Running" +STATUS_SCORING = "Scoring" +STATUS_FINISHED = "Finished" +STATUS_FAILED = "Failed" +AVAILABLE_STATUSES = ( + STATUS_NONE, + STATUS_SUBMITTING, + STATUS_SUBMITTED, + STATUS_PREPARING, + STATUS_RUNNING, + STATUS_SCORING, + STATUS_FINISHED, + STATUS_FAILED, +) + + +# ----------------------------------------------- +# Exceptions +# ----------------------------------------------- +class SubmissionException(Exception): + pass + + +class DockerImagePullException(Exception): + pass + + +class ExecutionTimeLimitExceeded(Exception): + pass + + +# ----------------------------------------------------------------------------- +# The main compute worker entrypoint, this is how a job is ran at the highest +# level. +# ----------------------------------------------------------------------------- +@shared_task(name="compute_worker_run") +def run_wrapper(run_args): + logger.info(f"Received run arguments: \n {colorize_run_args(json.dumps(run_args, default=str))}") + run = Run(run_args) + + try: + run.prepare() + run.start() + if run.is_scoring: + run.push_scores() + run.push_output() + except DockerImagePullException as e: + run._update_status(STATUS_FAILED, str(e)) + except SubmissionException as e: + run._update_status(STATUS_FAILED, str(e)) + except SoftTimeLimitExceeded: + run._update_status(STATUS_FAILED, "Soft time limit exceeded!") + finally: + run.clean_up() + + +def replace_legacy_metadata_command( + command, kind, is_scoring, ingestion_only_during_scoring=False +): + vars_to_replace = [ + ("$input", "/app/input_data" if kind == "ingestion" else "/app/input"), + ("$output", "/app/output"), + ( + "$program", + "/app/ingestion_program" + if ingestion_only_during_scoring and is_scoring + else "/app/program", + ), + ("$ingestion_program", "/app/program"), + ("$hidden", "/app/input/ref"), + ("$shared", "/app/shared"), + ("$submission_program", "/app/ingested_program"), + # for v1.8 compatibility + ("$tmp", "/app/output"), + ("$predictions", "/app/input/res" if is_scoring else "/app/output"), + ] + for var_string, var_replacement in vars_to_replace: + command = command.replace(var_string, var_replacement) + return command + + +def md5(filename): + """Given some file return its md5, works well on large files""" + hash_md5 = hashlib.md5() + with open(filename, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + return hash_md5.hexdigest() + + +def get_folder_size_in_gb(folder): + if not os.path.exists(folder): + return 0 + total_size = os.path.getsize(folder) + for item in os.listdir(folder): + path = os.path.join(folder, item) + if os.path.isfile(path): + total_size += os.path.getsize(path) + elif os.path.isdir(path): + total_size += get_folder_size_in_gb(path) + return total_size / 1000 / 1000 / 1000 # GB: decimal system (1000^3) + + +def delete_files_in_folder(folder): + for filename in os.listdir(folder): + file_path = os.path.join(folder, filename) + if os.path.isfile(file_path) or os.path.islink(file_path): + os.unlink(file_path) + elif os.path.isdir(file_path): + shutil.rmtree(file_path) + + +def is_valid_zip(zip_path): + # Check zip integrity + try: + with ZipFile(zip_path, "r") as zf: + return zf.testzip() is None + except BadZipFile: + return False + + +def alarm_handler(signum, frame): + raise ExecutionTimeLimitExceeded + + +# ----------------------------------------------- +# Class Run +# Responsible for running a submission inside a docker/podman container +# ----------------------------------------------- +class Run: + """A "Run" in Codabench is composed of some program, some data to work with, and some signed URLs to upload results + to. There is also a secret key to do special commands for just this submission. + + Some example API's you can hit using this secret key are: + + push_scores + + (maybe later: + get previous submission + get sibling submission + get top submission + get some different dataset + post results to twitter) + """ + + def __init__(self, run_args): + # Directories for the run + self.watch = True + self.completed_program_counter = 0 + self.root_dir = tempfile.mkdtemp(dir=BASE_DIR) + self.bundle_dir = os.path.join(self.root_dir, "bundles") + self.input_dir = os.path.join(self.root_dir, "input") + self.output_dir = os.path.join(self.root_dir, "output") + self.data_dir = os.path.join( + HOST_DIRECTORY, "data" + ) # absolute path to data in the host + self.logs = {} + + # Details for submission + self.is_scoring = run_args["is_scoring"] + self.user_pk = run_args["user_pk"] + self.submission_id = run_args["id"] + self.submissions_api_url = run_args["submissions_api_url"] + self.container_image = run_args["docker_image"] + self.secret = str(run_args["secret"]) + self.prediction_result = run_args["prediction_result"] + self.scoring_result = run_args.get("scoring_result") + self.execution_time_limit = run_args["execution_time_limit"] + # stdout and stderr + self.stdout, self.stderr, self.ingestion_stdout, self.ingestion_stderr = ( + self._get_stdout_stderr_file_names(run_args) + ) + self.ingestion_container_name = uuid.uuid4() + self.program_container_name = uuid.uuid4() + self.program_data = run_args.get("program_data") + self.ingestion_program_data = run_args.get("ingestion_program") + self.input_data = run_args.get("input_data") + self.reference_data = run_args.get("reference_data") + self.ingestion_only_during_scoring = run_args.get( + "ingestion_only_during_scoring" + ) + self.detailed_results_url = run_args.get("detailed_results_url") + + # During prediction program will be the submission program, during scoring it will be the + # scoring program + self.program_exit_code = None + self.ingestion_program_exit_code = None + + self.program_elapsed_time = None + self.ingestion_elapsed_time = None + + # Socket connection to stream output of submission + submission_api_url_parsed = urlparse(self.submissions_api_url) + websocket_host = submission_api_url_parsed.netloc + websocket_scheme = "ws" if submission_api_url_parsed.scheme == "http" else "wss" + self.websocket_url = f"{websocket_scheme}://{websocket_host}/submission_input/{self.user_pk}/{self.submission_id}/{self.secret}/" + + # Nice requests adapter with generous retries/etc. + self.requests_session = requests.Session() + adapter = requests.adapters.HTTPAdapter( + max_retries=Retry( + total=3, + backoff_factor=1, + ) + ) + self.requests_session.mount("http://", adapter) + self.requests_session.mount("https://", adapter) + + async def watch_detailed_results(self): + """Watches files alongside scoring + program containers, currently only used + for detailed_results.html""" + if not self.detailed_results_url: + return + file_path = self.get_detailed_results_file_path() + last_modified_time = None + start = time.time() + expiration_seconds = 60 + + while self.watch and self.completed_program_counter < 2: + if file_path: + new_time = os.path.getmtime(file_path) + if new_time != last_modified_time: + last_modified_time = new_time + await self.send_detailed_results(file_path) + else: + logger.info(time.time() - start) + if time.time() - start > expiration_seconds: + timeout_error_message = ( + "WARNING: Detailed results not written before the execution." + ) + logger.warning(timeout_error_message) + await asyncio.sleep(5) + file_path = self.get_detailed_results_file_path() + else: + # make sure we always send the final version of the file + if file_path: + await self.send_detailed_results(file_path) + + def get_detailed_results_file_path(self): + default_detailed_results_path = os.path.join( + self.output_dir, "detailed_results.html" + ) + if os.path.exists(default_detailed_results_path): + return default_detailed_results_path + else: + # v1.5 compatibility - get the first html file if detailed_results.html doesn't exists + html_files = glob.glob(os.path.join(self.output_dir, "*.html")) + if html_files: + return html_files[0] + + async def send_detailed_results(self, file_path): + logger.info( + f"Updating detailed results {file_path} - {self.detailed_results_url}" + ) + self._put_file( + self.detailed_results_url, file=file_path, content_type="text/html" + ) + websocket_url = f"{self.websocket_url}?kind=detailed_results" + logger.info(f"Connecting to {websocket_url} for detailed results") + # Wrap this with a Try ... Except otherwise a failure here will make the submission get stuck on Running + try: + websocket = await asyncio.wait_for( + websockets.connect(websocket_url), timeout=30.0 + ) + await websocket.send( + json.dumps( + { + "kind": "detailed_result_update", + } + ) + ) + except Exception as e: + logger.error("This error might result in a Execution Time Exceeded error" + e) + if os.environ.get("LOG_LEVEL", "info").lower() == "debug": + logger.exception(e) + + def _get_stdout_stderr_file_names(self, run_args): + # run_args should be the run_args argument passed to __init__ from the run_wrapper. + if not self.is_scoring: + DETAILED_OUTPUT_NAMES = [ + "prediction_stdout", + "prediction_stderr", + "prediction_ingestion_stdout", + "prediction_ingestion_stderr", + ] + else: + DETAILED_OUTPUT_NAMES = [ + "scoring_stdout", + "scoring_stderr", + "scoring_ingestion_stdout", + "scoring_ingestion_stderr", + ] + return [run_args[name] for name in DETAILED_OUTPUT_NAMES] + + def _update_submission(self, data): + url = f"{self.submissions_api_url}/submissions/{self.submission_id}/" + data["secret"] = self.secret + + logger.info(f"Updating submission @ {url} with data = {data}") + + resp = self.requests_session.patch(url, data, timeout=150) + if resp.status_code == 200: + logger.info("Submission updated successfully!") + else: + logger.error( + f"Submission patch failed with status = {resp.status_code}, and response = \n{resp.content}" + ) + raise SubmissionException("Failure updating submission data.") + + def _update_status(self, status, extra_information=None): + if status not in AVAILABLE_STATUSES: + raise SubmissionException( + f"Status '{status}' is not in available statuses: {AVAILABLE_STATUSES}" + ) + + data = { + "status": status, + "status_details": extra_information, + } + + # TODO: figure out if we should pull this task code later(submission.task should always be set) + # When we start + # if status == STATUS_SCORING: + # data.update({ + # "task_pk": self.task_pk, + # }) + self._update_submission(data) + + def _get_container_image(self, image_name): + logger.info("Running pull for image: {}".format(image_name)) + retries, max_retries = (0, 3) + while retries < max_retries: + try: + with Progress() as progress: + resp = client.pull(image_name, stream=True, decode=True) + for line in resp: + show_progress(line, progress) + break # Break if the loop is successful to exit "with Progress() as progress" + + except (docker.errors.APIError, Exception) as pull_error: + retries += 1 + if retries >= max_retries: + logger.error( + "There was a problem pulling the image : " + str(pull_error) + ) + # Prepare data to be sent to submissions api + docker_pull_fail_data = { + "type": "Docker_Image_Pull_Fail", + "error_message": pull_error, + "is_scoring": self.is_scoring, + } + # Send data to be written to ingestion logs + self._update_submission(docker_pull_fail_data) + # Send error through web socket to the frontend + asyncio.run(self._send_data_through_socket(str(pull_error))) + raise DockerImagePullException( + f"Pull for {image_name} failed! Check the logs for more information" + ) + else: + logger.warning("Failed. Retrying in 5 seconds...") + time.sleep(5) # Wait 5 seconds before retrying + + async def _send_data_through_socket(self, error_message): + """ + This function gets an error messages and sends it through a web socket. This function is used for sending + - Docker image pull failure logs + - Execution time limit exceeded logs + """ + # Create a unique websocket URL for error messages + websocket_url = f"{self.websocket_url}?kind=error_logs" + logger.info(f"Connecting to {websocket_url} to send error message") + + logger.info(f"Connecting to {websocket_url} to send docker image pull error") + + # connect to web socket + websocket = await asyncio.wait_for( + websockets.connect(websocket_url), timeout=10.0 + ) + + # define websocket errors + websocket_errors = ( + socket.gaierror, + websockets.WebSocketException, + websockets.ConnectionClosedError, + ConnectionRefusedError, + ) + + try: + # send message + await websocket.send( + json.dumps({"kind": "stderr", "message": error_message}) + ) + + except websocket_errors: + # handle websocket errors + logger.error("Error sending failed through websocket") + try: + await websocket.close() + except Exception as e: + logger.error(e) + else: + # no error in websocket message sending + logger.info("Error sent successfully through websocket") + + logger.info(f"Disconnecting from websocket {websocket_url}") + + # close websocket + await websocket.close() + + def _get_bundle(self, url, destination, cache=True): + """Downloads zip from url and unzips into destination. If cache=True then url is hashed and checked + against existence in CACHE_DIR/ and only downloaded if needed. Cache size is checked + during the prepare step and cleared if it's over MAX_CACHE_DIR_SIZE_GB. + + :returns zip file path""" + logger.info(f"Getting bundle {url} to unpack @ {destination}") + download_needed = True + + # Try to find the bundle in the cache of the worker + if cache: + # Hash url and download it if it doesn't exist + url_without_params = url.split("?")[0] + url_hash = hashlib.sha256(url_without_params.encode("utf8")).hexdigest() + bundle_file = os.path.join(CACHE_DIR, url_hash) + download_needed = not os.path.exists(bundle_file) + else: + if not os.path.exists(self.bundle_dir): + os.mkdir(self.bundle_dir) + bundle_file = tempfile.NamedTemporaryFile( + dir=self.bundle_dir, delete=False + ).name + + # Fetch and extract + retries, max_retries = (0, 10) + while retries < max_retries: + if download_needed: + try: + # Download the bundle + urlretrieve(url, bundle_file) + except HTTPError: + raise SubmissionException( + f"Problem fetching {url} to put in {destination}" + ) + try: + # Extract the contents to destination directory + with ZipFile(bundle_file, "r") as z: + z.extractall(os.path.join(self.root_dir, destination)) + break # Break if the loop is successful + except BadZipFile: + retries += 1 + if retries >= max_retries: + raise # Re-raise the last caught BadZipFile exception + else: + logger.warning("Failed. Retrying in 60 seconds...") + time.sleep(60) # Wait 60 seconds before retrying + # Return the zip file path for other uses, e.g. for creating a MD5 hash to identify it + return bundle_file + + async def _run_container_engine_cmd(self, container, kind): + """This runs a command and asynchronously writes the data to both a storage file + and a socket + + :param engine_cmd: the list of container engine command arguments + :param kind: either 'ingestion' or 'program' + :return: + """ + + # Creating this and setting 2 values to None in case there is not enough time for the worker to get logs, otherwise we will have errors later on + logs_Unified = [None, None] + + # Create a websocket to send the logs in real time to the codabench instance + # We need to set a timeout for the websocket connection otherwise the program will get stuck if he websocket does not connect. + try: + websocket_url = f"{self.websocket_url}?kind={kind}" + logger.debug( + "Connecting to " + + websocket_url + + "for container " + + str(container.get("Id")) + ) + websocket = await asyncio.wait_for( + websockets.connect(websocket_url), timeout=10.0 + ) + logger.debug( + "connected to " + + str(websocket_url) + + "for container " + + str(container.get("Id")) + ) + except Exception as e: + logger.error( + "There was an error trying to connect to the websocket on the codabench instance" + + e + ) + if os.environ.get("LOG_LEVEL", "info").lower() == "debug": + logger.exception(e) + + start = time.time() + + # Stream the logs of competition container while also sending them to the codabench instance + try: + logger.debug("Starting container " + container.get("Id")) + client.start(container=container.get("Id")) + logger.debug( + "Attaching to started container to get the logs :" + container.get("Id") + ) + container_LogsDemux = client.attach( + container, demux=True, stream=True, logs=True + ) + + # If we enter the for loop after the container exited, the program will get stuck + if ( + client.inspect_container(container)["State"]["Status"].lower() + == "running" + ): + logger.debug( + "Show the logs and stream them to codabench " + container.get("Id") + ) + for log in container_LogsDemux: + if str(log[0]) != "None": + logger.info(log[0].decode()) + try: + await websocket.send( + json.dumps({"kind": kind, "message": log[0].decode()}) + ) + except Exception as e: + logger.error(e) + + elif str(log[1]) != "None": + logger.error(log[1].decode()) + try: + await websocket.send( + json.dumps({"kind": kind, "message": log[1].decode()}) + ) + except Exception as e: + logger.error(e) + + except (docker.errors.NotFound, docker.errors.APIError) as e: + logger.error(e) + except Exception as e: + logger.error( + "There was an error while starting the container and getting the logs" + + e + ) + if os.environ.get("LOG_LEVEL", "info").lower() == "debug": + logger.exception(e) + + # Get the return code of the competition container once done + try: + # Gets the logs of the container, sperating stdout and stderr (first and second position) thanks for demux=True + logs_Unified = client.attach(container, logs=True, demux=True) + return_Code = client.wait(container) + logger.debug( + f"WORKER_MARKER: Disconnecting from {websocket_url}, program counter = {self.completed_program_counter}" + ) + await websocket.close() + client.remove_container(container, force=True) + + logger.debug( + "Container " + + container.get("Id") + + "exited with status code : " + + str(return_Code["StatusCode"]) + ) + + except ( + requests.exceptions.ReadTimeout, + docker.errors.APIError, + Exception, + ) as e: + logger.error(e) + return_Code = {"StatusCode": e} + + self.logs[kind] = { + "returncode": return_Code["StatusCode"], + "start": start, + "end": None, + "stdout": { + "data": logs_Unified[0], + "stream": logs_Unified[0], + "continue": True, + "location": self.stdout if kind == "program" else self.ingestion_stdout, + }, + "stderr": { + "data": logs_Unified[1], + "stream": logs_Unified[1], + "continue": True, + "location": self.stderr if kind == "program" else self.ingestion_stderr, + }, + } + + self.logs[kind]["end"] = time.time() + + # Communicate that the program is closing + self.completed_program_counter += 1 + + def _get_host_path(self, *paths): + """Turns an absolute path inside our container, into what the path + would be on the host machine. We also ensure that the directory exists, + docker will create if necessary, but other container engines such as + podman may not.""" + # Take our list of paths and smash 'em together + path = os.path.join(*paths) + + # pull front of path, which points to the location inside the container + path = path[len(BASE_DIR) :] + + # add host to front, so when we run commands in the container on the host they + # can be seen properly + path = os.path.join(HOST_DIRECTORY, path) + + # Create if necessary + os.makedirs(path, exist_ok=True) + + return path + + async def _run_program_directory(self, program_dir, kind): + """ + Function responsible for running program directory + + Args: + - program_dir : can be either ingestion program or program/submission + - kind : either `program` or `ingestion` + """ + # If the directory doesn't even exist, move on + if not os.path.exists(program_dir): + logger.warning(f"{program_dir} not found, no program to execute") + + # Communicate that the program is closing + self.completed_program_counter += 1 + return + + if os.path.exists(os.path.join(program_dir, "metadata.yaml")): + metadata_path = "metadata.yaml" + elif os.path.exists(os.path.join(program_dir, "metadata")): + metadata_path = "metadata" + else: + # Display a warning in logs when there is no metadata file in submission/program dir + if kind == "program": + logger.warning( + "Program directory missing metadata, assuming it's going to be handled by ingestion" + ) + # Copy submission files into prediction output + # This is useful for results submissions but wrongly uses storage + shutil.copytree(program_dir, self.output_dir) + return + else: + raise SubmissionException( + "Program directory missing 'metadata.yaml/metadata'" + ) + + logger.info(f"Metadata path is {os.path.join(program_dir, metadata_path)}") + with open(os.path.join(program_dir, metadata_path), "r") as metadata_file: + try: # try to find a command in the metadata, in other cases set metadata to None + metadata = yaml.load(metadata_file.read(), Loader=yaml.FullLoader) + logger.info(f"Metadata contains:\n {metadata}") + if isinstance(metadata, dict): # command found + command = metadata.get("command") + else: + command = None + except yaml.YAMLError as e: + logger.error("Error parsing YAML file: ", e) + print("Error parsing YAML file: ", e) + command = None + if not command and kind == "ingestion": + raise SubmissionException( + "Program directory missing 'command' in metadata" + ) + elif not command: + logger.warning( + f"Warning: {program_dir} has no command in metadata, continuing anyway " + f"(may be meant to be consumed by an ingestion program)" + ) + return + volumes_host = [ + self._get_host_path(program_dir), + self._get_host_path(self.output_dir), + self.data_dir, + ] + volumes_config = { + volumes_host[0]: { + "bind": "/app/program", + "mode": "z", + }, + volumes_host[1]: { + "bind": "/app/output", + "mode": "z", + }, + volumes_host[2]: { + "bind": "/app/data", + "mode": "ro", + }, + } + + if kind == "ingestion": + # program here is either scoring program or submission, depends on if this ran during Prediction or Scoring + if self.ingestion_only_during_scoring and self.is_scoring: + # submission program moved to 'input/res' with shutil.move() above + ingested_program_location = "input/res" + else: + ingested_program_location = "program" + volumes_host.extend( + [self._get_host_path(self.root_dir, ingested_program_location)] + ) + tempvolumeConfig = { + volumes_host[-1]: { + "bind": "/app/ingested_program", + } + } + volumes_config.update(tempvolumeConfig) + + if self.is_scoring: + # For scoring programs, we want to have a shared directory just in case we have an ingestion program. + # This will add the share dir regardless of ingestion or scoring, as long as we're `is_scoring` + volumes_host.extend([self._get_host_path(self.root_dir, "shared")]) + tempvolumeConfig = { + volumes_host[-1]: { + "bind": "/app/shared", + } + } + volumes_config.update(tempvolumeConfig) + + # Input from submission (or submission + ingestion combo) + volumes_host.extend([self._get_host_path(self.input_dir)]) + tempvolumeConfig = { + volumes_host[-1]: { + "bind": "/app/input", + } + } + volumes_config.update(tempvolumeConfig) + + if self.input_data: + volumes_host.extend([self._get_host_path(self.root_dir, "input_data")]) + tempvolumeConfig = { + volumes_host[-1]: { + "bind": "/app/input_data", + } + } + volumes_config.update(tempvolumeConfig) + + # Handle Legacy competitions by replacing anything in the run command + command = replace_legacy_metadata_command( + command=command, + kind=kind, + is_scoring=self.is_scoring, + ingestion_only_during_scoring=self.ingestion_only_during_scoring, + ) + + cap_drop_list = [ + "AUDIT_WRITE", + "CHOWN", + "DAC_OVERRIDE", + "FOWNER", + "FSETID", + "KILL", + "MKNOD", + "NET_BIND_SERVICE", + "NET_RAW", + "SETFCAP", + "SETGID", + "SETPCAP", + "SETUID", + "SYS_CHROOT", + ] + # Configure whether or not we use the GPU. Also setting auto_remove to False because + if os.environ.get("CONTAINER_ENGINE_EXECUTABLE", "docker").lower() == "docker": + security_options = ["no-new-privileges"] + else: + security_options = ["label=disable"] + # Setting the device ID like this allows users to specify which gpu to use in the .env file, with all being the default if no value is given + device_id = [os.environ.get("GPU_DEVICE", "nvidia.com/gpu=all")] + if os.environ.get("USE_GPU", "false").lower() == "true": + logger.info("Running the container with GPU capabilities") + host_config = client.create_host_config( + auto_remove=False, + cap_drop=cap_drop_list, + binds=volumes_config, + userns_mode="host", + security_opt=security_options, + device_requests=[ + { + "Driver": "cdi", + "DeviceIDs": device_id, + }, + ], + ) + else: + host_config = client.create_host_config( + auto_remove=False, + cap_drop=cap_drop_list, + binds=volumes_config, + userns_mode="host", + security_opt=security_options, + ) + + logger.info("Running container with command " + command) + container_name = ( + self.ingestion_container_name + if kind == "ingestion" + else self.program_container_name + ) + container = client.create_container( + self.container_image, + name=container_name, + host_config=host_config, + detach=False, + volumes=volumes_host, + command=command, + working_dir="/app/program", + environment=["PYTHONUNBUFFERED=1"], + ) + logger.debug("Created container : " + str(container)) + logger.info("Volume configuration of the container: ") + pprint(volumes_config) + # This runs the container engine command and asynchronously passes data back via websocket + try: + return await self._run_container_engine_cmd(container, kind=kind) + except Exception as e: + logger.error(e) + if os.environ.get("LOG_LEVEL", "info").lower() == "debug": + logger.exception(e) + + def _put_dir(self, url, directory): + """Zip the directory and send it to the given URL using _put_file.""" + logger.info("Putting dir %s in %s" % (directory, url)) + retries, max_retries = (0, 3) + while retries < max_retries: + # Zip the directory + start_time = time.time() + zip_path = make_archive( + os.path.join(self.root_dir, str(uuid.uuid4())), "zip", directory + ) + duration = time.time() - start_time + logger.info(f"Time needed to zip archive: {duration} seconds.") + if is_valid_zip(zip_path): # Check zip integrity + self._put_file(url, file=zip_path) # Send the file + break # Leave the loop in case of success + else: + retries += 1 + if retries >= max_retries: + raise Exception("ZIP file is corrupted or incomplete.") + else: + logger.info("Failed. Retrying in 30 seconds...") + time.sleep(30) # Wait 30 seconds before retrying + + def _put_file(self, url, file=None, raw_data=None, content_type="application/zip"): + """Send the file in the storage.""" + if file and raw_data: + raise Exception("Cannot put both a file and raw_data") + + headers = { + # For Azure only, other systems ignore these headers + "x-ms-blob-type": "BlockBlob", + "x-ms-version": "2018-03-28", + } + if content_type: + headers["Content-Type"] = content_type + if file: + logger.info("Putting file %s in %s" % (file, url)) + data = open(file, "rb") + headers["Content-Length"] = str(os.path.getsize(file)) + elif raw_data: + logger.info("Putting raw data %s in %s" % (raw_data, url)) + data = raw_data + else: + raise SubmissionException( + "Must provide data, both file and raw_data cannot be empty" + ) + + resp = self.requests_session.put( + url, + data=data, + headers=headers, + ) + logger.info("*** PUT RESPONSE: ***") + logger.info(f"response: {resp}") + logger.info(f"content: {resp.content}") + + def _prep_cache_dir(self, max_size=MAX_CACHE_DIR_SIZE_GB): + if not os.path.exists(CACHE_DIR): + os.mkdir(CACHE_DIR) + logger.info("Checking if cache directory needs to be pruned...") + if get_folder_size_in_gb(CACHE_DIR) > max_size: + logger.info("Pruning cache directory") + delete_files_in_folder(CACHE_DIR) + else: + logger.info("Cache directory does not need to be pruned!") + + def prepare(self): + if not self.is_scoring: + # Only during prediction step do we want to announce "preparing" + self._update_status(STATUS_PREPARING) + + # Setup cache and prune if it's out of control + self._prep_cache_dir() + + # A run *may* contain the following bundles, let's grab them and dump them in the appropriate + # sub folder. + bundles = [ + # (url to file, relative folder destination) + (self.program_data, "program"), + (self.ingestion_program_data, "ingestion_program"), + (self.input_data, "input_data"), + (self.reference_data, "input/ref"), + ] + if self.is_scoring: + # Send along submission result so scoring_program can get access + bundles += [(self.prediction_result, "input/res")] + + for url, path in bundles: + if url is not None: + # At the moment let's just cache input & reference data + cache_this_bundle = path in ("input_data", "input/ref") + zip_file = self._get_bundle(url, path, cache=cache_this_bundle) + + # TODO: When we have `is_scoring_only` this needs to change... + if url == self.program_data and not self.is_scoring: + # We want to get a checksum of submissions so we can check if they are + # a solution, or maybe match them against other submissions later + logger.info(f"Beginning MD5 checksum of submission: {zip_file}") + checksum = md5(zip_file) + logger.info(f"Checksum result: {checksum}") + self._update_submission({"md5": checksum}) + + # For logging purposes let's dump file names + for filename in glob.iglob(self.root_dir + "**/*.*", recursive=True): + logger.info(filename) + + # Before the run starts we want to download images, they may take a while to download + # and to do this during the run would subtract from the participants time. + self._get_container_image(self.container_image) + + def start(self): + hostname = utils.nodenames.gethostname() + if self.is_scoring: + self._update_status( + STATUS_RUNNING, extra_information=f"scoring_hostname-{hostname}" + ) + else: + self._update_status( + STATUS_RUNNING, extra_information=f"ingestion_hostname-{hostname}" + ) + program_dir = os.path.join(self.root_dir, "program") + ingestion_program_dir = os.path.join(self.root_dir, "ingestion_program") + + logger.info("Running scoring program, and then ingestion program") + loop = asyncio.new_event_loop() + gathered_tasks = asyncio.gather( + self._run_program_directory(program_dir, kind="program"), + self._run_program_directory(ingestion_program_dir, kind="ingestion"), + self.watch_detailed_results(), + loop=loop, + ) + + signal.signal(signal.SIGALRM, alarm_handler) + signal.alarm(self.execution_time_limit) + try: + loop.run_until_complete(gathered_tasks) + except ExecutionTimeLimitExceeded: + error_message = f"Execution Time Limit exceeded. Limit was {self.execution_time_limit} seconds" + logger.error(error_message) + # Prepare data to be sent to submissions api + execution_time_limit_exceeded_data = { + "type": "Execution_Time_Limit_Exceeded", + "error_message": error_message, + "is_scoring": self.is_scoring, + } + # Some cleanup + for kind, logs in self.logs.items(): + containers_to_kill = [] + containers_to_kill.append(self.ingestion_container_name) + containers_to_kill.append(self.program_container_name) + logger.debug( + "Trying to kill and remove container " + str(containers_to_kill) + ) + for container in containers_to_kill: + try: + client.remove_container(str(container), force=True) + except docker.errors.APIError as e: + logger.error(e) + except Exception as e: + logger.error( + "There was a problem killing " + str(containers_to_kill) + e + ) + if os.environ.get("LOG_LEVEL", "info").lower() == "debug": + logger.exception(e) + # Send data to be written to ingestion/scoring std_err + self._update_submission(execution_time_limit_exceeded_data) + # Send error through web socket to the frontend + asyncio.run(self._send_data_through_socket(error_message)) + raise SubmissionException(error_message) + finally: + self.watch = False + for kind, logs in self.logs.items(): + if logs["end"] is not None: + elapsed_time = logs["end"] - logs["start"] + else: + elapsed_time = self.execution_time_limit + return_code = logs["returncode"] + if return_code is None: + logger.warning("No return code from Process. Killing it") + if kind == "ingestion": + containers_to_kill = self.ingestion_container_name + else: + containers_to_kill = self.program_container_name + try: + client.kill(containers_to_kill) + client.remove_container(containers_to_kill, force=True) + except docker.errors.APIError as e: + logger.error(e) + except Exception as e: + logger.error( + "There was a problem killing " + str(containers_to_kill) + e + ) + if os.environ.get("LOG_LEVEL", "info").lower() == "debug": + logger.exception(e) + if kind == "program": + self.program_exit_code = return_code + self.program_elapsed_time = elapsed_time + elif kind == "ingestion": + self.ingestion_program_exit_code = return_code + self.ingestion_elapsed_time = elapsed_time + logger.info(f"[exited with {logs['returncode']}]") + for key, value in logs.items(): + if key not in ["stdout", "stderr"]: + continue + if value["data"]: + logger.info(f"[{key}]\n{value['data']}") + self._put_file(value["location"], raw_data=value["data"]) + + # set logs of this kind to None, since we handled them already + logger.info("Program finished") + signal.alarm(0) + + if self.is_scoring: + self._update_status(STATUS_FINISHED) + else: + self._update_status(STATUS_SCORING) + + def push_scores(self): + """This is only ran at the end of the scoring step""" + # POST to some endpoint: + # { + # "correct": 1.0 + # } + if os.path.exists(os.path.join(self.output_dir, "scores.json")): + scores_file = os.path.join(self.output_dir, "scores.json") + with open(scores_file) as f: + try: + scores = json.load(f) + except json.decoder.JSONDecodeError as e: + raise SubmissionException( + f"Could not decode scores json properly, it contains an error.\n{e.msg}" + ) + + elif os.path.exists(os.path.join(self.output_dir, "scores.txt")): + scores_file = os.path.join(self.output_dir, "scores.txt") + with open(scores_file) as f: + scores = yaml.load(f, yaml.Loader) + else: + raise SubmissionException( + "Could not find scores file, did the scoring program output it?" + ) + + url = ( + f"{self.submissions_api_url}/upload_submission_scores/{self.submission_id}/" + ) + data = { + "secret": str(self.secret), + "scores": scores, + } + logger.info(f"Submitting these scores to {url}: {scores} with data = {data}") + resp = self.requests_session.post(url, json=data) + logger.info(resp) + logger.info(str(resp.content)) + + def push_output(self): + """Output is pushed at the end of both prediction and scoring steps.""" + # V1.5 compatibility, write program statuses to metadata file + prog_status = { + "exitCode": self.program_exit_code, + # for v1.5 compat, send `ingestion_elapsed_time` if no `program_elapsed_time` + "elapsedTime": self.program_elapsed_time or self.ingestion_elapsed_time, + "ingestionExitCode": self.ingestion_program_exit_code, + "ingestionElapsedTime": self.ingestion_elapsed_time, + } + + logger.info(f"Metadata output: {prog_status}") + + metadata_path = os.path.join(self.output_dir, "metadata") + + if os.path.exists(metadata_path): + raise SubmissionException( + "Error, the output directory already contains a metadata file. This file is used " + "to store exitCode and other data, do not write to this file manually." + ) + + with open(metadata_path, "w") as f: + f.write(yaml.dump(prog_status, default_flow_style=False)) + + if not self.is_scoring: + self._put_dir(self.prediction_result, self.output_dir) + else: + self._put_dir(self.scoring_result, self.output_dir) + + def clean_up(self): + if os.environ.get("CODALAB_IGNORE_CLEANUP_STEP"): + logger.warning( + f"CODALAB_IGNORE_CLEANUP_STEP mode enabled, ignoring clean up of: {self.root_dir}" + ) + return + + logger.info(f"Destroying submission temp dir: {self.root_dir}") + shutil.rmtree(self.root_dir) diff --git a/codabench/logo.png b/codabench/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5fce560fff8e296f8bb04383e3f1499273e26e36 GIT binary patch literal 223127 zcmXtfby$=C_x?kV5H_5MuM(jXxUOmcJy2uKP@r-Vp% z*LQw?*YEjj*Dly~UgtS+pZnae2n{v)dn7a@007)mR6uG00D(9F;3k7`vEO`pWHJcg z0DvM=TH7mY`{u%vr6*xMDkHtACy~n|PeZJZl0z%`bE-kcM#GWMWtLFC)81|QUbye> zoRGvk|NN5(c+{0HcOoW_MC@(M;QaQ@QhEeF3Htn1l z;)SNcdY1@<3NA5C%nSsnq-R|R{qPI3patRQ;QEge?e}#6WO={^Xz^Sx?xvE-6v%kn zE$?f|@Xk^X9|=ft$aHlE`2Ig5q?Q^qp|bjG@HaIg$E6Xu-0*#=H-8%714zk(&Jf&h z;$--0$xAhi@0~^cys6uKBwMVaP$&>P9Cz3iBFAF>=KzOP7+JGTN=gUth?yc~fC3E3 z|Nrs!sNiH8^6*eobuRi$>nvbV{XG{PDuWa%iJdDaK8ZHs!whdUi>j?HNbqHM=ki^| zpW5-@NZ}`d2tq8VL0N<$W0E2CctA<6OCIFM%S5~&*Ro&3(0Im28Chlix54GnqF@yQ zu!u7Y#Y78&1V0)GA%n5N;4qo6j6C+fA7ZQC3_ZNoQY@Bd_)gG&K9_=lyaE!lz$iv$C!-$>4-VEeci`o%Xm#m7Zk zu2hg7?hX`ESSgsXf%TK-E1gsVJ=inSqMC|$xX)iW3Sha){tJ&zQV~PR;2dWomYH$Y z^r-1oXtqc09&{_-hG+M?5kV-~HG;M?i-HJ{Qibeq2_caFAzprd&NnUsqDLQg_OS2q zpQX+unuWEsf@dC{{Ux9KwA9_x&aU+7dQN;^+c^lpnVKR_Pz3{*ZbECl0kjWlI)#bd zU_G~a5bwN|n|3Ef4uBE~u}lb`>BGi>Dt+ zA)&Z1keF0P3GxkFgOB=1B|SQjrOwRuZpd|3-Yeogcmr)WQYp;n2)z$UHHG~ zeIbLA)pQf3U(W)Gy|*cDDdB{CE#ZtclTBuc5R`*_ce&Qv?$J{~jL5dt7 z_Zi_$-|NU1nu?|egA2kbB#iRBao2j(KOSZ>UVXgS{FwV1N}SO9>|-=OJ8b#>qDi*9 zF7-ERRcjO!-g~g;H77zzoDaZIM_%4VEZ_;lk3`9CL#)q2#ksyk{j;x` z1U5Rft5!Grq(2clRT7DLuUOZ>dMa{M0)+^ODkZVUt}Q*qX17Vc!kt?W|H&ARyH5sZ z7nHoNL23!#ONVbqKDFs6*?ULTA=Rw3Q?dP^b=_}u`FuZsQ98arPzs6fv5gFxj_Tqi zMX|F8$mxum9j8}=g5o_Vapa{*Rzu&NM8g1{zQgMC@51M_!R`F3E@BjT*q6G`34=#| zW{;E()X;eV2yc0nWY8yo6;&U16Z1yqTUJjqlAWG)hOgg?5x1qAu1Q)?q2RjmYtrJe zIP2(=W~6Ulg=Ggt2Skv;l8Cs^Qph{c#hO2K)@=!IJk-fyR+=>un?Lcc@%YyxQ=1$E*Sr#nj&;cHT z53Ig+2`2+U`0^N^zQPK(vRJpJ00AFybat2I!PAbQ{Nzy@hwlMZ&3gg4t+_GL1J%3F z@NtXNJ|M<+(`4!3pKR_y(&uX)NgT}T!1StK*WQ zTx-9;R{0h-bp)Gg;PI_K;HAy=4unAHC56e~>@bZnfW#lc1KyKsPyEq$j6NLZ zH7~ZW?J2D2%iANhwVH447jE#w7|Er;Wo5nNd{hfM7hFoo`O@tNOnW?L3h96}Qo zC}eeog3>rF+l5D0GWnEeL*DV|MFzM3 z;K}Zn=r%*FV4fP$wsQBY6<0Sjh|uf%aW~UoTQnc;n1b4oRRf^=F2ZAbjzTkgy5ec}Jn)L)iHJf7OGTqo1$8DxbZighurghf=!7KKtnI z;)$YWlY~_wHIP+3Cg#1waJ2fTu{Yq$#;uu1hul4UT()<&>7;LMH&@cC6JmM}T&i5B zO*B%xTSj^br(484XLr&KT381Hl4m|g`D(?x>51O9uQPMAgmcDD1uCuj{$kt@^|!p0 zZhp8^JQxA00Prp`zOO#VDFA*vUHf_;#}cf?iBWXorQsBFWcavo9d6e40%9L<>dTbw zvL-l>D~Yh)G@-G48kx^dV(C$+kcXfH+UK@I%z9#FS%#x5OWkj;FD@DyZh1UYaZt{4 zW)ab0?Y4ys^3Hd+uL0}f8aVpV1JF;LC~cGcK>-PQ#nbf~e$r?wAPR({6A-zm_?wN0 z_g`zm3l5X#Cc_>$u2*?sY$%q?(7}GrNr1*X$4dWCW=J%z(wBU8qXE+;g}ohXg;?h< z+12mno2OhLEU4g@LCQVh_-00d^>RC2vc*PcQ%A?ju|{!qaq@%#N70AW)Y%;m^L+6@ z7BhEWD>hn7)(Op~n|dOCH7LEuJYI17#%~MHnREx8!J&bQ-tKO>ob~e8Ei^s65Y?LO zmL5?^zAsXY_JE+F@mc%d;so&@)1Nt z#|vTm#qhfiwff(i7AK}ls$A$^TBO{o{{HU!bVCD+YLjM1xC+O}l@mt@E<=fOnSq%jvxvq795O43elBH77JlG`~Ww{LHn?CBv-o;-c} zQAmQ4#c1jfHym}IOW1(L`6m5u`iR`iOVBNgI%IyvNTg(Q}H{>yQ_ zD1!lVI7o9UMD$o)RcAdPBLRL8uxe5P*!URvI}73lhx3j95_;_vI-q0&m7>Cx^T7BR ztfuV69n7AS?zH3Ak92(FKFr%JY;JZL|A%BOHYmdT(oVPOIKJ1~|DuhNdrtdW`Ne%% z7dn11DsIjw6kOh-!$Kf827;>KR?^^9N|M$^vJRCnd8Is-h7tluq8FV>KoB(!kiyta z&a>;3yMU+ygpv3-@HZaOsIYdp@QkIo577!Q*lVb{X6?XWLwpzu=Tg`0vqu`oZ7<%R z%C9;b`PWF2TsIpkhtDrR3=3S8kE!q~zqRi&!ws&M(oZqs2z}R&eF?U3?MMGoKd5-* zGi`NaO-=H;H;tV`^avrKkN}5k4GgVoGUO1m3CZ-jY69>)n4@ z6lG0HE-jOj(wgxj!df_>xYqw#@L?#bP+Qeb)*Q{ew1I3y?N4ppPG8FRl%cY!=N(_# z{m0KAib`=uQH-gi2aGNT?SK4pfUl*5k4gu>a>i!J!~Xu28iZc@a}-+Fu6BH=%HGxW zK2XEkaSblMmZ~hho@*vK9(yK3^91bmKw3oaFS!Qv-JT{ip^7^ypGOm!7d!pUT%HW9 z_3)oHN=rR>Odm0^(@cH$4HJ#T^pS*}zm1CVfB`i~L#RbEe47rZs?91!d)dD!b+N;a zUwwYWtFN-yAw^5jJTX>-E%~uH`Cmy%R-@X)(5IyJjPSc_h$Wv28;0_IeLa_8s}0~ z^Xot4lg1ehaKC`?vrukL^Ua@^Ysd=~+Y|8sX{N>0SS)}b5=GqHkfKcdoJ8Y;bA7}a zaC>4Q)(Enlo%K8iI#4sUQI5(L$|b@k)uK=v?*)9#e{}u2`xx~*j_08SSok@r*P4nY ziZM!IN!A|(K-KOIX_7#;Fb0}X*e_em=1GQD#@Q?>+n2cK{hI7k#Q!Rx+4D%&g_Dks z4h5l{pToiTA6<+OFpu32#tE8UY&(^2jaiNKMj)OM4Oj+i@-e*o{_y;H?*SIDrt`Qk!k6G6)C z{8yO-pCP#FaLg^e8;|DN;G_H-1PGXD=uKmf%s@BlLg0~l>tU?82;7D7Gku@jI-;Pl zPdtor2tuqY^`*UPUR?=Xxs=wGQx}X#kjBm()*qgK*;(t|{Tea|r9;EW(2EiHm;m>+hJ~df5E$!KvXcJnOD>f#UvW@bAtR@sY)+}?!8n~hG!9Y3^ z8LyF3{N#rJ&B!&r?P$o=ySiuq#P`5w-$jFQJ+16(5+|N9&UO7BHK@5PdPXlE2R5Ot zGl4+xsUqdxq0AJfbRLUE*GS{bz+m9@%m+7rBVC?`;7D=g5}P95w&SXQl|5ETOJ`RaS;Pb7hzFEI7KVcvKd{ zJMu3baWxp8zo&Wj=&?3}Ek)Gm;*9N{s>p80$2ESimvmPC@z6^3o++G|0clqhqc}D* zw`~uV4by`X@Y5D-{wG04w!7H2UWNxiEG<3SW7D&pO#B(beQ6q}<>ysDMsk3?sGR*dtOerT0|H>~pQ#U1g{YTL$+@zfVA5`3P8QMtm4mjPgh=^n$@0t8I)y4(W% z{4aDKz;$(WQZ0)4ug@>##hRMht}@$fN*dwc#S=5$Mx)9EmK9##rYYs+u8jE3unF=q z@in@H+&rQ^2>GT-fuEB>WK~Vi9WLD!kw*dGvIFn&m=~MF2vxh*<2B~I;P2WLirL_6JVKRQX>-#-!@lI;(woOirB{8gj7b?;Khaqj#>;Kf7a^|w(vov^+3;ZVdO=W1@*%?2dJ_XI(M+K}^ zI9b_;UUX+(%>0Is9(Z9g?k{{2u%QWHmg5^|{%fodL$U{UCK3RbcM{~$%yJbTMWFrIa@48qnTt{jLiP9sMcudSR9F;*)U~j z(dR65)@Ns1%&}jZ1YUV5Q~lYn!WN~d)##KSeq0>*$A2JK;UiiGFtf#IF|GHR#TDr^dl3c2WefN4)C0ME7nTqP?oa+z zGunkCmnxvA+-U5t;~pfuXU`dec6q(=V{iP|QL2MSeLS7^W}R9aTvp_asOlW&4GauK zJ$L7}Rg`VeH69a@W&o^60qIr9Bl`6%<8*ewdyt7^4_3w#?#(oFmFO5?ye!xCFGrpv_`;GXu zULJ^ByOK)Ac+5fiTq!u{gd^o+SmJ~5U?MFsnBaDsZc?4*u0daUs#~nBQcK6}11sbn zx5r89n=##Y>vz|PF`u387}~(gvD~jM7rNE@h6V8|n6rB~Se&ob~!ROHB(`-GtOJKCvJR#u)|@QH#x zc`{fxyOWZVQo?QA_SW6k*Vof?J+r*6;b;YoeFV^h!^5vzy>7K@t+2f#cb!al2ar-j z0gv8y{?=_n@C#vq=5kop*g~V9GqFsUnCEW5`EO&%i&dYx*%s&D9pwvwciEl`Ihonj zL#IUkmX3n7Hi_)$EQw?zO9-VK%*}cd{>*LRBI!$C9Ob&3sF)RAaCLZY#r|7<{bwOq zMg|KjsS56Ky!oU4)ws?7>NN1G{O-S4Gl1IiG0^2g*K<%F@ShMJ)x4wU$+h>(8BCLB zTm8lNXMq>}{SSw)vQfQ-+4KN4KH#jBqED_D|03NZ)rEKC5ww~de~8bj-R2Cwll8zR zW7p#<*XYjc_gvpv=?C4HFToqaUuv5?_ik4Zw<{-Gll@hV*wZlH?uS!Ip2H&Z(dZ{y zg~^T>gN`@(3VLPLD-W#U|MgJl$FSa8FPO-)TB7xZ#NSMPD_z2dYw&tkLDv=e2|NPC zWRO5qY!j^$#c)L}<8O|AG2({HQ&3rV-<{na#=z#9ni`+mW{g@``VR80rH>Sno}Kwx zFReUsGn7J7&$w+*_IL1Vd){Kg)-4Z{=w3}YQUm5(m4#D09}Ir}x-_@-($Zn&Rwy?x z;O0`k?fRr?@9vLKe<@c?jnOpc=%<70sjfPS#fGj&R#Ur{Pw9&fJw&z{OpnVGMB~}O$R+&l1v*pRA#=1ffDIh2V%2kX zl$ztUw~B}JkrLYpmB18bgzDC<(LQ=cNCmY|ghwiMvnn4b=&6N!LV!?5tEs)B7=zS% zjWmX6-NeG!`QQs0BBOpFC?Pmw?hbkwi)}6!Z?^(t z@csM-m7$J@eect!PpM=z&7Z%4?wcjHI46pxkNrc=KWsc_S`Y&h4rOSk z1^v%O2WXzr!EhrxTgjjJ=q#Q&#TRG}t14i^iNyA#TrCXoVc@9UX^;h647# zIW2mv#P}2}>`_ciPMY24Kqu?=eW~J>?c!xltaIF0O8$>2_pRtmhupy?9d1v#R|tiB z$~!3@mV$qFo9CA)0hdg!epTXasHg%2w3- zx7!!-k{>~y#HLeHONGLw#q&Za#@u`Lo44oy1>}`BU@LJ7+{O-+iF6 za+RE+;MYed1ciHU^9bcWXAejuf&+3Y_Q|rh4+24?rRM61-uks-`QT$>4+6LXd>cScNodh) zVSR`nKnRg5Waq=8ubadEXD?q4-z?8yN~#8il$rUPZ0u6l$OYl`BS%B}ra91h{+Iv? zHs=pMPn&-ps0Fu^u4A2=erRHog+6$g>Hd|9ErL&0@O(GZN%CfOtX%RAK78mS(Sq?t zC?$F53=9pnjM;OSb1&9kU_ol&{(mc5%y9$z1 z6UGHN2>=oTDHQ8+X2Eh_r^RV64B1Y8U9N6(Pv##G5J^i1RdF}n{x}hiz1t5*6x!g% zN1^f)pw|T~9c;Ko0zD_i_rQfl$9o9)Ih*Of6nwB? za`QVJdz7Ywy1=WeqN1XF;j%tAI=+;UKL;(S17aN=IiWH(pB+*>mt%+NDwVSTh)e66+XYB?DTW@E!N=w)C*=J^EHqMB;|Bk;KSFi(2{$Lk^j&(=k zdlZ)^27}fwlqqHV^h;4$=wvWSJkVz?;>xU8tjJG+SG5Owvlqkc`@(&79dRN5)(vyL z;-d2F-`Vbmtue_s4x8K^+ROO8*g;V|8)5w6pf;22w}0%#sw3XB%lVIUQjbIBeejmi zHlVn?LEK!ZuMeS{-FFOf*2RY^5u)UN-&1nOzBF@eeQ;P5@HST$!~0q9wpD-OT-+CN zmOG`$C=1))7QrdL`F5Tv_^FTd@hkPw<{IP0`h)LQxlZ=>=HGwnj<>}4Dkhp+FopH6 z>w27>*4EZa?QBlhpu5eaRmF#Pc8*|K{AYUH+$GOY)$PFY}+nkRm!B{{z_cpPcX~ zMD97?T%HUWtqZkXs1_K77d$Vdpyw6$e<4NnXda(oO(D&{6B>aM{q^v_g{?3BudB^F zqoFoMn2}rZ{uL1qGhVL1hc8edBsVjLt1X74uA-e|g=KsNM=BmtKM?7Y^_lYhtS@%F zx*DG}M$fm7&2O<_gufKE7&#SJV}bmt_kz9P7>i~%_QTGpX!g)6Uk?vQj{wKp7@-Ar zhySR_v3r)SvL?T^xB992sV7AKF3mh`OKEj`<+Sfy=(vio?SEmaY~1c2F=qz+EX;D% zFGq|y8k*XPlqyBCGD7hBLUA*hosIA94ji}@wFS5LQ>Z5U1~|@KyntWZe_CQFo>g0Z zy<@dc^>38-QuFf6NKYBngwD76U(GtTwfLO$Vd@rqLRujC_f30Nay*z!q#nat{USA& zmh4ZN9c7o2`z$Ed7MabOr-bCHkyE~K1);uYuKe-X!cG6e$%N#~D} z3yRv+?adiB@sHV~zjDtv-n`IU!&ow_CG|Co>K36(N=i60C$s>CMDw3}*toKsu9)1x zLOgw!ZQx(t}WgsgDH)mvriOiY=KX$oc$OG9?Y+L5t=NVX|)62&A z3D0kVNA!uZ9ySA;Us;<)=JP~e-rN@F2Bfn5a0)oeWKry^@~fgh{Koe+L1Cjl4I0Z4 zm&?Q1ElMh}bDz%Df{R+lf^*d8@|aD?9nlqV`Lei2m;(Qk$QKo{3&=XTG_TMK`|OkrqlfTBZuSS(xOfsLUK5EM3#-D2PK zJUjL9@EDUrPg@Jp4*C@E4pi-A?|d^67UQpOuF*~@0t?cr{_a~Jcd&R!^$??rg@Vya z%=zw)Q(KGIw^u3Oi?|*>8!XS_!YRYuzrSGuwMcvGK;he(>1BQAAr#cC z4a#m*`u;zNLmt`dJA}A79>W4K>=b3<7;yVT+=JyfQe}9xomF?2qs|Kl7R$Uo$3v^@ zkDV(S=A@8FaQ9=vK{?mW52l1lOKvWFYGTB~J*<&t1#USn*k)V3uNGRw(zOkGxVWM) zaw~HeM}2AKtrwTwaR{(!`Gi?5#-qBzTR%0{+t;pi-f?(fST|*UYD&TOOU3bJ%TejN zY?tV)EoM|8X2r{FC7@}3B69P@JnaX1wyuEPz(7x>pnh^s77YRvKAI7=f90%T-<`h> z7R4JVv34Z1BOLvV%LFFqH4?rE=Gcj(+#_DlnQKQ^W=4uW0AS&RuAK zAorHAhES^(JY4*|X?I=h`6vr3$dR_i6jo1KIG-52PN*6ZeVG7@I?7&1l5s_l_fR~^ zMd`xxd0-a@&|b3Dp4I3_uRT#HsUVdwZYlLwMM4uvc+}I627sPk;^y-1Bo75;YSUUIv4FOV43jDrOgao+dW&F&MIWznDDjS*rri7Ei z#x$w+jv0(}-i#CL#d-Kvn>O_`qUgplpbq*j>m$mo!{>w@K^k-WWM~ zSQ19;qR|lw*nRU@a`eL7{MW;L-BNihNwuxVrDG>(8}pv4FJ^w{dRA%N_NcTp>fsP( zW^nXD>LbuKK4#_j7jK2)hcv`n1f`;2Vchk?s@f_7Cec4t+}|I9vLk+8A;(84REf%) zTxNVB{}}isNCJzZHbw^qR1C0u>7vk#%5s#A0lyrn za?X0>=W$!!CiX}mZ*hw$A5^WNqcbFWU$zh`_A=#h6!()SUI2s=y0T3YI&e8qG$KL^x0JL*5HWrA~2fk6k<-e>X#B>qs15gQl4nbU^qpVo)7 zUwk+Uju%wLJzY7WpfpnbsLzA-5!-MvL+&=tzhPdPBw=QD_GYuR{MoB|XDlsfkU?G@ zJuOkWA-QYAsPDtfxX@ogKQh+pl+6c5b#cncbXnD$hi#-z%wA0G|>rs^XBqR2e`DjjBS%3#2w77Kw9;u|&iNC)oVASB_ZRT4V$uSxf zT9SFMpD}Q1VY>A?N+`3Rv@ag7a)=}fhSCGvuc=p=sXw$M7K4Z5hFl*^li&}**)@{C zoA6ZBM#GcVMc|2i8K60H%K`q@F#osMo`H{B&WbFlN^vsgj9h-k7?8F;y?A|EhXu$w z1b|{<`%1B1e6PAq;PD3)!ET!!b0*0-Q*WPiXiE~n&eshDF>aDcNeORQyimAx(GdZp zWDIrmbMU10XYq=|@4L>tlTG{hIYZPD<8Ei!THCw~CcI$~d06~Up^;cPyQ3S51N68I zY-<Hy2@&9@(i3iO>gU*j z$FNQ?^~1|BheSCNg6Ra!TMQO=GX`Yz8g9SA2dYS!3+iP+#eMds6x- zhs@lVz{osMp!>GBuV9FVI5;8O%v;(n$k>A|Wgp8hFsXbswtRZn+()W~!ky+(w%a($ zyb8i)X=mn$d_g%#8%R^UV)Bo}EeeM!KAxfW@LTDgM9ei`+?EHnG&f^an(3IC;Yh^2 zl?)C-Acxb9as4N8bMD@bAvOu2vN9}04zuA=y}8<&8Ew*xRh9ZuS$F{cPb72PLR1aU zl*@?S6V=BFRb`R+6|&QN6#b*v21(yi_5+>t3kDL$o~7ySo0 z(ASmq*7zHc>`u$JdgDaT&(!S#m9+$gR&in#9+5H|<_7YDU}1>bPaAnHXpy3rqL{H? z_D7s-sZQ%W+_|AsOM_oqCzfx9!4ITH#Ag58z0 zS4tt&q}7bbS;qu8t~9uHP``g@x|(Q+UQ0oINkWauLG40Bm4>H%EBr zsEkutYs!8?FYMx*-Pm1yISV5;_k!D#H1%oIt$FAI&gMN04QCmzPeYH^Uml5D{YfC7 zTPdF2;GhdY{!-$AquoU;${BKH5iQd*MDk+1v4kZmPT#Q#WRxOJN(x70e%ayFBUQF( zjq3q#4K*>9NQs-QUoYcv&@h=Yl_#5J^H25`b7{Aq8vCNh6*gU}NJodOnrmFEk51%f zkUbYwU*T| zkP~*4VQl_0L|Us9g4te7AiIKt0Ni|l#2TmQUNN~LT2@bV4IhHR zlaT^^Vfa6|qeB$n_v8GJrRdM;PTyr4A1n2Mk~Z_CU_4C2M}4}pw3*X9cEbYYuj;p( z>^TjctwsN*GCPviLwfnzZH=iT3=NDLy$;@3phstP$H#V3af0S6JwAQNzMo#0+avqO z;67yJ5q6$@#GPN0!487TeoThTZl801nq#Ye9lCU!{wFNU!(2O^`7hVJFarDdo^`gR z8VVI$VVj5lYixXwSok=dQhY;M}>ry6ke%#{g&NA$PS4AH4UFCvpifjosu=f|R@Vo zNEQms$<0MDRWi9{(^+Z*svtC?B3nx%M$}ty|nqUxbuVT zj{%mnD(>!BA<(<_;kS5s2Y%opZS=^op&^y9i8+Jor09IX(kE(FG!eiW;_rIh&SoE7 zIW%3u83|W?A@g>5pGr@Ru*O{7AVg~sgAuPbytc5$!_Q0tnoSLJXLB~2(1qNy#( zXCGCP*Q~){bjq>L&&~{GQEZ%#bswUBQ95pmL2|^PkVyo}$TZVrS9$S{bU7S?&&PjP zWC&ST4?J%QDDE14E|xF2K3G!y(NBmsGY6gz}EwOwytl&F(JY!JLNGkv;+e^q=dVzJ~?fQLlvxOej2?sHt~2M`tCz3r*t zX3;NZ$c7v|vEvIAG_D<_hzdDV|4WBwp|DZ9HXsOc9%zk&OLRgaP zRJ}5)6V2Vh8WAI7$j5*6=7GBJr|f&xj&+%zFv77>`TGS+$rIRV8@^Y`D4OwOGT|V> zEt6jQV=%-ALI(d!_56IJ_L#5Lxz*b-!PbR_meA_;<94_dz?Y)J3`ZXK_$`Uvam(wgIl## z9kI>=3riD*{?5HSv?>^E+G`a$n940n@yDeM7F)d-Q{cG3K_cTMPK9IRd?(FZ5icm! z+&)$9%1amavdtE8I#}F@Z&ZUIkrf!hVOMO+Ptq-f;slpdLBqY=#u*9YZhkqxr7Dic zB~VFy{MzUzsq2pxxmU?EwslQS`~x0BbySN1v#*4$f|5MA@P15D33|e$6i8|&28Q&l zva_v+u-k69CKU2UUl+^oSm-<*7@V-fdX25Jh;8X7DZw|0HVJ3QpN?0plZM7kAKb6F zLZ$5jQvX<~ynzOTr#g+#z8Ji64ie9(ch)G2rH>+?RD|vON#i8=+jaMAb-TP$e}Vjw z8RO{b#Pe4u^Bw@0n0g09c#!#X1-Cz1-{?=nE9YDEJ32X?m{`2KS)?ta9t zxE=2(TQ_q1Emv|q(Dxp9f8|&8I^j)1pDwV}Q%g!3z22J@a9#Jgq@=1W3!`K=s$@ss zlW>MFiyxHmZq3<9B4*j_l*Vf$D}L;0Se_z?8t0XRg~jd3q+{Uux&k9+O_ZA)7BHh+S%7JG}h(Z?=6Z{>^bd{sUBCxie(R-p2n)9>kQD!AD-SLdT#kr zInka+ysm0_F|}tU0|0Qc0f31UAEyG4!pd;Cu^b_gAnS*eGDmdE0hQng0b9&@$AyuT z#!R1q3yD+Ut*QhXZ6w)tJ@f9g;lfc89|C_1c$w@RZ5*3*#ur>{GFSd&yUpYH)j@n?8>4=QN+d9X0cOp90PDaa+awAOTIv?=EzPcE1~@msysY9Go|egO)mpaIK;> zNxtcL=S@@8%0~JKdlnJ|tqGvOp)s76NIR4Bi1lx#q&9ZHzjdN2?WEnnktfTEYP-&7N z83;y!_E#=;#%_q88KETh!dt>1b@w z)u}}lt0V5Ob}6mprKYlf5-OuIil30>zCA@fZ!I`x(Ae5)R63sE?BVfMw^V&$pZJfF z%GD-cEP|Jm@}nz@6z*Qj-R1nqmM(Fc@TP^dub&LL!rSI3bzZcF&Q)WLPAZ{2r zbHDT5M(*Rtk~)=NRj>oFoE@qRsikCobd(rEsBLm%dv|x$n{{Ved^igOmr>#<@>A|7 zX4otUEzg;9f;rQGO^YzviB=-Od24f$OG-eMoXQT-FfV;a?J#as??4KQmMq(yl`jlG^4Fuv5F;hE zDsct?%1+3@`O0Sp)6)NPx7;aT7@tr|D)#4rb9q^#V+E0K=;gbp%afza4kaX<>x>G1{U-dFysKms5wh`2ZSjb2u|!!IoM zZpBniw>1T8Hmz>{nUJXd+5FRyll4htaqMqtU!S)r1pqNPh=kMvwx7jB_x(HQ`=b9! z;Se0FA`qsw)eik#QO0TYBwAqe@5RMVn1V3L4&pvoaVP`_0%}&_aYc3w-6OoI;`Fy; zNH{dofbGLN-qaWw8{hrzVZ8fO*Y;XpU(+mne|kHx>(@^EdiM8svn(zlaICzSG1 z?8M0P&kUB^^M?t3;Ouv(L;RG(UJGGSB*bdB@3qWSgT(9#*nMb<=j*(&6{4@o{+NX+ z?@%TK|L2~Y4$bhhC*7DD!}9XQh8G^3*{{e~i=!4fnWH6ddB4$^b5u&`i-RWckbe?MFBRkztm z>#zE?>5cF6$-d*u+qHrg<8ccY^O!om0bCmF(d}_5G4$W^Bi**y<4GIRw z^RyBisrfxOmS0s@VxP&4Uzw_;pbm=!W)2flAWZM=cNv8|s2*&NF>wQX8&;1R^5|RX zd)TtMT8}ir`4^une|r19>NxTo{IH0wU@XB0iqlPh(U^f%*F}ubyNPq)0#FVufr#k) zUxY`uyV%`t=St9#%Z|FrFM!@~eF^lAy+d4zaJP6=<4Yty9X&lx zJo|`M-?w5MghXl^)4R-j)~&=V;m5DfKpLgd70#QM{tZ3+|ET8s9o0>NI_# zN`G`Zq!!4dC*iqU7{}$@D8=HDf-YfEjNeaEm)zNQmptDgQcR2li535?G-Rew+01B+ z0fpQ*g-Jc=c~Aec_DzpWS&E!?x3xnw{E*TPCZK;!5$&D%_R#oUD#d>m&eaNZJyLqm zchIT5dsWRUqbw1226q3Em!lLc(0e8PBsayPwROa5qe+Xo%6n+o-MtZfDSRoD<^`M} zVMCF-I#vyXFYl(m_vyCSr>$Gqylyd?)RgH~sdI{^y98 zI{d`g*VlEW_I`g}@=tcuV*o@#@XDfbh@7yg)3LGX)tWD+^ut5TVV#7{1UXc%hCykO z;%km1mIT2+#2Hf*Oz?^6sxtDJzJbe|Zzg}U&^0!+cr+CuJW)hja1m#-H~O@$jru42v%?Av4VQAW>^A` zU?s1mo!wS<%%1L0c9YiuoKlUZDyPTu6FW&cGX-FPmD8wTL&mMbfqzb#0iW3HK2DI9 zX3o04{;IxYZzOvp94m+EtIDz_0;1qD1MwA9@=9ClFN6-bnMA-_k0glmP&|<{35xcZ z$sMSyse{A09rvUXE!QzSH)o1%WgpDku(PgyaC6-}ZLp zZYvhp6DVXWt+7A~;+!Q1l4JzmYh-3YcnnrL;gyK7XwIHsgy z(ZJKj(^dhN)zpNt`CA8%b$rhIcVF|H$@vEg1WVC^@7cT$S`dq-f6pFR&t;z1=^{Ua zAa*AiZ4V?n&wTU`NL*FGnY~CUlHNSbo1^A|>?d#U%gSo9(-1GhHcLv7S*ZGw=;(<1 z1<4b`+UTW?^rwG4#}H}~!yu3XN)uY-@&^4Rvcr?K{XT~u5tb8v7I)@CPoxF;^8r>Ocbd6SPumHX%Bat~YBA;EA5=m#Nuc@u04_7Y3 zXw7Wl!t$#N?s#8f>0BL%?5CVx-H!vTf)qP6ej>UBBDv6|*jX662_T5OA#hDpYg!7( zCE{1kr!9>Vp3L*0_j$**XhF zN*S8%Ru9Rk#vXtz{=Alfr~*s|M`23;Bm+P#C1UyPZ%qoX~A z2S~PPc(E(6dCyD$V20-%X0P@gylHVc>&f+~dJ|w%>Fo%4gU2)a=_sIy8Sq4=_Z5T~ zML7x#bOuhrp6x^rVlR|HAox;9bOh)>%F*7KToqd|got|?pDv2uAW3W9@eC_OzSuIp zo7p?Fva~c>f?OG{uC!R&lSI>u_}huLoZD0cx0iN(K6&s6I4m=^?3}W$)zNaQz|^vj ze`|88hvCU;cDc;$cpz?8ldy}Hy05RJR_Z6Wuv3t?jyMyEX~x8LjYARUnFB-HARZ>` z8$^td5|Cox_{{_QmB=2`e2k&HdpEh_u`3v*I7!`{DKT zX&U+CpT{3e#4`+V3T-5|wjZQC$iqi|bo!M$<5t5YPEF=x(s42jHi%?c4ddS_&~hg1 zCl@IBKaS4BpX&dM zRh=ih%Ncs>XIZT*>$a2A&c7!j1|cg;_VB7$wnO4a#1K{2YLd4$*Am?~W9UlnUhkpN zqKU>_B=Z!Nm7T6`RQ+et1)6q%wYOM;h0T`!oG=*MbjP_&iEK6K!eL_K;xCS(h>J70 zR3Qt?iBzNqM;!ZoE#_0d(iYk+t_K@cyRQg$fKo^DI=>zeeyAYNr64n^wd37v z8gw|32Od}eeuw0HS1f~|NelUxeQ6L~^wWP7v{^qqfU)<2?2_Ci@;fghDfp5*edp@X z)xuw(2aji&2lGCoCeLd$Y<0Tpjn)js=jFRM0-+zwFY5wq7c4>orG(8Lp`%~R_-t|z zfiI402Dgg8h;6?t6^v)QFE6HKH`2NIS)d%oBzzU(Yu`k_HC#g>ZK2sz%tl0`3L#=F z_X%B~BANcy*eu{|t`;T_}Cp zr*)*iLF&iJQ*Gj8!X(I0-EFtnag`>>fusUcgqpk^n|jk5*QMWgX*5Iw+K!iE+s;ph z=PcShPxir(<_zmlb$CX=`9HkNrT_Zv_l$*ILZfbCJ}>Q}e?cp%xj2gYr_5(77T?Di zGr-!n4Lr-LrKaf1;7l&e6K|@d7RMP?4(o-Q-+6z%LkWx=8lGfMj)=NcL~uqsbSQXa=Wu! zQIPNL9otfehQ3a#OH(0n%hpYfTw`xpi(h#aI$rvZ@jJa=B;qz8k^1%H9VtW&?&sTGs7mY zP@Nfxo<&O!^W);gl-2Mrrtk55@{+CI-cyO9iuz#iW-Oj}ZQJXEBEJ)Dn6p3THVI_! z@AxjrUBceoytK@wC^puI>9aB<#1tB|9b?ZO^iZgs>ktb5qoB=Y**4b2)}}qd)KKD; z+CY4)U>SNV(81vXi<5?=kzEZ{DlnnzGUL>-@@W9>3$^k3+6d%5$gQ`SKiPVnlr2rRMcfI4 zAQa;UIznJ{Nru`yb3M{|OZ}0Hh6d+o#m~j{4$!m|aYq2unB6E&5*`CA(1zkr&)G(|!jDSJbjH&`m4iEVNV0{ITbz^{`6X zCQiOkIr`21x0-lJ3U`k#%eXV@BiH>H1?A|Q)Fa=bO(0hlov-EB4}B*Wrr(l!{>g`P zw`C745_wkDT}!RRKXhxYadeNImvb*^7f^R9kfVWoX5Co$@)`PHkGbBAL=P+!F#3ow#S_Ma4Jo4dx zNejx;PHDYv>Ay1BLrvJ4)Gp$8j$x+1@bb0&8qPzG1jazaKr6B054F>|6!~7V`0f#+s1nC# z7)^eS3z)fQMIP`5N4Zh_8OT11_Sn~yf>OLAaogOlt>s>oa5nYp9}+lfySzgmb-zLJ z;pyK}`zxw!*nF>>A zi!4Oq{`B{abvs26cpUcFnBg3*t&Lii0IL2#{lhRMM$%^LzDDGGW?Cr4xBJD$ zY_1Do!8mixIlIWccS{Eo6eGge`o}(pgUunQAMXnzgoM>@!E45+Iez{v+r1tOA$sIa z=@^s$&FgG>%jwwL?{KoMWp`U}e8q#di2QvxB%OoTjps*kV$>}tlvQoAKSdjjq=#e= za=X(-sZcc5hGtt4pd8i)A=ZA4$fjRL`X$*`mJsZVMvk4D@o!@n?YL)IfeS_EvhQLk zqyBIu!gZuo~e9uHrh4}W@S1OiBx{idzpIc?7w zpZzUJe0q6a7k`LYSy=(mm9xp6Hq-Kn^8pnnN+`2Mx`AELcUMWN(LEr~!PKv*-64iR zi9*Gnau`ASYUA>4zs_C_1;-a^+R4VLO8gvnt`EAq-zVfZPECbL-Ap(wS4jQ=vZW_H zd4Z=Km&=zzh{hANXv{1h1Pd5hJ-0DVoBz#!mNT@~{3+y6K5$!MB0EW`KtZ;K zz!X{WHRpgVL@Js_-+`vp(1jmb}Ch6gBu-CV zgW#8wGh41#1r(E;{{<|!yg`o_e6rol5xCoaJ~Lxdyqx{)hf)C%#4qj6DDdih(M$tR zemzv>FHZdJ=iysy0{a5{3b3XB+U@T2vNFu}Y_s1%&#>$zWfGr*a@DmK|9@niJ8hT2 zUWYS=la&)2@p~iroLd(ZLNo{B-LNEZq?J{OU1crMUADyfGE1}nb&-uomAKV5D`|s3 zcHC}u*T0>rt9HUii_7+)goq7aA)?We3%3YYVM&jxll^%hA(DNym=FpVzGk3j-S>Cu zsKd}@4hu&$8$;4FG_;hyN)kav?)U`l-|e%7vG$kj4}6v|*V8dlR=>Q`8m68;((t9`h?>;RbH(z}BSBY{SGlwodxt9I-9EHClJfKtx#wD_ZLm=>LoNAdW{#o5k( zy>@Whq+`Qfi+tX`a#}F3-Eh>`GI9PINeyCIK7@;fPi^N`&9jw#$%?kp4Q#o_Gyy;b zI(epyO?iD{*-WemJEpEO`s<0^0J4mAQHpn^r&zx*$A>XBjgayPwm9Q|9S$znTYd+3 ziVUktY<&!G81c0(&D)K9_a~jroT#BewE0w=W9IxQ3w?-|c<*Raoc7nhj;%+?kViK( z;;WMA@|R>C8U-^;801a_q(oFG|mkiCX@~8zV4WUdwd_2`6-{cU8!~ zRM*k+Yzk|yHViuT!ea;%lMjcDC2W}8tete}BEHc=Zr?XwhFo#mjKA(X;Xl{*m{ay; zr7TlBAOvc2PkBl5+IRFq^BEy>{9RUuTjHj(O?#c)*x4<_4Xc6fqwc=-eb*rLsN+Lm zm>eD+MB;Qm>AB2&tMNY5M2(xK`-I7}_pC|nv>f!vUest+Rn0a}u6gbY#4aWH}Zf zM83PJkPBktxbMcnzx*_#3hAIR5)k>qpFZ>R&31e`uXR$j)mRErn^5RO*~^nW=lNE2 zYisV;L;fmLO$XJq8&RIgT(_la$fPE0*Js0)HlGs#>f)%sgToo9lNn%INjRJ&90Z3& zDu%Lu9PtP~-7A&9{AB;jki1Nm@lRNXeb4=3q(lsq#=J)$4ML3%q9(<@(2I-lDAtQE zen205ZhyY_T98=&bs^hF_w;V-H(%~UVNhsD-*Skb3o1*eF;*f$GJZ)lY}ZpyxV=ug z`BT`f;rd&?{6;Dx&FKm-Dkmb>6A*ORJ`j}Jkitiz3;R5@o&g)|y4xG_XiB)x4Ae`d z(luYeNlC287PlQ7jBadptTWy|VqqYKUmw$47Pad}!r{-CGl@MXUZr+GM?XI3$b=&jY>ec zp>&E^ZrE(CuCBJ7Pq)oAd6iFYJZ2u8zr1jn57^POOQw7u=3LxXUr$$Tr&8Jaui9e1 z`+P;u^vSBJ(V1fT1PBzUIr#eyDlHUsU(SMFC#Qd3y5FNGA)~*S+35o;36KRhU!Ny1 znPW?L2?&!Gf+ax7kcSuc_KT|H=V(X2toQmCBw1zDH9)mPX<)b7WOk`FXY9xr`(+Drh_j4#tt3oe9_8IGt-4&h{N&{MJWH_=(b$*f*ZD)Ohk}NbX;JPeIpoMf_`;5i zY{O1Vj-*1=+PG~!h%mlzG`G$YhK;zsmipY@t^Ho~8_qk#p1Xh0+vg*>N0DJA6;{;t zqM0=b`Mjl*%mF{QgG~6ZU-z3c9e&?=F*CNb*_dkCaJ#Fo<^ws!Q{8Jfo>s8(P)m5J z4nBqgOhtzqRc-$%ff-XE3zday6jO=@EQCuXq(2)Zy^%L&4c-HaU767S?<6^2cRb96 zi<4L`nJem>_s(1-h&w9h1wuQvjs^mD&}YD&&8;ymA2PCW_`b8EqO8#5S@#FlpvGwE zGqu@94;;vV%?IOhQa$(+GBPxFf18@0&jYe0OnvL=KFIh?i*k+;6HQ>@gcjn5jp%=w_mEYCIQXyh#+ zMAa%mJ#{WEM;9T4;ll{bV8{uFH{rr8;1fStsz;fCmsadYmBc1>f}Zz&P-@tfgPRJW zNt}A`{8D|y8|sGlbGG0%f@pt96LiU=WC}IKzLBKyD;yDoIaFywQRH`%_nPU7nektB z{nM?mjD{%ysbYVakumjdwt5m3R>4=6gPyGr@NPx}r`O3!QGJ>Ogos8#{$=PuB>y5v z;Kwe!mHPNbj)?dX6=nOZp-F3_V4=ZB-WB7QLJqdatx%S9Kz-= zqOysLXYsEChSPc!?B$64Ury0}^FA4K8GbNitPejsF4(!mo7SFX8PUHM!7Nk{7!pSd zYZVg7>HCgR2^Ed3d+gnE;DCfPl7=f1{d!qU+POXCjoGMj`Q$uBNm@x`BWjxEUu?b{kQ5=DP=T>6 z?*PJ6sp6EF<>RHUSafiRjg8I74!9O^X&gwuNd5!976y9oW0=HX-iOH}Oc;{jh%_rE z$Omntf6&NIok#1u*PCz}XwqJP#7>x%^}nAIWPhG56Bn51wr#(S3au6#zQ$m|>6%<< zC(KG!)_2cNbn`GZ-yi)2sX$~+HlSiZ(~|&^>*j?LeX=^~?(PO07tiJ9opmG}JrTHr z#)Cd_9^m+>BD;X4#`TeriGef9dRc;=zJW@0N6LjPC7;BG5xQc-Zh)%{+5ZWKbvwbA zXH!#+j`{|;uVe;fCK@SFo_FC1Y!F^6YwOjRvc3Z&yOoN%A>B?j{SPniBcd4bIT0o< zW^tOy5;2ta1xMxa!%7^!2B{FanDzM6A&g0y4agC&1^BqOOsvUVd|Y}hu_di*@}?$~ zxrJCdIkXaz9YH=Wez|)II1-2DRfPTS%L~->WL4FcbNOO5lbw2@c9GjdjMDw0Zb>In zvuv)pMArs3BH`*#8pvDyq=wA~Z*M6k8v{aLYW=*bU=B*8&L~z$NwE*lEz|86UG0&W zZcPSJcnyQa^x72^^nQSPZx-3AI>_t8;bk`>zoz}LQz(?+=j4wxNSkk}L!*tS=k(G3 zsyssFQCj^4jY}%z=Hwa+o1w(KBkB$`7NczE$Fq2!NoRayr|s9^?Y<(xr%kQR%>~SM zbQv-H*4`K_$X+;!F?Xj63^$fJ&o&`jTY(c7^++}k=a-AaNNR)3gfnV-=Mdl)&GLJd z+dqot2OkV# zj=VF!f{WY`n40TVO`;ka%K8Asa%gD4z5vyLvazvVk5`h3+3(BUJh_@H3DutJv%RHg z4ah|WZNZ*k0#vS736ZlRyxgu{C8J9D?dymAoz5$)sHNLbQq~uF7M~D5x}f|t{sDCt zCsaqP8u>wmPVsagxY+(M(*l=0xZh$@CI44WI``Eg2%}J+<~v_TLcff{6z)J=^HFC8 z4r_f5*KxRq=-kSR^M8{!{erGvMOT#QOIjJ5tIMbYy&YOhE*yiOR?gglKzdsh!f06E zy@C-7vxk?2))RMG;d?mxf>GE;8~mKWynh%D2Ur?(ifS~RG;D+~Cyec$Cy^dQPa>m+ z;DOGIqjq1?9vol;8d{ExrK0f;TMSA zasvk(Lov#RnF!i|Y{wC>>|IYcHoIL#p*0v_o(P~*j zX@(!lo#uHzLb?O$i7xi-Izo@rUHee*zP49wrp>t6g)(REy!u5RnuY8HocplmOhK`wBI zOT^R)Y#jOH1pZ&a0e14rx(1Ufz=zA{1!NWw zgeafbFIO|r(a`}iA)R8$X5#q9}A5~Hob05;NDpH|&`SMgE`J*egc$MPx zm*^cnISpPlDI8gr$b#*21tQW7KJ7BvDOP$!L~2o+Q~P@AKkPx$KbeL2HX(>unSC=Fw5gT@tw2d)>Rx$H`vwUPftM+b>Y~?O7j{_%6kwg^tFFMvQ~{CU2OQ&6*Gt>h3wRZNfRiaA;tD_OJ)y7}bO3&)j{Iv**3LKIrweDWeZM=4WKGm6wa9e05A-!xku zQgYcc$?+}4{Qf;>WN2Dv)!K|UwmdnRnCRDg_yqU9lNjPWcYe{?cCi9ZR=pVbV8%RP z5l!#3Sd`lbc!>A!OC+$wb3=Gs$Cww`tL7~QRcU%mJvvITG# zg`Aj7etzOObz;b$=jJ?y!o+tZwVR7b*fwH6&1bp{R$;aS>#L8ZK1~sF;^#Ptq9~Oz z*WI#}Y8Xs9ld8{ELI&rHwYVz}FFwWzaCH!O@KQ^9qt(NAR>Ba|c>3Ty&6buiI6PI? zIP*TEHdwe2R$h92FVt#aGv>`@7LR+y?Tb3Z&NzR7Rw}SwJLQ9@D-)>k4ZLGOilk~6 z=KtYeCpqw5_JE8Ly_4~+m!wL%my2?#HrR!*J=?-q;w_b7_uyf@poug`&mLw-Qzvcn z+k`#(Xf;diRr4%;5*jUIt~vJWhn9h^mBEp}%j~&GBRETQm{7O2}3TI@=PTay>o`*qH!j z@Gst9HCcV2Wub(OJ&}YeY1o+a(~c>SmcG{WY8>r@3TTukR3|*phNb&%|WkfsP$+nKID9!u;XGa z)f`c-{cK49Qm93->^B6F&WMrQ7h&R2ApS$~ZR~4(#N2NWV;>ifkyiIBCLAyZJtpff zTCoM03U@c+gloCuAPXSioc47`h(oaF8?RvW{UTuv@Odi z^y%JIfI!rNU1nMDamTTB!J^K*Cu=I^$wo}Rii4dlz3MyUo|*SB2w$(UOd=DO)1!Vj zmI)v+gSyutaY9NCf^2yhdsS_hUo40I7B&tJ)L#AR6bnsIn)|0RI%z5;>xb_Qq8Yw( zOe{m{RS$>2TR=GlwQx(&dR)M4!bF$-US zCIVEx$r#&_oljOwT}eXV?3+c;N~nG08ts0q10W&eqF$T*#>KTZZ$A;6q7GL+nLd2* z01fbmN-VNTXaBi+GbKmbWlsFC9zm{ecaNT-O>9;#TptO56Y4fE7we6RjFuz1ViH(3 z)GDs_cB&)4A#c3ea{bP&tL=W8vEx*bsFHZhQlI;u(+*zo;C~$aF_iowuxWq7r-Lnz z{^tyB=OGt8IWJ@%9yL(^#`djbJX0&6dmF868mJWbYp0T$_L^|@OP#5TSDx>sdk-{y zzD^=;e{%E2Aoac-cPO?8vEuV%Gcuut?p3W;jiHYQHSA_jINiOG&vA267 zE9-N1v?YNTI_M4^UuO5Ctq)*s;hlW7%?0A2Lp9o5P|#iHzAUF%RyUrcHT(m5 zWe`EQUU%S^tLK-5lM4+C9)A1lV|Y|HGHh{#LOg5GU;D*JOVo5Ds%9?NZn|t=W~%|b z3JBf)ww%0+5qqSyjkTDV*pnR1f~oABjOb-XNchzCArd)|Ow0hObESeg+h0E3K6TPI z5pf?fZ3%M(=8~0&E`4R@pKl*2L?rx}PhK;l1{}#2^1$N?upr6rX|}_#W!NSLC4O-( zc-a&jLdcmvUq8Z}4vZNEeLXw_Gs*MWX1fO`01^TYA?m$I4(FpY^u&Y*Kzq$iO$CA1 zA^5NyUu7J?pRjC*at1D=lM_G4OEvsivy{~Ir-#Nwp^IjZ7^(i7&ZVxnKKkCnagRk} z1*Y{Mr?PZ>?VK=w$;0&vUh}eiA~Uy)*4nm#`l;t9O+- zVWhBkOEHMU+PW;ntYK3dO`c)&Tnj~PzfQp&ZdDyVIm&zEG$)QdMyXb5xQ4QIyRwvo zd=@T`O`$QI# zR%Q4QWQ5k>`o@CAvg^_}Vxr0U?|SqfY_ER$`586VBK-Hhxj~2AMW9cs#qylzNgQ)kPt#-@i)|~ls?#7uxIGL>SDy;6L zJ0W+@cgMT8n&*QqR!#xL%Ncjz(rRBJP~UIjm_I3KY#@oMZE5m6SiK2uvTa@)y2W)s zD#76xq6Wx-6bbZ`J;3&|Sp_UmI>kHC>jwv6(q~Jtg2`Nso@>DW0O(e|2j+C8C#knB z;+T*~*?pbM&zPX7e>qgtNh!4SRW~mW#ph?h>9!Mc*$m!O$V<8_G&-z&Y$U^13?++F zwXiy3{T@!9`Lqh|%D<!@a|?5ai7fKd3aUh@G>#{XSYI7 z^^VnM-kM#du&=s4*|RbCz* zgq(&nxP;8lPuUl%ZpXZjer`j1yVx>?)zC&hlJ9jmx9s&=g_rMMTYCpN-Q5vy7-lf2 zayzuIugzzv(gWML9Zzq-r3ayFZqU zFbr~bES5}sl%ga6pZN|bBu@zjq{KJh2CBg!DV38S+1pPi=R?cWs?bi$O{G7cl*YVi z7RdK!E>e9WE}}>Ewy1Ys_&>!PCRfmJ=HL*^l+2Zpl?U=zyue{%QBc4OZiFl>8wmeS zq3a^CDOMR6d%zKcje5Gq?Eb#ybYY?ZF$nogXID^O@$F=5(&KsTp#AUGY5|G|F~s4e zL;&Qppt!`!Pg?Lg#Aat&u^c2(2FHSO>j%{|zkC!R5}_)-)J5JUKWxP)(*&6k??OzxR9YU&Z7)+>YjF zEhQwG3&6qa6BPeeSalPRN2%nl6asRB3F>UrR2B|5Ib8_5nauU`fE6O)yZILP-U9bL zRj0^E2isR`ZZ%}0*eDA$s~oRXgg?RLfd*1YlwDoM$-&RYggO-6bD_p{hPvMR z^0PlIv5!;9*`=;!KxY0<%RxSm+>EiIqqYRuN*L3>$yj8&YxOH@t5SBwFOhwQc0kwi9)DN$$TTR$VP4>HtGB zC0zAeKJ%T>>Oy@4$&KE=*t|Q$aRDEO-v?iObY0K(elrRea_}x9zi|BdSEWo|?t?Xs zVP9TLGQ&Uu-`#e0Huapr+O+Lx@$ys|g|Z_9{XC~1jT^_CF|GVi>ZBjdRTb&n2f8n* zW@nX|d`Yj8UcE{kU%BpGrip1pE&6w2*H1isdMX6C%W>+>5lVY13G8MS;W!J$zY!a^ zlQ3GAxv$Q3U_Y)wyWJd}03gVN>T^B)TSfjN)e{mH9R7%BLS~!gapvH&^3HPVZHjD& z0w<}Qn%vu%4JXp^v%R64otGL_Rj4ZT6g-z~gtB8Gb=i?KcI!kJ@jfohq)On6C&uS? zCKrI^F;Ue4#|Zd|YFJxa8|c>M{@w-abHhVkmPQ6lK7z#%r@oPsfdHc~5U0?f3Nof!$+m|K2k! zBbY#=TTN^cK$9(t0Yty5X$=RYQUUPG>*xS&!>V5_O9M7gH&fex7BP!`-)GVqXyxpj zrXocnXIVIE1hz8LQ?j2;aUzN!t|38$p&Ti|Dl}>F)v_~AvjV$_S|EY7GBMD7e3}NvbUGmuBlFN<-#>#DoLTCpCQoukWj5Rtm$_jQILN4vA?Qk z`fGburY#8h)R!%yHdxCvJKpe6T_9G1y6ZDh$}JMxoTzG!q9~Qt)#cG3EAa_PNO*Vs zCF0TX8(%rk!j#epN{DB(v7t^SpMGI(A$+5kE3<^pagYn1Aw4W_*M9eG9dB_NdHU>k zqx5j9Hk?1?u}0Z$583VW#P1?+Sz%nCSwHMtr9RHc(-UJZ+%m3|H&dekh4Bj+~5S`R1b#y_tQ)lCET7?pOL)HsIh0 z0qif+*t8b)n5CYo)*Qn;!`49g!9h0|tjwJ%J8_Q)vLJK|s(hCZpJ+zsp9N##*gaY6 z-DeKbezcy$luCRDnxJ^kZQ!_q<6Bxp3uTb)hnv}OQX;F@pBQ*li&?9`!e7V6w(o+2 z5`Bi+nQJ^-M&voqW;WKBZK#fw;NKJSFxy4}csA&(D)HO--MJoAoY=YF-na8)+s2&5 zqJIHk;Pz0iUTp;*j!d2El;BZbKAn|iWk9beBr8>{;_%up*XtBLmD<&4eFjKoEj|au zx>OB~&7Kzu9?q45j#nZcKx~P%{zmoiH7R4SCHhQ61neDj+CFBr2Ku*F)#T-i%iWt% ze=B7E)Nn9t-3a-l_fdHrnS1KYIy2LLtW@P{>Bk#ymu%_xl$A=6OEp<_C?io{F`*il z$Dg=wGWR$JpwLw57_4+w3_zD(9|MV^`P~>)OfZWLHb?`xF}t!T*LC&8?C*yQ{lla@ z+w~k*uU+Gfp@S-{l$9+#YrQYrYw100ert@Ulp^0q<<+3*;XZOTB-i--;>D%^f$8NI zK$_l0vxVsx{i6tN624}yP0pJH`T6%n|1vVwEyXKO|2r>d@RR&d=4;<-Xu^l)2*a{d z--4RaNSlX7o?%v*c597H>R{1acvoXK4P*Nq31WF_dY5_GsmBMq6yY^R8ki^haL;h) z(r+t2lhl9z+w&#w!WkDI=WgxmlF~v7pbw=xf4x3P&vO7+uPUJb$B-Y z-^lNVzY_ZN?f0j1k8e|xK5uI9tPEP*-;J#TdtR#}v$~Y#rdvJm`z|324-gHX(u~l^ zn#n7cdFF&B#%$9we zk<6uv=N0j{d)&abxxV~)j)D4VK4H2HrMinFM+XZFi(-=vN5{rwaGteJHi!5S&Zk{O z)97(&^cy$OlS~u|+$xF2KB4z#1(X&#k*%&v5&wAQUKADpdzcs)ul9T>0UjzBl1q(S2nVePw)o(!)8(IWHIX z?1i(j|EllqEV)28qf4sp%bGB<=h_@itiqfU)^49UGi5GMvo0%z>dw3ME>GJqt!Lk6 zrhA(MoU5as(6iNOT8w#V!{e12-uXu)T$J{)0Qu7Uksi)_kd3jl42#&e{izK*{e}g7#{X#XO~cK@GdR$Lro-$M$jG7*PKUMTxFDCzu_tVh}BTa!_im>nR ziAV($D!*vCeS>b^fY7$W(y+N%Gu2w-)MhJ zP~Uj8=iXln+&?j|>6i*sXif}ny0HQ~+Q}WTg)S9Z^k;MvS6cy8^i+mup_nK(8$b59 zqF4l&0Av)l<}^HEF;qn5rIP$T+zviHJnyTNn$IwnVYpvG=S!nb1|#lk{y`km&eME7 z=<2f{Z@*s?YOE^=MLxVbmH|@Yn={|Mb}q1$uaQTan*(DZPf)ujrm`1+lzrBm^G(a4 z!4OnwfWrZkJ(&8K3eAMWV+i1dS59Vt4B$j&^zv^;5k^Yt(Zp;B1o66ViWJcP;*|y z!J&B;7;I2sJ?s+q+th0lG#)r=)`X=1rdf_Ti|q;L{+#R_d0(ge7mwHb(xtOhs|pN_IF#h}lp5CegE)0{ z127vad#BE%htVFXykA`kvR3OD>pP{L_0&Ffyr8d$f-DebS52c7N<>!SX9Jp6B1ZGr z=A({5e6Y{fO_z8V6NU{Bn`d1$30xyWcJT1=D1mC&UhwhDBp56_Hs1_)@Q)tr_=FA9 z^O0*%N2*A7aotPjx|a1l;TLs2G_uoS3@)cDm0KP{vC{@-m3^O6l*WR#`Pjk3dK(|0MTg29^e#7!ezuYARS7SbbYK~@)}rfX%ss1- zg@TnJgITC_0tS;7WjSt_X(g&-MI}+Hlk6)y;!k&|V>Y(A8_50cmLSq zyHsGzOs=DYdRU0iJDaNd7%!vfr52Tg7GMf?#A@*U6c4(R$T6%k5`0OltYZ6tE?--o zI$iWZ3mzjyoiC|++e}GU_7HwRm9X!{_?_09jf6?MImA2U!Q8x9*49QF!3*E{f{x!U z+2$_Gni?y!Q=B%I(Oe!vBmX72gs(#af0c`iUJ3Ty;Cgu!seI`BXf%tLWX|bai3I!K z94G%h#Sc!;5L_?muR)?xgyFY{u2bi}u3jmWZFBs94oMC|zVc&g`s7MEFj6w%+bIxAw+U7%GwUNupss*O~n z5xWskl}fv+_#*-4uJ-A$on>)!bTpM;uK|Us6}YS2)03kr7QbA$xBqEP~(U)bFp(R;Th`n(w(D6wd+YwUnL@(Hsy;`F828z7ElD_1ufBf$&`3Omyg|0oiY z4QW26;16W`459c(MdciP{IhzzOR4L=qnM%8nT-Y=Rb(f;`Q+3zP!R6Wf-lBjw;5kc zi2@4c1*6^Fy7yF>VWjTn zJF}o5#QX+>;^>-p{g|84sM<$joXnlf@3tDS;nRGrzEe&}zQAiQ>gUtdudp)2)VyIt zpF^pl94yH5^MI#jM9^mP#{tyZdnj6^$VAFx zFR*HEjhzE8h29fo@eC;K=T1iX=WNB|IA7Y1Xuf*Gc1ykP&Bz)nJ3GGx1~XZ1m~~~@ zR=T>fggXg?wMAnqb$ye~bu;ZmBuAe>B z*Z>PO(CEkVY)H|lF_oTK3kRoysb6Z5<`PVa$_1mAqEN+nrMr}!k3?PJsavCqXqI^8 zsR_p?8pY$$+D(`ez4F^TGLRJ2rFP2-^2wpi*J4<(vX0TmUlQNb`7@Fu@LMY~WBScT zN27mvYM9`6VUb(aPp)BKn0uL+nnw&PuF;o<6GT~9eUu2T&&-q63L3nuZ4eXtoKv3I zJk6MV+xpC^mUFdjMwq58?+fty~0al23v!62mae*j+*yVhPC2ncT$g_^Z&l->Jvde!x3-?M4h7fxTucW z{Rr$|yEJ855?jUf=UU`>!fHL0Or&nsRV0vaBY15$rxIS_DetLiVOd$zRyIbNNZlRfD<+c&w1;!^Qc5u1>2 zsNn4t*^4C(*^@27o3a<2%q!ZK>|bK!BEkzF(~#Q-?kaN?&*?D|k;{bM1?XihKRd<; z=&)s!RG5RFx9Pe3#?ti@f8v_T`s7E#6e-mUBr(QCPjcSzl&=2?uY)EgOO)y^aaBz4 zt3Hry2{3b5EU8{@7Eh$wrVo?%@^~=kEcUMYfxUa6@4@=Sw9mFWPpR;ijS7aaTzC~j z<)io6cP&yV=a6;c!yA`irCO71;zzq}MMS(gVfn*{ z>LI&Zvv9-dnYkz50$hJdigj0@;J}ySMr-ImBKD%64;$YvU+k)H>KTd&fDl*+z%<%ujX}2iv?kscN#gSK#@x(eD5Qyc3rU zvd8VKn6Jpn&CuhokqsRbp8;D-k=nqf_+#n8oTM_WqhpT5wrB#^A0`vD@NDQcDWyoG z5brX#Jm;P6S8lYlj0Jta8{(#kK4)@4Ub6r7U3-_;b9+DI26mClnG7Pw#YGL1IpD30 zzzYx)haR6q=hupg*Cj|vNR)Knh~Z<3nZkx_nVx5DJqQ1!OV${m?Ybk$8m%IA158Kf zcqvux@*OG?3VC+ILyJ~#gpScgSND(Q=KNEhl!{=9=le7~3P;a2m9W{9UP2^gOtz;@ z$+5EZz1Px7xySEAleH(f;PY5Vv8+`1R`+5~pv3-XwthgBy44UY>eaEcaPRjd?;kW3vFD}Qw*lo{G=RuB zA1#D6f&s5v4FZ&dX}uIbF03btsaHfCy9XV)XjF?r6FM&s4_BJtNTf2W+1cl`cYo9h zGk1U8#k&nVH<7}sx)Oqn=}}A>e$Zo0E=rifv=*DI463Nzg{{)NK3#b1}7pX_T=Q3>gpu=44WT6tR2Mw|mL;De=Af`$MyWy7R;-ltMRscMm%| z1=&G$6tnsl56s}!t)P}Jeyjo)(8h3kQCZ(mzv6Qy98o+H`1Y!~vZ{+eB!oz>SPC!{ zhYK*vL?q4#VWFmwul}WIr#(h_6A)$@#Y$&k0q2Mes&aunk&O&m2wH~9JXd&$jrtQ6 zfF^1UdHzQd0@sozOQ#ED_7h2W73O*cu*rsC;pA$)W6{`R1wzwZY@AzGEFF$2D{~Nc z>)x|Z*DH=fzca6H|AT@|-hBF~&uYo%GCpajhJc6y3lPQvV5p;FNt;vuvOy7 zFOpkscq(x>L3rMr3S=3>|65(|RooD%lF}Fe6d@>5d&i7ztqh4%qU#2CJZ~~?Q|Etj zD}qZ+CoyeL5>N=Y8}GJx2wOxGP-_~-==N>rr-^3nU*4R|H9Ug;?EZ-$D7CXe8VbzJ z9x35B^G&QQtUER~Ws!uK?M8HgNb$Vi3-+H!_151>>xcyJ#7L5keTrjpxn104rZ>Q3*CkE3PLu(RAH2@}sfh$I+^dq)o748wN( zqupFfmX+^I>msn_g4QRIEt{QOGv7QsiYTHBv&Ie|b@1wXN{<;vfCy6V(R)r;R?at9 zib|YXQs|jF1F*4M_Ip8;XNu+#%m9EoT{=^A3IFF|+b8pj1RGd4s?>vNvKf)Q4!4}P ze9!z~B$Ni)Szt-%irYrX7U88hQo36$EiKaES+=gzZCDijN3G444{^2-QNauX z4g0?B;tDv9_=kBIvWfsK!2-MoJ_vo7W?uB=LQ#%Ss`AvJWxR0HNzpjQ zECJu;72oA$PqD`EOyqTZ&eI!#2r+GWwnb!AaunvIZ>;TevM$g6+MvQf+$nvb!6j7Ma!BIhWA%wnBqSYTN1tN6GIt@tNx+9jfr|y=g*tdH6NeM;z znNGv_nvvkY!DgQ=4c4pCo{{8okw`})v+59VUy-pX1^+LKkwiAYX<#5;N4g|hu}}JP zqZ?v28WZZ_Q|)y!GA~<4_RI;U8(k)ZKZyGZ;9~!JKogtotQ#h~vw~x=edXKvx;;Y?dj|);ar;@S=NVr6f+l2%pWXMt z-sB~EK!AS-JF(omt5AM|m_g%*gZV1jfH|Y=Sms>xKo*740PH}L3Z4rQ+X3gC6iLaW=V;|i^k3s(dun&TNk;#QrmHN$_I z@xy0mR;EnAe>Qw?5$B2xi-JN2c(KhPb={A-{M27t+>oQ~_aCzu)u~AjJ{e)KIzNmL z|2p#w4a2)NVZ{5;=xuQw>b|-R?ur}yR3I>hOVaFDJ}rV(!TCvAx`IdL&I{gWu)nr` z`hjj=&KuA=$`j;`Bv2$?O3erXY`BVnfCPHv>D_Kpktp1PED$?S(C_hES(2@T-#I+T zUF?yizVFn`tE!{K0Jr`k?xGfJnC}A5*j_rl6&Y>YvN=(b(l-`!|P49G`4V#%9O}x zoX{>eQpZnG5GeXr+-u;!Kjm5Ez$yZUA=K%LBH!~EnH~yYYIYM z(^G9-4YYx4VJH%Sh{P{?D}LS#VLBhe=bw1~uBE|p(|d#laQQD-!+uOy5(m2?yDWv$ z8(V?MW(Hs+Y|N9f)!U_5UKzf}`0>3)fkt>Y)|bqy0C|cZ3lvAxtfW7TNczos>}#0{ z$a*sGB!i_RbU@v95PihvKO>45FQ`I#3$Lm-g5B@7c*b69CS4sG5Wq%G^FC+V*+R?f zTc7~D#}NbZh4&4Z9mSKS)OJ_-*cQ)N7WO^Ff@(ac_0Pkh;nn@M7)LcMJWM`LDnE}U zYG#Ncm%r$1~0vF`?&V`SfoiM3&}!oOcb54zixE z$=jPBTg0Ore3S9*cfM{NlpA4$2dY7DHFm#3&`}J?o?zNx3$bUy4Fq0@^{mB=%2vXx z`LJf6-Gdt;?OKE+GEzV^>$Y zi()=Fm4f{EH7OLanXmPHe>oB9!hO<&xY?Ulq^LeK{BJ!?lnNmvP=1WA{*R9wYZSg4 zgrwo8@G{H{?GmxM%Pix6i2`_Iu9QqVa#Jc15Y(u+KScAtTCuS`Ff36C1%d%2Tof#@+W!k=r@gj|7X?K`M8pJYtzLHf zz8l2MviG)uF4!|8G2-7U1}OsiOoy+;V>8ad8WDR0og)p%B`o31sFOW&(_OmnK4Qk? z*W)y(Xg`+vtsl8zeHf7`C)lD0*AMUJ!i(g!;m=4IbXlPJaz5?#KH?Q@+EJiKFps4| z%SN_@S9x~94l;P=Ba&8L2}1!uc=X7KtWCjtkVNb8B#6e;#TAvIOd|shLE67x8P3ru z34Kr5nYGuiylk1aH*t0V1$Hsrb3w-=1XgXO^ado-Nu? zhbRbBw4UY{Z6*bK(%i%;22Id$BogMTB8a=NreTis{7Rpbk^6m*+IK@$302x(H!k%n zAkoO!FlKWa7)xdVXQ77~qtJe_Z$BTp;&-2Y+kQSd?k9A;2HQhYEnBh0Q7SVT#c3xL zlfC`{nM2FGt{v}pKazX_d4?@5AH6Hh(>6(D6y(f2#s%z1G6R`hX7a%F$ly~TzTQOd^`&$Fm+)XBDg zE==i4L1;|f|8j2$?C5CGWfI?Acp>s9v`eSeGW09U5-T+0%|XsJer#-PR$a902P?ZM z$)1kX5YK!r@zjjwb3>slRDZ2fEm+&>w-({Q24E`PAyU{a9c+VyURW%ztn8@Rc(>Fv zq_HXVqWH^d7Km|+$)mcx*1xJ#C#e^?KwQk|DHNPO9QldkT{sF%j#s(PfbVe2;PGpf ztjaGWh7>;l$EAI4I5$|{@2VK{UQqiojw?^J1t+k14f<$H8{z@s7h}3)I)Aq4Xt3bW zCVJ&B`5MdjWA6Ny<1`4@o7=_qyDfyfgL_Qvo6?mCkFhgdLW%+~tU~ig8$k%6Kh`gK z&yS;Q0gCePO#7#T_6+Alzxtho6pQDLD#N!w*Ttt^SC7A2|I{K-lJ={3k}0b&y;!$| zDXI9lO`JA{$t3otCoZ5)-78ewlfs1{3R($5zC}qlRq0h zyEw8!HV%KgjWdo-9ak}J?M;q>jGYQv#E?&Ff)`ky#T&my1L9c#B2`<0$7}oTYXMOq zq5JbGT)&%5QwRPEboDyEvBH5UgWCASbpgOsXD7=fx#mqiN%CX-=^0NImhiAYD}+I7 z!{ca4(5$Pw1@pkivTM)KYe|rL&T0F2?D1pDXA&7*)q7jxE>^t+Qmtz^21->X<1NZh z$p2Hy$@K*qz3cvE4%O@>m34Qk*gg5UI<_^pELv8`k*EZ`C~Lc))lZNCDrn% z?>K;{+dK!%{_2xx?Psp#b2fgB&Z%_iJEN!-2O_ZWWF$f));au|zQ>_RN_eag^x=z0 z((cnQ2wk~9@d-IA9Q}9yH$|Mv=kiybPI7dux?vy}I9&X5`!QcTEjf{D@Es~4$&}d2 zFVnOGEM(cnkPlc-6R{!_5_#X0HYh-AlrbO(AaLL+SbaOXBM zrKSC$fb#ik_XDP>N1&zzxtfH){r8({@-l{z?V?MuFmurgvLF%`g&&R0Ad|o`jHM2d zQ4e+k0UHpvv2D+{3?Rh`ubbOgTiW_9`IDKP{%^G3B62;76wk~p3;8$dQs8ucrgoer|-wx=dd zGj~0WBIRWysXuoy{_bQjz8c|m|Mkk>h5O9~B3MnPP{-=Eg3r}q=Lxu8LAGQ>yA>zu z(JrMhOO{u4?RFC@tJxN$+d9PR+GRWsYl=#gp&ba+v93lb4i0dCi&VkLdNf@-Txs;& z@vhAFJS+G&k5qhLvmrtxQAORnN>`00k-Z!CrMWaIA&~HeI%}SWPAXyBpDuJYk6kq> zrsp01*GGfvHkG<>S8E;kOalqF>C4S--vy|(n4uTW7F}MWo8>7v3fn~I} zq}l59AW4?u<*SGWK58qO01ZQ!vYP3fYSHF_CD%bzy@8*oSOfd?0H$<^^NiO~Ol%>t z&W)NGrn|flIDd8fz=el!6&4njBvdN;q`_9Asp#!iK=P6e)?V(!&yb|b>1`u|23Rr> z2Rpsj(pArAndnv_pum_KJZbR*-Jdr06|;P^E(LjY8%cdzCcPV+sk@h8MD)>>x8KFA zA6O8pli;TO{H%Hh)>NMeAs8wfb7V5vyCEOvo6vKk7LQ`*avs9(N1h>L<7syaev+oR zG0C7~F!)R5I%eyrsa$k)2kA8rHD`iDM_t0y0B2wgk{{7&n|1T(ceaZB+Uj$K5cfSD zHXv~Du?^*~-9l$V-N&m_A_<&Cm3a}3`{pWIs%hyJDvRXL_;rJ}6ykjjls`7VQg{)M z)%#2J{0*D4`onw+Cy%uCvtKnOlY)y;u!>2l%p|mFhZKg~K4V-5YFvuWsC8|$vg5SC z5zy#rnRk@Yrwq^HB&eX8)O}7ApW_uNe&B?sktSFj3#YVy+gs2V8my1=ns)hcIe zj#6KP*u6)$w6Mjv`fEpjW^)hb$IG%$y*LI@q2`_FScBD6F+ZCA8k#siOs7<|-6fM0 zKTG@_Oh2}%(4pHlF%ppH^9(pvk>k4W!e1U6_25;R(znncamzf+p2z(+J>=DGGX%BF6uMl~LG?1e zF*WVLtRENi6FeYogAwM3jD-;L#B2N!gE#nIE=_X!xfV{NZ>v3{#s0n~H-B>HKoC=D z-+upljLLyYFT)5Xrr5|s9j6c_^Wp8EW(f#!&qaRS44puR zrq-~RlMeml*UpFGSa_Jn<(QyN7FLQ)j3#4DAdhfMk{43<;?R;yNw}XL+;SQ&AsuM& z7Z)>NDV!<+k$I^*I@H0^(x6eVdTV?6P6d9|;>TMi+N^o}IRbGt!tl-GZVV)?oo;;O zw6nG4*!q~g{pk^h{eFH8B?Jv3(WCqRWr$}s96nY3NjJnu-blbNCd{9qD~)a9!?I|b zlZdnA-E1%eo5J$UnUw!Ob<0h7?fKwKXQmgg>G3;X3i0YyBAnI>wBNR#F6TUOK2-|S z{mP*((mB+zH~owFKfjJl=L5k_e*8)TWe+TN+w?o4ei9J;nub z0_1bWFa5f#=`wc%fCRV066tl#pKF;O`>2^dE>N-ogEQn2N`l#4*1SVx0MymU0$HQT z+Rnn0tX(VpHVlL}FqkN%7f#tft=+;HBWxz$!piftJ&X+*L-X0H=jTfe=IGj&qebXX zp^e{auo8vh033BE6)XGxpcc7?sg=$JB>BIG;UvVvtt>m*z`XaaXc(%@7cZqT2DhW7 z@@%c4WCm1!J|>Wh6BET}Cf^=r(w^D=3;^atEOzGw3tXVOWtjxC|3+K*?PHq3j%JFN z-F}tU?G}9Dkb`b!P#9tsucEb&^n-7RxE@y0Y2-i;7WcF`Lyku4!UXZYnc)fZ(>`}0#3q7N%V+OS4gN9rgwKa< zN`p$I0!>f!d+S|sh|>zF#l0F}E*S<#80eA|Kag6&_6;#HF^4hX;as&o0-5B=!L4~#(wV^=yaJwZbTTxfT){n^)NbJ3H8;W_x^1#2J@Gh z-~m`rQWl`9m9DZ6TbD2de>)oItM@ZOTFRK#wr{ZDXXp{I;nOZ*S&_Qr2rNjidZK2$ zOwVIM#bH3dsz2h_QJ*vir>v&pFP{s_a@QEd)mZhFV|v3zCYls| zIe)s5H`FfP{tFe2JAB<#h?d;b_hFep7p7%r1FNs2{Silc36xI|;RG71p|X5DY$|Jf zm^|(br7PS!Ptw+(Ut0PqbHHAH+$temtK~%PZG$+Q(YgFOB2$MVk(5XYL-Q&ON=yja z`9}KJGgO1*1S9teeQ*XfC)GXufDTtSnuKX9;jpCrnq~_=tjkO!z9J$mhs09QtJxs8fGQRu_5ltv)FQCof>T& zY4iyml!;mjU$++brO}wajuBm=+op)W0r*SP>(%)HD~e-DfX_a%K?8c>W0$(dl73Z1gT=_`l}WU)DhQ*U>@DDWHdjXRPp^y5T~-Ob0JjX z70|HSVM++sV2cPs!eCx?T4U!rJ&&(Ln2%7r#UmHfripC8yblO#dELxTS~Rp+A2z+Q z#iH;7A%>r4EO1Eo{D7A#TGj@-*SC_^sLz`E%xZe~ahj01I?P2lD}&#mqO$Vn=qNJm zx0g@A-~B7wa^#${)o5uoJ~gqy}*KD{j+%aswzZCoc&e zzuo=*$a%jV>w7Ey)Q-ttsY-AJ$UsHhF-m1+j2OpoQwD#2yYua6ZGIx2=>rmzP*gqt z9qu#;%T12$9NKVB>o;Hj^>xy3`N;T^8Awee;jMPz?)m+;C?H#^=~J0t1+{6xFdDAL}e zFa%DZ-DP1-qS3D(=wvJS%f|r`yqbTx(|lbviqv5`w82f9cD_>zj7xd?@f zdi@HT##hK?;>=&X9BWvUc|}7KlHl1L`3A?r?*H#leFI;f?DN>X7^z9NkCDb)1EHIF z!urDSWih|L00jsK4g*?5M>x%P>B!^Fz?n!)-qot#(2UR-!6`oRJQVD_ZyUv} zb3;i&&B7z@%6pwu>CBE802bJgeafkiMnyGm!dtSN();bAAf1V}pOeru+r3ub3z2|d z=gg$2hB88bexxBxu96gyXAFEifp2e4r5HEjC|J!d0VlZUC8pm=C0kO%)4sUA_ubWU zO9LJ}bjd44rEru5TRt>j1@$WQPs@;U_Ij`wXAev6ycLkM>)5}l+@F;(OIeAau(vBk z!t%6=yh!s1g8+(`PaEu%vHbwy49# zsUF+$eB^RRC~8Os{L^)|@vC&+ygcbNGApA=eBCbIzik}SPasWn34ob@=nA-$Q8pK6 zyYSVgOY_|w`!`aOngzjU|AjxYbErZjvkct~+T1j%3{|iU=@sNG zKTb3E0bl&7P-d3+D$hMBnF134ZRj& zq}$!j1(m167Xk4X7QM*DEd7S*J=^~lDzoNC607_#>T3(X);3oD-pdK%!gE*w!=FJp z$o&v|hF*rO4PYqdbo`%DNYRf|m+I>j8Q@-2E=ETplv$sTodbIBGgMNhLSCA4GN64% zg!CLEZsa%aFWBW!cqi`sIZs#uBm}I312>E)yVhXG=&Z!Aw@86knu4P zSw;NJSwa$Vva#>i?!I@->~}T%bRkadcTTk`{udu(;R7W9f~%rGT%D2x`=wYu54Bl6 zT23lvvPN|ngBbl!7t>|N9K0fg{=<@v>z>s12(4|!N9d2-By8H=Ggx`GHv4o3x=ylk zVZw<(UC4W!X!RsUuzQsLHA4wxI_D=ID@i-uOe?Sw#Bbj)DsQp`dd67an7l7o@Tsfu z^*uV?K|;HQg`v()2n{UnOuy6X-iRykLFUTjsN_h8pv<91RXz0CL|?76J?!gsi@b&= zSpv{MqW1~jq>00oJCtMqmhWDncEqyj+SHO8b56|Hiwzl9`~CxaRmv63O)k9kPCWAT z?SInfFLp9|W?MI(b(Ve$K$V1?yj6ZT-pyrOzbJ$isJop@^y$4qoNGE&!338GNTO~u zU$rKyJkIQVH9t}Q-;zVlJmPkqSv^eAT#O?MU0%RqG2C3%(BXr$x5q?=(!+_z?!{3p ze*+Y56BxjB+>tL2K_!H9IA5tCovuA!qxZK}g`AC5Kiwa=m7uaa59*DN$oz^9S5m9g zGFWJzfKk)QsUJ0><;eyqxNR?(3M`~%M=nNNmz5~}42QF1`v*s1>f7}YguaO|c)H~W z`M!0agfeCheCeqqtxV}fp6G!NXWiz2iPSpd0*uz@>IHsh}n4)I&VfS$J`@zb^hNttp{UbjC zp~G+4JN2oi35q1zWuHHIIR*Ol@Ki9%VlbW@2%=@^2vfEw3_Yl~@R#a00{eDz1HUwI zm7W{!jDNkXO1AJ4_oE}i3Tjk`$uXrC&YPykOpTC*IFGSFqZKw9HDE&KI~qX7-b)4{ zvntapc0m(Iq4}@%A#+yd2=chbcu-rtbbqe6d$_LOs*0gqUPDUDi6!w4EB8u`er7yt z)%$F8Q&pUTZgtWSML=r8G)l@iu%L;by>J<-_6*n#xZZjvi++r6XU*eYCjM2mXwV~7 zsn5&(KfBtm1yW827EvcJ6lm3d0f6|=5>p-_MS%NUJsXCWi`%@zHc5r!kVjO+}HN_(JApFzJyQLVtiT0=4Oyf z%|CFhpw(Fv3u{DY3va_t=y^9q(cW56Y|WVESwRZ%Zv2@=DNGUalMp+e97E!iLtaVO z9HYdboRWQHra7-_Kk1D|+8dnoWOhRCINmGe*uNlb)y*AtUi~2bXj_sz3Xli;i^L zL-CA^07#eUe+PhW#q8H~TO01N^NXlO+}ldqtl%#5eaANwa_lHu4 zR2=k-GO<#2vQKxB0w>UazS?FU_h+3xwQwZUCwYgZ2WU(Iu$e@^jD7U;Qh z=ji^X%2iLd1P9S$El+#mE;Wng(I3=SWXe$|P^?7$Gh&j?UL<>a5}CZHo`gjRAtrQ0 z_dJ4M-8P89^cPpYKh)V;jDBqdsBD^IC|EO=!;eYQqbyFEgEK0x< z4(C1~h~hJ%;LAfVje#{J>R5yieGp`q`G}|Q0K=5hL{~%!#NhJ(+dGc?y+G~+ZM?7x z3y^0B1Tj57e?F5zw^&-MG_-B#!cl$RW8AfKd$?a|;CF;PhqKnO{0M}}q7p`L_ZR~b zD9>;pK1Mp!4ABn0#@_Cc89BCXr}1gN{33pD7j*U)yKwX5ki4%{(2$TsE)*L^AbULY z#S|t4fdy{>qXb}ekuNJOsKVADs#rYC_=1#>FTZ(}r(DvDj40}7XKkXrsF{Q8 zul|mqM7nQUr59d$7!urpK_^ir3B5uIX*T7FtbI+11Ss?z{YXFkIx|T4roeEGZ~N1U z932=Gojt$onw;_|@&Dzz1K!Mnex)|-a~Br}6eHjB(nsG%Ch^3QOZk(z-$~cDc(?GS zQBnUc`2wez-=2PmBU_2_gReMN;6x&sTTemK6+AkGF*l4~_MOcYkTt%|cYQklvDOZV z|D^b)NU_IbgJ~B6^n`0;k??aq{msb{^xoSvc+|-Id)QCs(h|y-CCk#(V3Oix3{8~O zwEU@qZWOnok2SM7NY210zg@_NMM+wd8n|q=PCY|q2^hXB}=0wk$b2x{l@$}D6FKGuu08qk^{au z-p9|pxzPJPW&6a1ZkCT#k`z4*M`H9gYR8%sP*pU2_)|K%0n5@YOVq;iw~RtDA&esw z72a*t;DHiKN1!3GIl|~A(lUrOq>Gb8zK(x;!GNkkd-SnggiIFV8|ZZbzUirJq5>LT z-(lZReovQaw>E@HhcN^Sz7_>XN1xL zO<({un4@;0$wSRbLOK<3l|9d?d$Tr)+U42yZL$&*pvUUsR+#D%{JdpEk=^3aw)%hB zhFrs;N{n}E3z&ow&75%3h-jG>fFovX3s-hW?5$W|-Z<|LdhFy?joqz+yHiV%+q?EB z?P%F4UEDH9P|5+w7~KU)rYi^0%WqHsK$?!UO1dLvkZ<;G05;`lAHU!XDJ`7~b>ea= zI`i*mMFe^SdviBkg=5~k8!TLXVE}{J$M=_JTph-kucYx%?O<6j`0&BOi-ZK=1?>i% zSmk2M>UUbWp0o&>7xng7`BCfb>U{$hr~cz#?e@nl@^-Q7sXNP7vYf>|3fnxFBp1?F zqHWDIa{5%(_E$NISaD14zvZMgaw$$bJy^K2ql%WpsYur^wQ5?R`*se-C?=AT^6ND$ zGj=AZZ~KDQi&-$l#(!deCIn+nHTS0_AXVfbV9(z522;((Y`~!FR`gDJ`aGtM&7ZKo30@Ti`qE{7;c{QY@}!xKw8BgT0)K2L8R;D5p|dlnQ34TNO^0jONcSmWxhWg|hE<=YZ%%Xz zFkT!EM*y{L!ZyLXJ-grD}$3Om|nePqXy1oswms z3K1yavvTJGH@Zlwj6D~q_`DGOla2v1tZ-vUJ$@u=Y1>)&xP5s-(eucDwfSNlldko+ zD-!%&K`1v~R-M89F!-ee{~`gsbw`n#nk5IFySvAyUskych0NoCRIMi2#o`XYH1q*kS@1L*=*Mv9~wY z?+Cofm1W}~CxYBy?6{5i+ydfyWBcggRQ!=sd-ZPGg?l0fErT>YmDiXs4M+?S#65g$ z$b28Vawe;R0T7~YcMqC!A5zmQhX&m?Im1qh>svxE>IusDB-AvXyS5Nbe-jrJI9zSr zelBxi{WV%d>OJE(n z_+8Y#{#}G7buuq?Uz1eNCyvR)L>6e>Z{7UNg##J`#PpDvQ{Uk-A)GvfI zRpdM6EFpG;)w(56T|DnL*yG!f2>ZXCZEiR3QNqD1}GVtb^@-G`tU zHPHwW>!y!2>~q!o?lCQhm8^`QAYpR9)9yNc{g_$3A#~I(c|gpIxkYent#BVw>`msj$|#SUDV9-NLE&$`Mu-@ChYOeD@v zNeLc}^BQx`irOhPOx+-leBF0O(&!%khB9HDiX2_tPFrth(5~UIgXnpf7P~StraOVy z&H1a;;ow)LA^Tf9JC`88dS{1;!Sl*OD68FULN;rKaflE3!rXu&gwQgPUM@9;X-(&I zsS(9#{K7r_NBqr@nE_^%df2b}Cif0|6~+D=QT#x9;N!`CJk1G|d#2U!WiFviq6blb zF=Kf}*3D$>({f~r?-LwUeOLN=ZD)zwqnmSQaqp&mQ!unCg-~X?R#ua@G3(Oz%-4g# zvbg|BAmLAdAy@*ZQ-{L$eDJytwr9b=a7WIs?nW#6Xx}50t+c$pcDd-+dUBGK z9}j9E^jfNRDU<4SU*nIOa<_Tz^8NfRNdoqw0pxEL`08}+xYN$>5PL4Vu$h^eg@Uij zd)RxK<7b&zyJN(*u+g*3wCAkPae&q{pkswy17!B*((Q58cHt0om*5w03ou4+0n6o! zGK6syt`Qx@88(YB#>dW`u{8db>vM8(;m5V0lTj*ISj?FM?M+W`H6O<3rmu<5%a7MQ z&Q|&rrNmxA)fiV)ndRE=zWbc@U9R=+L~{P-y-LF2Czdy!WGoJrL=oFo(~>T zd6`EKoM@qR<@$jEw>6VhH+%gptA0FUE#!v>Bu0t2GWMg9n zr0U7+EW@X%!<5v+o4Tc)CMDi}r)BU8;UniG1sgT5vZk3BFve@s9(mmO`yzoOX9sF~ zzJ9c-)8guWw$U`C`^wA_M~#&cwcYf!YqRHZfQfg7Q@k7c@rWT3%(*E{7`y^qt>7SE z?4`=aw(}a#A%0z#bHJbrrA*wUxl#QC%L92I1z^cbsZhY3rWBU=F27Do@dfnbUXx;c zuu*ZM`A*To)Jaw8E`PI@NZHacC#G`I2m3aDR0}YW+=Rp*#ni!0okX!9Uw-mTUEs|k z?{i8(BGM4Hd4u}UXxELv8pSWy6mSd6Wu2SzLayGoT;4?9akia-1F7k!gHOYJC+9=n1=L=cFY!RHT*LhkJ(u!7)Y?Z0a zO_k&VP;;-LK&IqzM8iO2q+>g)`)UKEmVwvu#v|2b4?3s9~soBuxL7==r~`cs=e6dk;`S z$B&MfoqVp==xJgwHcRT zPVn!f36n1=NQJXgxN5?XNBozUr<*Om{486RPAkV>_q+hq&}u$h+CISCR}tQ43!Twr z7VTFLF#c7n@k|9ur}&vxsSpysnaW$(#@kzx=u`QDLXUp^kdODq8KyC$!loc9Ry29H~P58_$POFA~2bC{BZ=mAWDO_K{7h?6!>#UL$jwoP#Hke-u| zxOzf|2E1^Y)o=DeUSBUT(-}M*Cm3@lb%kHeHwy33sNOA~k1a2gbM|LsSWtp#Q!ehj z_s5&D{0mfI@mKblffE02hD;$Iq_A4o4ULRYqf4rjNJ~r0sDIfX(ho->?f(B=8BtIc zbbe=#*JT&!CWOeC$_vBn)=^&gOA`8%eCpT|^>8vyHQDUSe>}AzFN59Qkc-{!J&D~O zyFXqVZs5A9l)DdA@%`dc+#~+bc;~Zrh=T3NiWv=bpmFa-b2D5#57-(v2bgDm~B`?T%vo)>ja+5WqF~|ru zI=zp9ugPQ(66|+xDX@9ME6m78z$v>vJ%h^E&bBIl^%~!s-kqki{@DtOqq;VJzRfA) z>LOY>49EO0B&^BlGkca34{OBynTigrkEf9*A@Gy5H&{^cJTb5tap3HhyRJ-qKe`BB zODh_St5J-?9+{burAVJBR>}RIu8IjgxUQ1t;m@4mpDEMZcS6JuS+!hal1`9WpI!Mp zh)7u>Prq7;W+{P@fiRBY6`##$FGNh3j#i=;<$-oZ9lHoEy+*Rx=HMI!GBb9=vT5s) zrQO%>#TqTsK&yMSHjT|H{$Fpc_3dN_!2k6)t%hkw@JsK4%&U49dQUI3?;H|RFw&RY z{?CT9Q7A&hD(BwTo;D~Bub zI{_LRy|>)SUl>t9zZ*OH+ul8^o9X0!mPm4gC?z07-aq(Er6{FnZ*u*5YW%iavS><~ zIe{Kao*s;OZz{Qx)2^WQyPuE#NPemR-RdfOw$1zSpwr`gGCJcwZ?2M=q6Cf-#w?Y} z729$)6zFOl-j#Js3^rL;_yD030x@G>4|LoG2FDHIuYCK9=kNb40Of0w z1nihXO=I8-dR*(ih+{h%*apfj#LoM7i%Q`pX%-E2O*nkYJQMN`?bvV{yD-k2T zK$F+Q!rW!W>dNg{nQ$c{{vap1bQ*6iToqSOU>{KxcBOB1k-rPU28?t`D5H%SCUy6{ z5ZS@%b<@}9Q{#Dzn$QuIQ>s~zO^}d0+Axj`iL0017E+^49*R}j$<`1t=xKXz)xzD^ z1V#yioPqId;T8lOn#2|%1%bp>(f@wcrwKL`AuR03B*rwQB7iUz$R@xz+g)Eb4QNX0 zE3MorlREpJvn(o=4|GK)u$OSf)bN_@e$zwOA?uyob4J3qm)b^Nr4Ht?N%Tm|jHQ7H z68APug)Gr_pPfC@8%@7EH2jk@cS#9DNFVVxkNFUM)H-_9fO11-m-T+Xmd7req?DEW zbMmLTn;X9&Ot~P*_P$3C-co5NxIRqQh+eV!xI9SH@RyA!b14Ls=U>5>?42$+)5Ho{ z_vYsMiIdeHd6+~tu5msVV9H$*RMT*zFD}-6H_yC(^klUEyEn43?T-lD*|jT&#&t?D zJOAdkPuAN+R59L#`*jMX+6aTn&+se?Qh#z12C(=dZ~L?Tl2C&AHE}A9w?u%|I}HJt z<^rSH<_Vj$i0! zNPDJpIPj=)U53n>;vjXllRO?qXT|UL{DfMUZT57RrNg;@4BViGprlpjua3lEG7B9^ z@vcr1PK>1E3o<3p(b3^!CSZVHUSX+)GtWH%|(7fLXiuMv^ylei2U4kAlL){3Ch7~tb`;y)A57?YmG2UqJpP|z2|ZS( zd-iuDT$3m--K~oLXN=On=;lxFMa<#v$clw>5ptos}fWWcyF`72}>Bx@_ED~J61u- z3p!t0qt9@0SP{EFhAwLx@F01}+3TcyK4ZbE_CP$u_yZ3`H{vFH^s?uzd;dN(lTp^OEQEcU+2oTL-hIf0*C7I$mncq8YrPjNa(1l_q6FekPeiiX^4vh*Aa|2v`mav%yzI|A7 zIQz0nztC_ON+4NB%_h@m(WVo{4_zuT_1x{7ZPd*O5qOs~1{nk(&{${#x-}WU=q-9a zOpoIqoeRC{w(SbuNk3}SH1c*FamyPH!&)1@P9o)csh~a=1PwZLT%xjDMGzxARO-@o zm^4c;k_2Md(%zJ(_{;wqAg{imeU<5+83`x*FlAjANMDRTczif{yfzV2=$1)CLla#D z9p!0R1Yoe|WgnQcEgoGP54zBRKmx}hpF{dC#N!s|%yFH0++r5j(X%1%9Z|BGwrcG% zL1)n2&8!Q+J{6%m#{?U}X~oZFyX|B6z?sB%m9dWW zqVu99NY9-;nos8W4VjvsmIt4InDm+jcfuB;f%$nVK~_D7d>%cXoD~YJF01{~V#4yP z-~LtTQJV#UebZsIk=Q_18KXkEW>}(xL>Jj`v6n)(=UeTD^96V)#V<8~Ap9rFX?F?2 zcZ(_QNTrGYJ*Ud)g%)Lh_zG56GnsV%$g0wkJ&Z>-<=5TK!*|&9dgr=QJ~90_!ksp8 z>Nz9b|7?1b@?ACw(Fy3UvW)2-THEqcD~_XsbTsF~Fz_U1Zp{(jZ${f2Pxn{npktQY z?=_%Y+(IN$vV{IR$+v$_Pn-JHOwl#aS4jJF{!@tjv(&ucwH{))+iK%c6~l%d@{-GY zSD{3Zf16eLx%p8~SrnQH1DAT8{`@4&8Ud1`K>^>xI`6nw1A$@caGH8;ON;`ATs84n z@@=z~8@M4nZcO@~!yWa`{b=Rk8XC*b$2Vrs2moVu^jKQo57i^^(G$Jk|H zZtM{Z3^ik75m@#*D(!w$|5fP1vAliL@9S#9+k9*!-nQ#gc7#iQxH*CjJydY@Ye(hQ z&PWoP9(um1Pm$>2w#PhP3ChvlvFw!cB5Wq!otzfcEG4aGJRC-GbJpd;DM$AxH*~8*8GVKHX}oCu`o5Y1$2FM z+#bC2|3}eTa7DF-QF!POq@|@px}=ei25Cv9ySux)Q&Li5Na>Vrq?;jxkr+}s=AQcl zX04gCzWL4@dp{d?g_7NE3I(v(0;+N_OJE3oml`9Zv zWYOC_tzo>uoWYYumU&Y}6wfvpVf;LKKivLm^VDVS_+KL)opdlGR<@e-LYDY*F$jSm z`CWOMfq;d#sMzUy&ZPeQZgZ~*f$H+bCG|=46Vk&MtrdbOD{%@eS^{bH1%0EdDND5& zZcHw&ymA?R0M+9ul7GEpXlB>l&``OD3!Qem)mpyC5WEycVapy^S_3?OKOtw&i$?Iz zB$082fh8pdJL{2+lOOU`5|ff-N;UpxEfLC;#jTfo)Xl_MPyXdk6MWA?6YN7wSGhR(fO&T**%>WGuVEe!9@ zg#OV2%K(UrTJpuiD_TuE`(&D7C|7vPfdiXr!=c@c_{@;wn{*5{d=!9xeB7u$gFjJ; z-|dU9slyL0)Qdg3HEa-uBh4t8B@p|{gkmcc-Ix$@nxs}IB@#^vri4>U>qjggwNP1` zr3r0i5Y7=o7>`WC@GD6KbVi1RPAdWamcYxSGS!#ElSRmjsq@~AI}hXVLL!rT{j9ys z4E;XmG~?Ryv4qg=#8tH?FQ+V<(4X+=qWmb8v%g+Whq41EU_IObM6fG12IlDGAIoOqwO2GTUQuASv<@ zetnw1CuvmSIi4o`aoav!I%MfBMRfY(JC0Rf@xS@#2i)u+>b6+M37-$yZ|28l^Bo^| z%f_oYkGmaEzR<_mER2yb1fLvmUU>MaQ86U7M7PEj*DsYz0@^HxZc2e;Kw%9;_LY@f zl+I_dm65m3@4dKJLpD|&9UZ(8-*Du8)GEpx&7J{FwOAkR@}qGE&c`#V0TTM`%l(zr zOo0brWS27U-9-kUui&D5XUEJU?!!k0)(ta@`tPwm&b&vV18q)=F}K0^Hu zK{Zou%6^Q+)Drt@cKdl=So;Atr8CLASM+A`>b1K9FGIbn095Yga^EZH!l?G;!i!@j zvetR1?a1SOv?-;v#K(tM)u;5^#7+NbW@HfbwRV+wULjJCihfbstw57&5y;-?nz!bw zBRUIr!RNE9(ighwmyt7$0ZDMPx7b-;Z0)yauytN+p{4Cu+qyNkU(~(ez&jc`b*v}B zMrBLd-cr=kw9FRNAA*iJ{v;|$1oN0n4k+Nl3jIp%Ax`ZABV#HEm z3UugFbNYqmOyW=#4vz|@yY>g+IX3W+Q0e^fb6AOX1wkRC?#r}=Ta)uv?h45>n=-Ds zx_mTqIEptqw#=^|qcvXlg&)@5CN8xIu!3ceB0?0z-s;0yOpSJIq~4h(#Y!UA)0e&S z31O4BB{saDg4@AyxJ2yo?5fm3?qw2pe9%KfmYC-a^!NO_VLc5q~OLwS5`F z39NrYK3HtGh!{bIQp6!k{`$`fcZCNc+vv6AD}GRbJdhFcX;UQSc5cAXHPdtRY{?xb=2{hSi=nBeH5;u3fK z{7rF)K5u6yto!;j0|%!TLV-1$cF1?#@@6oGhX`8&M{G4M82xTOsOb?-VS$~DEH=H+ zZn@-+{#sShsx-ZINr9XGujk`9NdN_^y7%Ke4&Gs)*OIFW4{!ZU`Q9|2UHAETb7;ICbrcMz_L-WD7`@Sa9&Z!&SWS7=-qe!)v~iq`$@+aQ^&4LFC1Unr?7sjwwP?` z=R6fO{1=|}&%jds$Z*J8kcNIPZObfUY3p&v*&Gbk0#IKXk?E#q#38;y)0cK-^^ju& zz!kE#x#`mw_V{K;eS8mRldzv_)NSvQc0vObO*3~OWNY7b){%^X&X>&0XneDja+P?m zb$R@O^>GB@kJKQ7{=&jq+j&Xvht6Kl>wHKT9EdKti4wnYf=ta%`Zh@RQLBScujAP{ zo9cbOZ+Eou2NQ(tqZL1?SJpK-2NucC@(4~O*T~?40Wc8S6shvXitqHrnNd#xy>KEDnl>v%o`%#ghqagRS)f7-4 z!6%-xpKHF?Q$LpM`Ig1+4mnQ&g!0ag1C(j0Y@EmfTCr4=9*V{wOw4;axJ6+(ZB5ID zHEA!(e6h>4x=3!*I^Q0U-L-(zdTZ@AK%b#s4RZ-R&sQBCn)r}Ul=9 z5L-*R6%frO?Q`AF6S@RN#nR#YS;a_DdO9C6a zs9*8)DSLM3;pU&ZFjO**$9HU&?9h`Yrn>aH9BTaRmbqqGI3^rntj*Np!WRak3^3t6 z5@ZY0_ifPyTa88l0q0eVH^UDJ>t3jGn`17^NpmHRR-$jC^7mxxTHP)}4ebu7*>-39 zFc$yPI|gM6U0Hm0;(6N(*zBP608O%qvs8f5)^Spz@N;02 zJh-kdh#)M$(8c!iH})DOtJQm1?jY&RrV8ZkY6WR}pMr{co6NEf`)Ea$0)W}rEpgj{ z!m*f=>HqX5XrjVrJ-=-{%hLkAW*k`npBFd8jq;ltT4CU=RNp~(cHRMV$y5>h>Fya^g%*xEnH1pbTU+TtP`~84d~l^$o_9$@WE72<5iSQo<|LHW z^^H1WeuoKEFG`w5$BDd3xb?Bp4aDB|4jrsddPwRZiHwUDE?R3^4cQd!alerQ(yVNr zTl#uy1(a_Hgh+Js>TLb_3MSujn{^E89P&OH`ah=`J@1`|6&}xKW7fJ(hU@zmDHmaL ze;3J7+=ow$?e>9!B_x>HqNV#|LocH+2a{fnec|W$Vx=n*#Nl&@qPM=iBCpGBB11CB z3-UIVG^WSj)+pP>@Gk)7@cJSo|Ee==D<`!CtA8F*Q2x}1ftzoBuJ)KYnl_n^90EEs zANaF&iBBLLkjmK=6Cu4q=Mh0*%XK>EBY+NWlSvGUh?8LXc5kh%eRJa|ESI(o2!LUH zS;b^c&#prX1!-8`gJnY71 zH^Tz0w%H%~%0%Q+iNFFuB-BwS43Kx1|0JqTke@JpN|aNOc|_wcq^jEFC)78D6OIx5suSC zq$fQU2UDkr;VNd#tL|d&T7w#i+Pr^vLy=vy@yiL)ysr0Tsn>r@ z(1}6!4y3VBGopSiWoHJ@P!b62@wpZd2hey-hAS2Xd(kzR%{Fo# zyi5h`Xg_em5WCS0F$hD0k>09|3>`E6^upzZVDciyY&gdm~|yc zBO*6(-A96aNKk;=)nr`V(?tc`c@GR1KA~`r+Oy7X-Ydl?d97<#Hj8U(Xg_$1yNU-w zx3q6dWlg#okqpzIO|=+G$n|Qh@qDpQfnK}_=|lQS{EgM7(zgzvWvMPkZ(vkOloOY0 z&Hl!?ctEE5mXHp3scNx`o3V1a-vI62nJw&a@Xg3_UG=m{K)uJoWn4OQc{DbUe2$6? z!W+=Fa{fORnwhKP@g=wJ`)eF5^92jF#&7o9CZf56QWeH_c*v5UjwS*mv}s_$)h#PcjUz*S)xFQx?xOD_ zGAMXm5pwmlX2W@$NOG8lvNLVZ_CW{-$IvhCs8$u`8`0#4)H9GyU-$H@O>P+Mn_Fl7 z()j|`-Y7a5M-T!<3U&Rt8RYNjR`>j&yAu08P4F$Ctjk9NK}GS0B}r<*GO^$Sb-842&d)`1YvOF+~}#T9AQh3YuS=-f*ovg1tiTQ04uwv3C4Un)gU z?SQ&y1}PQ96#~NL^n~ZEU?T;8%!y`~vhDG`ubch}5u5+;G*2ZGbT|KJiIT#?C%Kyb zz4{m4MUtsJ@R7?emi9U{=&u0@>Hg%HB;WbGxGg@BzXFEdA^ZX^n=W54XlOsFHJ&Q@ z$snYrZd^8h2=Np6_o!BzxAizM5Js=Sk}>Y<2gds>JTfA;sM5Q9o3c_9oa?nvy0WtR zij3Xyv%+yUVpy!nD%XJ?)@H^0A=Op@F(~jdt*{0l(!E7eA*RDaNdbUm=^E&xi?&#O zX@~U3fHJkdg?bJX1&@>@nX2q(^Y$<&n`P#vgJRy&RKRY9Gaf>Upvt$Y$Yu^eA*~%d zQM4&!0oII-Mo&o<+*;j24ZbkHEJeF<=r7S)>%DW1j+Vq_zA4SP?fE~WZG7HPqf*ov zWwJ3oCrt3S{x6N+FZSTm(zv@QfRWv0Ex^ZfmttCzc&+D6@%Nehm+jS8?fCbGYCP+f@7H#Re z+zj;xBM}(A%fDH)1o5AqI&Z^70KBC7kn7Xy#8gRv4G2vuK@Av1GBg~EELf2Yo+keZ z(&f{%47gG2lH};@z;nXr@!Gy_ZoG-bqBD5hwSyV9i_#`d+EiYsB_*eQZxcr5fsHL+ z{d6J2uG$nQzU*iUv$G?;QrA5{akXZx~EGWS8u+tkyUd-ADMAe!3d6U2cPvtZWF`$FWBSE=t`V9l7%cq0`}hojb$6zrP45L||tO`6qQTe-)=R za>t7)&W`T#Krz2ol)*c+#d2}>hf>E%WpupErm)(V&Au7|mu~%T=8r(IyS${=KkI#y zIg_JP`$b4p@F}xRQsd*Jsh9cq64)ck-2yj3r8hyK8 zB(U4%ddZ0yG~UpdyeZD%`3dib1jIALRueFcr#W?B-ZjZdeNwdB@ABFL_!s)k#r_!n zCcjnGFn>N1T^7HV=~(n)5;Miwa*L0l1!qv7Q-6SiH*6e_Ck0Z2 zB&YWu^6o1@KJ2k6ewI)yQ4irB*DqWT&9QoCo)cpnka7F<1vM+(R##NsG9WTO7&*4M z?Q)k@e62PgzT+i+7ge|!c-wsl6A4eP{3%XKw7W;Gocaw2nx^69B`CtryziWpW^@^@ z(gej9W%_=~;#Lwu;sQ0M_cBzlz!(mbK+(x0?&Cao52wBNkAUO?a(ul+h&fA`>wR-E zSCe&pNWlXzqWTZh8sumqq%eu9By8-!S6jZQ;+^oUy_Pj~ubGsS9>Q9~vqQO(K|L^s=as zg4IVcyOHpOrs8(eto$q&5{p2uKQ-QLo;iK-H-Y;>;IN=O_&@QN!A-o9(7hdV{T16( zEst#END^F-o+A0@?807Ebl-`J2v-O(F7m6_;qhkQ+78jE%Do(yUr+yzcy_<7v3HQ9 zyKwm{L83roP4i6XrD1vIde%f6rGPK{9iwIKxLNW2>k@#Zyk}-I_Amm-1I6B=*~!#> z4Yye|Ek@U(pc3tKkwW3Qs8z3fYt zmmYr){4MmG-^k(7?gaGko>-CcO8MJ1bWU>ddb8#t%H!eLXkaQ3*l0zjw`L;8&%Mx4 zlah4UaUIgW49kJP{NnNm5W6$0Oy_9w1|QbNlP-c ze~b#t^*B%Ryb7d(<`#Q{Sh`E(N9nw&m2e%;3O|v-4pxt?f*ya(1bIHD;SBD(;3niX z_eV&)ucYRpWiCsBYIQJ7ni6?C?>}vQpJavM)F^YfpGicgpw_mXhn;-|R`Ov}>`H?Y z7DKKL;ZsML)ebHZOxS-kDD~el0DJSxxA}!*8lpecJ|UtPLH$OQHElUI(1Qy?mDS~c zl?7eg8bCQuP5~tPJlxkE_ECiN9Vg2SyF7mQA{To4>_7SNpPKsb1vT>$`2-uB{k=v1 z0`7*Rhyc{Cx*LHHxyLh8gxkzOJw60uibXPRKDiSe?%XXagd00IHW6 zQ|xbiR9FJofij9l;(d&EH!rKG!{nk*Jonb)+#jsWwRJ^5enMJCNg0VZ2*H@BG<*re z2e&=9d-UxOxvjO&4_^5*A57%q>{C~1acC3M)DBmWqnTgQ-7XbMRzKcIqEeght%CN< zx36|)x6l6`Rao_25$ozQleN6jY(`~NBb@Um2pWHR)z34<3&tiTlHMs($^t;ZVD+;t zp?}W}ggp=#wp^NV<5Iniof~_inUfn2Y9VQ-4y|H^O~HKOhda&I^~cWM$4(%d#9~!I ztd^+fGj;lYG$ly7jnL1bd#z$IU?x@fl?cr#zPXbX&1Y^PB1R(Dh8 zR^t@?^lS^CAE}`L4cAWerRb|J2pO+=slWexo4gjEA@U#nM}rlOu#lgasj*P2u%NKHeQ|!v1C>t9X^Bfza95_c&+G-fW}LeI%^AP} z+4fCTE0Y}BsyP{XM!k13h*=|&@b_VMyMMlnmvDjo{q5KDNuFxa!QUFHB87SNc%q>U zpy1yja#eYoBJh&l72oI2n~skgs61Iv#y=L1)qwmjRzh0KSpHRLG#)U8-}RJLLySd0 z5zQxDFI$KKVfV46vI;uuS)6SQ-FU(_hQy!egW9fUJNHyLVM!9Pv~HTY+8(-}iY3Uy z2}bUCcE0u;Aq_0#fRWnuoZ?U!JYDxtE8gA8aVZ}|#eS}|B=72?U>?3|*|QK!MQa;_ zM9G%mjVMrzg#GHXCKv+J*Mj{Dw94CGBSDKFq8F>5w*gYBn>YD_-2rXLvdg}%NrW64o{dAEo16A( z<1|NFu&D;aW;{AbY9`p4C?Nxbv)j34$@Yu)MViq|*W*jwRY|U%qtDUr_9~2_HhCKj zBOnF@5jh@Fo_cps*tv-9@vt=a*$|tzqsvU-+pnd>swHOrZ&-1D-IEe9-ZHE@bn)1WN3%gw zlIrwdW+Th!YgVQhID%-g-k|+%CPYM2AJCPgoebgkNcV3CVH^5(dABd5=JHzvzPyM& z7ts>2Shy*O^~PZ6+~0k>*5<t$N^6mAfOQ5=AMRHl%e+`O2nPHRQy$zqwH=7`;zlSMu(e>36*JvH5 zDHKFl9zTlP!9BlKmz zT^U|>baG@1gv5>k7k;6mv-mk@D}k-e*{MuN{&yH5$rFs33~bs7moD&8=r-HL_zchu zb728+=QyEEqXD)b*gb~w6loEd)F1UdXrG$xpmpXsj0-< zO87l=<$G;@@6R4D8|YIztGH7yU;yE1^N6ZgmG5evZ$lcHq6P3g0y)7;OC_33swEUPo#xqRZ%`CLqxH8@mvDP!;oNw`~q1Qbh)1VYjM zgkSclclqo#YD=j^TaPV2X}A2Qbf9P$3>p)lL?TKNChbcgt7ss_^Dbxe;4wG(Z+{6*bY5~rlUGK^4939iLx@Hr zQPKFp$MnMx4h^~;@4avJaT9Y()iRyvvG);g_W}AlTca;KV<9GQ&9aPUmT+l6Y8D3~ z<$a@ks@-Y*B6#ZNZv-koSNO^*)-|s#UsVcImVy4LC+5HM;xK#=KeZ1!EBp$7J`;ah zRrS;PK(3m&Ts_+dO;gB3zE(^oQHm7;mEV`2CW|8AO~oF`W3i!I$WkI8=wYM=gBl6M zRpR~~G;g`MSv~gK)d4i;xs?aL+HC#eehIWlykt@kn{RL$>ZVL$jn?v`zqC3Zsp@4H zwMNq$K8;s2Ij*#H%Lr+B%N<0%yrjx;v)r;%|c}w(6#IE9h)cIqG zw6k{*j*;GRtwl$hvC<+>Y7`7v;d3PhTM3CcOY?rnCkB7YRN}yOg!w}ua2)cwu75zO zHqW;9DAh@VzjxqU9|kH_MRmZ)OQ(2Elc!iLGY6-wBwYWU*k=b7o`-K|6gW zN)`b!_ds~m`O!5@CovR;-POf(Om2JJ^+W9!n@`l83=6T5YsW?|-j^ z>)(oXPPLzpGGv=P^Q500E26_pz9pP)P`+-|?(7EI(N$}MGqI_;=kUh*Qkp?IlC3eH z%JpN6i8cL4yz36!F`Gei#_>$jiq6Fo=0rA3k)g@c<#_5d zL@CL=Bwd3{GW^-TL<$v|kP#P=8}ohT@4vIwm(%M`@R!@OjW(A7p`eT1h%^7&PJm^a zTkgE};_z-u5+^iz|DtOlAJ1~30<+=j=F%tVUb8C!sO5hBqQukt`-m9V7y5H%G_=X& zLiuh_(m%9_gX^7Uv?b^n z_%{VAfy8ftn+CAj6Gs0)$79e=nWE(JWpYC#8}G_^-f9czDV?25QOCuc%aJe+J<1us zUGijAxH>uu@`G)9iO6}ej3V6izaHKj)BmEQ7?!uNV#V3XSMLLTQr7!+0B;CJL=6t6 zp~7};Ssr;Vv0Jv|43LAJ&P_*Z;a7eR`S~q3p95H$V7+kfzMwq({rkhPF3Lo=^0SAr zJB9WdwjG#X){au9f!jAAKrFVHzLfVi^gMqg z3Bf=Ob@E%5&cxBmTmw{|v&(^zoGVm4b21Axa9e$12u`jHZqB=pE37FLgC7n-{GZm1 z-uhqez}A;pscR@PB+|nbxee$P<3KyavlsF|L|0~t5$Q3c<7I?SwvN15tXkzZ*T*8R zrs5lV-e{SkOKHcdh2f5g=6Uxs-?xFNX_x%oT)d;xXj-k(uJTH)hn#tJA_)}SJOWt4 zy;%Uqe`?gDRjDl+LdX0h>fu_LGC~l_&xjss(CP>WkQX9=6=8oOtFW=_1{0z(Nq5s| zuOh8@*y#8ClA!#KXPC>aM4cQ<_UvT`=l_jHVv-*SaNlzJpLIe+yk7j(34tI>M)Vfe z#fqT!tfEccPm+&xruR=(35ms{VS><0QPtExq~mrUq&MAHYa*bR53)27!$J$;OHGAL zW5~T)WV=eDt9~~H5nAr25&27&uBZNu&2IZJh>D{T)n@l+Z$B@dS;n|4o1yRwt{Q8C z7It?>R?DV7+F4f@=Q&Q)`_;C&MCYzchw3KWjR@u z%^w>!SJ9E)AtsEGMM{DQatHPO0qI7wr!@cc-d(r~x`rmqS7;oI7ORIK*1MVq4QV>q z^O`C}u;4@kBOt>QbUHaHR#!K2=@zbHXiM%&L7Z zPdefMlDYyS=C^)VkOenlrFQ&~r>UGB6!7RXn}(U8XB4}e!DLZEGU?EE5$9*gSvwqZ zE0vvdWpC5@#tdeR0ZbNC;l^l=!sa=$qaDVgJ&S6yZlv4noVfl5G%zCV7%w@hOH*b3 z)mA5SED%CND6^=TK}br8Dv7I;s#1}L(F&v#)(2frOkfkC+Ew>txfYGRviYOe=oIOM zHVTNm7xcbmUGtuaNP<~aQc#UYIvX_ zE5AF)Y+fr{<(9{=z~;p$mAAEZZ3N^$>zRdROXc}6 zKtW0_BJsI;_UeCVW97*SkbwhweLbb9p_Hl*ysn*B4%gYlnNPUl)aslZQJbX+i{1uc zBqmarsLR$-IsE15FJ(?F224XfP|BMZ0Q5sA(^M1?88wt&jh{&8`6(9tCu6t^>HTZ! z)TMF1#bfLb-tcDui0|=Qv)y8;=gZSPXYbAU+Ji6%Yu-J(Jb@Y)&l^OGfk(5c;_x%t8cH4D=d=D~~V-*=xU(rOUX1>6& zctL$BhJ125lAfVcIkj}4D;uJ`|lj8*OP0E{NFUZNMs}# zN-xxQa;xm(tpfo@c$A5JY@XLr$H0=w>f5@E8#CI^6iiEc?~pMPxB*Z0Zh+5D$20SA}2ew0`h!T3`Dd!TBSah{u-poObjrRW$a8{?cw4X)<*U-Lcz_sBVPU z^LNf-B%T9cf{UZ_DqJL?hnpJ@x+&W?0I%W0H4sb(il+Nqw}?bQpHYfG;x~S0GX62w z$M0fy=$CQ5iz)Aq&&u7Hwm*-v1z*j#d7szcDHe3mYQ%CFwYvga)+VbN4t916J^rSc zf}LM9BMzo|$jH&M|M=f8j=V;^rH#uVyHq^9-3qu(5ZG$-gaH&U@t~JQ9iy*bjfi)Y zvN&{X7j4Vt?D^h08%HA>ajOSZ3u-h`v93}*R#J3lqhR4g)P37HtKo$#>s(D|Q=GLW z$QBA2E@TzO$#25V3#1rHHNCkuPNG6f_Mx9N6eLCNjbHeG&mpoQ$ za7D`1P@fgnkR`yg#k1iflc&d;BnbLIh-Zw4C(csGjho6}{Q02UnymC*r2>8>e&4An z9zc2QmWYK*+lVXdbY^)aFn|SlooE?+5zn_cgjs}(ASrzU`8{-@)ba^5ieDlnFf*&>C2-%EI# zO_grDbhOkVI`axTaf3w&LME#?7{MeCk7mY_^I~Z5K>icXTXuapJlC%X+c@-syS!Xd zLAqYXD%`dhnDdK?Nxs#+m%>dbnlPly6nVdxqNaMFM!?=Kh`bzQW)dsR;w_zP) zMsxcRj9kFU9x@&^;BO5BlW%JK*?3qSw*&MWWD{Kww15&bK0%bRx9E^^_TRttvuo5a zlhi@BImc!I16r2qec*?(vuG=*y!rBL2fzyx49#B6^cF>Tx!G2^bFVi~o@WT^f5wvt zmMJ>luhFPP+48vnro~=tdyIA5%bB1)J%dX6$44|kt#>#y!+Gj>2AZhUtT6Py7)nUM zF4JmVM*$H6r%mx)yBw=>gl5O|C^8+;aF2mA z`ZEfGLnNeij#y`U+q@6{HFXAEo|K7qdmQfWtBU{K>Tv9gih>OU7JQt=#)yfh6`n+u zG)|w3{z3SuE^Z$7mrYk*oB9Cc2lmu*V9O6UYVUnsw>G0=rzSvC!;f7J^EV3g;eEQ} z@slyv-T9X5&_vJ_mCd}X05IeI((5Ghf!3B@I@$vl&7@=&OVs`5b*$v@Kwgs+t$|^k+;$Gd6cz*%mm58v zyF+|3w%EI#3>PUFsm>L~4 zzR}rdmMC8_k`&x;nty%bpdQZngYTFrm08aoxJOHxA^jUSacteb&5LK#?Cc!1U@hVU zEIjn9=iYzZCRJUvFGUdB=9K!py6v7=mu|y1BeTRt{@j0WgI=x*1)$#w^bS|GEh5o| zzOFQe$I?`4&AHciH(d=ygbfW1?eAOAmSt#27dR1H+3?u{YYHHBYZ^ey1l4<4=Lg)Z zTEYJTmz0Z(k06Dmo@Kn=moI9Hw`gIWo!vd{*VJZybC;j^cDK)eIz4%b{h&g83lD!! zS+>QS+Yd6v@~NvS>ld1OExCDrn6mUtkeneD__)cA&)Gbmk*u-5MI77T@LT6T{R3Y( zZm_yqTl&1~Hl;WIpdvL>%d~y|hl{kO!wDlo@Fu6Y?`=PtQ-z<1*amn%Skh^X?&QUDHTR!9rwZDOPWg-#fY)Y z-ofbm>Sj^WnT{(_4a`QGSif%U7zSo~QM63XCmcDI*!(KF9>iy?C=Bwr|MPGq?sKLC z-&>11Yxb;N4xuzXaBz(C%^PGiHt}$piqkN=zqxpoz;YTY`^C#^ef{U()3(j8LI%=G zthO$REDc%-%Pds)N?ETH%xK0$YS=15q7^hqxwrClZdb75?M^Zdf$@5}cG*S1!@ib_NE|!-x@$4y@F2X$EB@Umk=g& z)62C5J|Cb_`JYdVoY^};FvITJi4E_@R0A759<+KlA*-vBGSzl=)&RHzWDgb#?D4Bz zmk#%$Cvxs;zRUdRD^1VRdF&{#&C(pWxWY{~E^d{yjkMP}!;@IuR+F4QH~d$W(_Y=W z>`>*~ybe1HE#2b786S*qrwk!87K<4H6$G;AeolFv0MZ?1e;-P{EeX<#W^4M4fQlCf z@!hZZPF^eK0fafjCzhHgY6F~)ce8+n_)UxRG~q>qtfN}6A?RcIGPuMQlM`=()06j=q*v7!nfJ+>dkfvPotWu2NwG^MB?|2B zvx~@O>EwrlG1VVUEa+x4qR5zJvvaILmvLqLW%E`a9-r$rZ{CalUFB;H9E`#KL_KPj zhTY#FgbBjj;?>?y{h1U@eLdP{pZt{y3un!9yULE-(SbqV5M3v$UR{&M2U+%E-1O01 zk{at{$<(vetU^NfqT_loGo7b2t%fA7G_bZ%KU9q?*}8L4qgV5UfL`JG+%(H}xBtxU zDE6oaZWL$OI1xd_Vwnv7c?o15i&s>e!fet8T+9sG-sl)hbY(mA7L5!aQ~4%`oYRoi zNpMlBrf%T_I|QDaui5DyP zzK6vurG+UPceQN~UjuhR$bY`x4Rf4h4PE45W0OvZOf;QCAVu-FT4oh@{4OW`uK-PN zjp{XvOJjovtwPo84g!OT*DXlY%BQEPY4Z~rQ#Mp5i&fG*#D@i;*u3AA#DQ20pN`Py z^i@{)?9rU7WxmFPVX@Kvqonc6eIbMd>p}efP?cq|%B%KdH6*8G9m{Kg52-f2>0Og0 zs0gIQ%gccKZew+Q_5RY;&r41;=x$aHEeb6PymSTFv)bB><5^;k(>``z44;WVo?JaY zKl=hVZNoKj&8aC;N+Nkb%(~ig9y4YJY5NF0uXop zS9NP3#hC%d$YCBwykU~Qa0~(>rT0L;HP}ogf!l-x zRkA4ERfdMG=TBi)VtMNX{do4Uk9nqcfqFpHAH_e7aQ5Hh%tXjN%wG)KDy?+4+rC`E zdTw!o8)tC24e5Cs5#I(DA znlrTpf)|$)#4qdcz~iu1C&N2h)7fsAKP zf`c*G+a!f`7nGP(oW!MB^*{(9kb<V{uMr!&~bujah9TtE1<&_ z)lXLt7@l==;L~8h%r8CsEp)TR4N4((*++g_D1SSG(G$aig2;-D&0L^qVimey_@dx{ zW52(7`{8~Hz7hl#P!A%Bwq+iOnCjw9iHYYfl^^ea{$cPrSwS}N5FHEMwzz4UwvW10 zIeLv5(bLpRqMYQlR#jv^7390+BkdL zVgS22SQ@+EC;F=T`eF0bgj|=f*t-q`nS$C{tT{si6Aws+N9qIM6hKqion`3j;^sD= z@G2W3wbzFQkt?ELziovL;Ng)*hv=qzJ%>GqarQ>ne60=dyw(?oeeDh06oSA!RUIAj zMS{vTDwk53*`H2NZ-7$%3INMqqdn`F^fWhLX9c|g@J2H$D=Q}_PV~^>F99YlDQXV` zA=H2-*d{oI{talu4lXJnV5B&_l^Mt~WFh_M!x~=_lEt^TF}NvsKkP3$KQ=~%5aObb zHemdHU7bi;e!i)*R*{XS^f$6>Wy}H$i`QS3OP@S` zc5(|-yj1Rvn;4aowgg+io0?ONIilJ5bmNt{>aJ3?UB}CC#>@xOn>GH{cO8QwOdQ-M z=xg<|9Cjb~_uk9=${)v7_O?j#QtJyIkX*M2gH3<`(%Y~_#i1J$DYS6hqpo8D(vW48 ztA_HeI`Q~0W*`rD)OpFC#A@XYE-E(1uSD6{#`QOG(%sM}hV@y4uXQtyYWM4A+tKyn zuBT(r%F_EI7WXAvv!G}?bziX4jzaZHWG#eE3r!-Ou*1>Gb_L(X_WQX{`x#r(N=cxm zbP{ikWIG!m|Dblo{Ni>x$Qjshu`tF8Z}Z+u3KG1##0*WUnSVQEMOLX-ImFnu?9Hfp z{KtF}-9kDEfWt{*AQu2`w6=g}-|K_xW@5vZ8tbhNAIzg>jn7fWrF@?dUknsVvJ41N z@lpbVaFH2fixUWP8DnrfZ*QC&aBlSRs$jXxej=R{cGXM;Vn#;UNWq@f9*1c&sNE`c z%U)YPBhNcU9c?69yF>R811b~Ark2!s^1Gi@#K~ue8tj33eei)#Pj`Ub@t+(t z)}Ki{cdgxHxApmtwUSMUTgwA`7;8zNPtHDwZ>fKLyKI8{*%%8EYRq1tXVT3~OUt83 zPT+l(({BFtZ9dXO>NBjlEqXGK(;zzrp zlZSr+H=Uz0#IIzR6U6*!+y;8W_wG7z268NTD@_U_Caft4+HW5|nu`m%JfsSQuTs5$ zgRUFLBWsfrDer#ACZY&RA@wobM}wZV{&3tbsd28Gb|FDu*>z9h)b?I|xNVAm;#~?sM-~p}{`K}u_EV?09-qptU?ReHEuLTApgyZK+aadB>oQTWBr*a*G;`Au zH!E$4yZKK{EjN$vLBG3w?^7|Yzq``L4s3LgMtDRzp~$fkA|@9xV}?o$63Uq5Tg-4N z`tN)s3YC_AhdJ=O#=h$%2yWCX`uJ}j0$;!OdJ+8Vfe0NcmZCIPaGWcjthB=o`8E^X$|5_>Ma2BVff5*^05di=yKy?!9?2ERcJ zdee;CG#DGPspDsMxFlW7ZU2u>FSW`c{?_H%uj6Q&5fMVFKmAtL9Gb=OWl~-%C$)PW zYyWI&>gb8_<+1M(4q!1$K0OTYz$WdAfYK1}yhfC&1bT1}FrVje?%f>#w)`&YIU@ z1WYQT@TV)FV7KEV18W<+v&fIDCX%Ljt!WnrC|@E`L3vleXL|?EF1wK8JDktD!pCwU zuDa5t&RP!Qo?8lw&wqPKKfcghAZx9SFyESoO9%hJ28FDM&cqd~>9fvhH(k_ZoZ>{L z1x08ImCAxI!yDg~ccAKu(vUVM|N4ec#NshH^|7xm5RkXdGZ#|tNneBVf9e<&Y*z&`(N_!nq|^eYi1f_@iDpG5P;tRN9M z+t90rCS~*zzMGM|*FGb~%cnd}UZ6;#MQdH*cBX|2S3igdPuL(w4UZ?b^CGH$@h<)HD6U zStP9yw>!Q6egJ4)R9ICm2@*K9jz+6zT8<7(Xpb552D6t^T25-+-<6ful7E+ejYjCn zho!~qcBMioBFnWawfe2Ub1$!o3;xH`Sw=AY8jn*H`N(PT2qC!ow7i#x!qBxmjFdU}w-#onTxCjpHk1GY2} zyxvHz{LTCL)rTeY9|9$)WYQLvt-YPsz@ztkLfEV7IA5<@SIhI7W*l78b2nS~(Ix-x zsBsc_*xsB50xzcLekvaZaWve5Ox|uH50G9LtL7ccXO~x3RZ~l~Dn8NAa&8K!1idA- zCy%fI7E2b+1$$+Wr_&H(Vr5c@M6!wW4QM8F-#~sRG8F`qs&*4+Ck{+&gD^Tdj>>G+O zIQ?+gLqC)&hZT{q5H5?R^;&P@>Aa4!nqAWWFp=xDH)G?bW>)fJv)JpI3-`4I&I_a= z9a^Oq^O)(0m$oeTpEVE`*=Gn#gfw2dh-dzldiiQkkTdkz*nJ9rvhWhuqxOU;#)|$W zr!5)ULzRO&gbG!V8& zfnoR$H!m-AfG8XrY&DE7XqWm+DR0qT%`3<`%2g}Aw!T{HI{A9{i^%jtu0_;PtT+Su z4hxH`4VgkHpkDYeJXZXD^XJrUGUJOp)-V*KK1Ru1g6v!Ktj6=u{J~(m5o4Fp!)9yv zy7NnhZ;#$7y8RUtY$%mV8weNOt4f6TP31pQY&orgqa1l+i@L@82V6;!2-?*E$Jsp5 zpX>ouCDtxK{(`MhL+H?Z))ny#(5xYJ6!OH5?_C$<^|3hk;6Kbo{-(*m;L^EE1MWI? z3y1D`tj*R^n#b^PU9tMk`;lymUe^TdW&~nYrqr=nh@uV+h&BkSd)|yb_3421KHv8$ zk`ObZX(-tXz)A6cSWSP9Ne0UeShUWr^dkTHpLX9P#0^r)VZ{P;-xf~I+Iu=W8m5em zjf`;N_1dtYtNn5~*Q0_JLbSX&X?vu)uIKLV5RmNy9`DAvM^VeRnQHW$FflQs@9!^g zaTN}wbGyR>o$~pB`v>DUxMLZOTxd)^^8I$s%$&4ZYTK;y`trh4eh3j)h8)%J!@t@a zsxN~n#6c2T8CR>){n8OYOw(IHx-LA}rWtAg%I=r_>&V0paSr1I-@PMB|BrcN{`YB= zL%SsP=(`tS>He57rYVC=alLOmH(ybafa<8>RjJ~!6B4KRXJ}t+=WjVkFtvK`Qx1DE z69njpW0%wi&a3Vh%mB7dBWn|QH=HG90F!!Fv2u2T9?ty~e!kshKlQnZV(Av9 z+Hrxn)f&d8LKJiixomBFRNr8iCzRVuR2PMy3dx1_BPd|q-Gmq6xx!n=v=hLU)Ad@;bky*0~G`4E*1jD;-C~ z7gyW|hw#2XmhF209U@ED(`E8N_K7PZ^!X1Ly@sM*cLO<%g^!!USyF*R_Nx+{*Hl75+45nM zvx0doq)Kmo)Txe|SDC6nbV2hSu^q@AOPZ?W=KCTtDrllC$ehabqy45%Y}YLir>!o> zrr7XAf$}h>zuuiP=3z`<=*&0(kNV^Jc~&lMu7}43L~G&UWcv$sf&JRA#6>wAzR3$C z;o8UD!lz(8-eByNfs8oh5jv*B!io3F@yYooJNKZksjR#`XZ?38D@a;9-hiO5^~(as8)c74bx?ZGZVU z8=qLE1Z;c*d_RR>kwlfy%d;HVled&fHKsi)U1QK%v{LQ zfn|HlZECgW|6RPtH@_m%;j-jAg(GC`o9Z+}(3WJ7(yueSOV79>RX zSB4mN?Hf3nryDa8&1{%aCMT+HQ&{f+Vu~vP$AOAw_koc-Z*edkM+3-C)j}IaP}bH| zY?QcYM2zL_-MWcfBC^``nDJ1p;`_yDKUnIdvy%TLirgGo*hTyZdM--y@|&s&_hYub zpbpxRmPL~rAOd8&?W<_fKKqcJHLRzCdX3GsvN_P6<2`Xi0l6!NB?EzQoIU1_HXLy3E`8p4KVW+VOIkQi1b z)Ch5>8^tz7_DA6`-n`aY&~ScYiISXj3cov5sez z_qcVV$Me{+bCF;hN3=RT22BYoQfysaiwtfW92gkr?(Xj846IkcWfu}A#>V>k`as$& ze6wDsk=tJ|u9yjEL-lY;%JJ_uC6oV?oaX}t0xMrQdw%(5$Yml#kX+DPDJVp|Z9imc zdt7yoR)7s6;{ejQz8PSE+({() zd?t^dDz+&FC*(qeN}}D!TBX$0POFnMgCb(9*7(v*1OG^Mb+1Op4x<@54X|sKe2SuG zpix4_lPQ8VCvzJlaAQeaz1L9*ok3keC8@ky*)M#!zin=ue7a(MK13!XJi1$GJtN-O z!TTw{4HtuP-nENT**ql8?VtSar_2BTkhTm~3F|B0NBT%stc&G|A88lN{CbyNt@J8F z1fwj3jG2={E|20XOedAK64d7=0=(?Zl2e2B^z81y=mA9aLa0jr6oI6jEGb~~zP>hY zfX{bqEYD_n$-zO;LHsn&OC^mf&K8)*zuJ9cl|at5SDyxT`Csg6H=ak-=?Xklgs`p< zN$aO7GDsS{86}NOezjTMYIzm>C^!%d`Ekw^xLD8d&=X^Z%XF?A z=x3W58ND4)@7&HeGBngy;V6dda*gt{qFy~d6@fo`)aFp!Qd0I*=y1|HIb{rh>RHZ2 zgtE~>+&`;XZMJ?Yg#3rZNjr03rvx9N>o+p5rCb=avtXVAvKvJEZjpwC9= zirsS{EA_>Jsbg>pS*W`R5byK!ugxnTw8cb5y2H~NNrQ(ek75r=Dz4LxZ-?7G_2^xG zXnVw(4csrO&h1D}h7OZ*l%&rf8k>*uE)=t*7zVo)MEstqNKulqMhBfw@MfF6Kq16@ zNeYUz0?vca#oR3{8UHZ$pt^ClT=H^cQK8cqgYkFtwSqphz$Qt8Pvj{g2_Yw2qVp=L zaZT)0kzs=6L(IJ>Q@Ucdu3tU~eXjHcI#CL25{2n{?rjM?@|H5_oUmNo%k;*XH3N3`MX)ccGutZ*FapVG!Lz6?@5I7OwuM(qn&6+jj;AQ`!`khXd(9eJ>K z-4O$V&h)kkdTza4wr6f5YHF0JQsY^w<)mP(=FB&Yp;GPFO(XCA<;p)?reIH|=%R^9 zYg_?l_^xRIZwJQ-_5wtL8Y1B(L-hIcjLK3x7T}a(GKb+e#lo?uQbNfVs$a_zpXQBY zuB$hiIxRRE_QmR@xNr+{Ay+IqyTSZC|4p!crtbNmMoH*9;-rgGEX!vo*XKvPDb6{i zSg1(!F9b^#t4NW68wbQ>Mbcp28ZBh%f3-GK2@szTEB?Th<@sjAZ;|KibTVf~3#0z% zYiG+i5J^TQSPNPpEp!#i_{PHKdpD)aWaj@W1hDK!GB6Q%gy*-L;5uT6VJv*i~3gtYWNu z_vk62g;wi3ZO*j zzzoHg)?Z3TSS8yDQ7MnDy~Eum4z?nV0fmVfHjLI=p6^YQ3VR2)mD8PY*Z0zEcph)@ zaF2>BZx03jREgEIO1R(|<(DFoO$fU&qB>?lNwwE5RpO}EH8}0wo{4JSsgTM{8~H~G zgwgiRU}Yv%LAjY_h=@|OlvEtb!lVyN{lvDDJ>ANWYlEYGo`Q?-7bG8GgOF6p!4kmr}ZaOLesc zsqfb!Vb?NGI7jGll4Fc`7>WIe3yC2r_EFa#eqT_oOh*f|q4Uam^xswyM-5@Nv^|As ztt8%GQG>9(r~tR7ORKoLC}|Qb$u^eKz&Xafd={WE=-VnLh!U zyt?b|TCbK8n4>*J;XW`Nr^YWGc|a}deK*wla45{tV&s&wY!Dyw3ihr3=Z_y>uan-q z0C7MR2XrzjXgA&Mdi`G;5sOWq{?bkS=K>J5e{@$$yU_291 zZ_<_jXTN(!9YiLrfNFX9C^$K@09(%=)tePetoj$v+jG-k4!! z#{7pN$x(jdTAixLpw?Bfwz-o!p8qCSi(6Vq)GYnJTpCFgH3*Is9 zjxsv>gDa}1uO)vv_Yev>#Nf`wQ5U^r{?vBn8MHEA434rCwv`Tn$UW*Hb0&Ejl&PDS z0>O1qsjJfpe3rA@eZzWY<)-bhxyuEx&+w(HQ?rYZ{} z4$ecLl5i|g2rt}=m^y7Tx$xuBjvAJ%{?ZAhnQgwQx0Ctv#&8W~y3`a|!sM$OD9GdsWagRe7zhK#L=;%J&6lk1IZ`Rd- zA&#K@7xq>vP8wE%dqaPFWXx$22i+i-M}?oM1EQ$EeI-K);rj-T!(^sF5UQabi69fm z3W9DaJ-CdVGnzbE4iIIn-P1NK^7QW4K*Sx4P)hd(D(qx#bGhHrcqZ85D}b? zfNppIePj757zJQMDx9%=j&um7c9uE1*Q4yHmizq?m^ zjJUhGm|PG7KEmOBJ5b5h{IRp&REOU1{Pkca=~uzjmu1o@%#LLv3%nK> zyY6d}Kli);GwywV`bf2--&6;>)hArN5tqXl_?0p`g$e=%P_fx0nvl|S!GaHMLxHPq z_Kz=EP17Tk#_1XqlBKP;k9=1d)XlBFMD(n>*%ZZg&s(P&`F>BMQa$f0b_Nw0FY_8d zzR&s@MMbKiRrDG=wfBvVZ_yNr!Aqo(zkI=p`cSL?%zk$HB<3hCC$?^@5J%&3bAE1C zt!ca4G=;o~-XeTcqzUP-{2Ei4~^M_+t#*So#}JgHBVL14qpq_BbeWZ!da{MQz33 z+VQvkHiT^;r2A_hO05X~T*8{UcN)mn|FDV1@w2cTIlO-KHfV$Rv--8wmxM$jwg7Lzc zu(kk5(R^72dz670CIYqKIx~Xf7hTi#tQK-| z2AMW+3#zD?C7_ZRjv}C_rBk;U&J>fdPv3%B`5yrxq2ckoMQ=}&j+r5rV=M)$MWjxL z3Iz2fgaI9O<5QVQgtFE?3rjzwF$n58<&zuT3pKl59C~|Su_4*wuB)%rZAwcJ-Q!l! zrDwX2+2+b{kr6aM&zCUwTVdZBWlAohSeaIS;-$d7cB4GEKlL)x3sMeNIXNN{39;$# z8)ZZwO$R|?zi5j!Jt?G4a-2DX3OXOG9JzK>s^*ZWF9x?i ze+}+?sP0Xe-n4E6JyZu%WKlpzB5mlzb~>sC#D<;Kg9JUWH+Oy$y0w2~{v$ei{r<%3 z=&5IYRqEf&1unaAiIM*zuklLI^Xx(fZSGg>@ zu{Ob1UVhIaCl6Ywd+5d3%vxi)Xn*r5sZVE8 zTz2vNdD1bV)N7?4uhTHJL@oLVn^NB)lf>ZDTU~BgDwr~$2#IBv027hD z3!ss0(Q#fyn>ZzZ-NcA2LPYJoY(jB`ZZ$B>{nK%)p-;_GjU}KG#pEdS6jP)3@TzTT zX-2$}SZWXQxL-)rkb2(P-9(rHCRmJ6-B8<8pr-R$w1oF>Hp$enawxFo7@px)mu=_? zzO}tSNMu+thl$R8x_Zbd+wJnANH8%p#0fLE2zmlk&^#x>uWHJ;+&uqfV9NB%^lHS6 za*Z}SItN*VF1H#vdlQ5t5MYFFo=kYd3@;)-$b2S@j>yozQr>3_ooiG%#cirl#_NwtUHo0>Q9 z<#1AnUQdUUN1c8+%iVvs7Rsn!_DGe$7mAJH{W~Y$=Q8lqy~u7Ya8)3}R(7~Lk^oPR z9nr-nq%%aw>!VrI=|~X~GJ^Ca8e-g(MQO{Zu!-flW(>K?1YdH{tk##r^nqE;Zx8`x zz#*(@-dFPaEiFDM!I+F7f>CQ6`?vkOSA)dJCbb#^=H&XcZ|M9W^_3mJV`ITRyzaHuJ>3ZHU=eeRBC3!e__dIRoZa4x;O zWA!(&Ovlmaz)L+HE;q50EGYZV|k(9L3^VHA93;;*IbP$1pY$SAyKJT>DtA3 zGAa;AFIUZ^0pK@8#XPPN-N~`oDIBu4wuT3vg4Q)L>@NS1@vmJ@NXve+=iri0PN6+_ zcnu06Gq6j8d_uIY9B-PpL3#motOQJwqK+W=--23V(u)dU?9gP$#;~H4VaInWf|)Np4=K}{-?&j z(aCR)iy`N_3rZ2Z0i$zEV*#{FD#^Q}?im%T9D|7N!ppVH*Fx0df{5i8Qu2Q6TAB>H zKfwNpC4cE(#W8E^=#sPBl5pcOmHn1t+30jTgSThd-nd&`*4w{*Ndp4sT?cji7OF%9 z#a2Ma;`7MZnibHEFtpH+{p5$mNtBGW z0%ex}c=y)q;|Z+#k+;r8;(2DpV^VLIf`Sv7TpIwr*hK=A1vn|moiYCvjUl>-bM>Ryuzhw^1|KcugY(JWa7!8cl)(g5il2_8yeNaaOeZ~B2 zg*^C|8Mf&nA--3du(S=1+X(}w{P_ilKL`sBnY`FE2ckxUrwHNvoXOoDN1}M) z;8&}ku_*I!y6obEMpFv{WxMN9^e}HdMJ%!+nMVn z(Mwz+4tyPqzW4XXOfe*C#z3@8JsfvyL$#gbn8ihdbh+>6}6>}Wgy~H zx<89~JOh(uz{JQAUt-kbQvMG}WmEm#r`HuYA^#2Bc>Z=142!a@@ zh>rRzEj0q0feBDij9}!%y`-jbqEPT%ytRXaOW^i#@U5%&Gn;kJ=qK0ilWQfWxEEzH z4}5+S;dVr#{Hbl*)=HXRmQZvOHjC?5{qJEtczkAWJh@Z>c%$Ka@fR=BRCZ8NT`BE5 z!aiG3rU5M-3UX&HU*ba;b7YJKkz+}91}8|w407Qtw$#c96@29YBlG8plXZ|+GDtZ+ zI`;BgPke7Q@)wsXeFTW zndzTy@DZn#MM3=cBPKpAlY}zYKn#xI}KRT7D(Pr3OR*v$%H%t5Jl}JlIrqDSf(jB`HS~qTdPx1iR_H{_=S#aa!)niwRu2e6N6Oq2blXFQ-u@ z9|f_Bia>n89_O+8;j0yXsZ96`LN!X|b=Kq~fKla((bl;HKGZ$$cze5?xJ(XdF)=i5 z8hgA1k;;?58;G>?E@MS%DLealia;@`SZj59%=pBt|?G4oZks%1XoM>}7A zt<~9r$nmT37vxiodV7ESr9@P3J)P+6saW{(lDkh0YlVOSap&C9MkV>^IiEjz5n3la zeqLNT{~tEd{`aA=3MJ_@HIx!*x`Bn|hMAZcz=tsJ_usolV6ll3f=Lm=1YyJC)Y7j0 z5DvLSKy_K}p6EBoiA~zv+-+=ZEF{_Z!%Zi_;|N2&dOP~RjwEI`TaE30+mv$6W`MkR#OAh86}%uT7N>2654MWxjcJWtgzc#C?V1iHuEKSb$OW@ z$A{nw*4VOLjidpAi1;|<{v{;6X5tgukwtmIBz2|Zo2rvqIdBE)~XLHsjy~Au&6OFoPLF#q~Y0#m;`wmQWIo%&(+EjI1u1Hq{iF0DWb_5;hvIB9=&rxJuA(?{n!Mq+7LE%mMWFa` zp#lr{9_E#f^*)R?>x%FOK1rcbkYi?stzKwPc;EKjFvs;kg@h$s%Yp<)^MuY_{dcO; zkb;H9y17z|Ce3TvqL7$ALr+s|Gy}xO>P9=1WpcKiG`B|aQJnCh40uv~UI1&~g5`fN zVr3ARC9&py^ERcZpynurwud8CT;xcWlM&^Huhm}TWQGU4t4~+!SFtUT;z$IWEa8Bx z>4<3DzPI@Gbo@UvhX7MDtQ@ZbEvMjn_(vaqJLEEDf3w&2(xlNw&D)AZejIr!>4<;& z4kko6cFRz^d>l2HA%2)SgsN%PnGDn)f=81lcchTZJUNO353D+xNW_YK^+-h=+3txI zmv(c3ONkNwW8)FIA@RJ03)t#z8Dp;qRQ|~EY za8LafD3!CzVV&hCV9oSm&&9`IR@1H3;E$G6 zqW}5}1D>LVfwG6rtL9P2-v*!dUtC{V8My2%U&1Y^sF(5K5=2Ot~_T=wfAQoWV&8n;>o0*v{rd2pfGWdoO z_VlmZwJXyfSyVV!(U>>I7f)vX%jmXaHc{?}B%oBRA6{cTjuD{9k}#Q*==2XHEq#^# zsvq&ftfgzl>Q#4Ux+%@;#@WYC6I);K!)LBzP+xxp;EMzKUM;LNAPC&D+US#(v83E( z9u{9rvl{W9z38X~5RV1EnRo^2W1m$1-8>jac3!MQ1MD5VS z5k*SLTHe~Ohpgac_g%GvSAvZ^UDO31nP$Ok>@a1A@v+tvPtYy#U?TUyovG{hmaen4 znbFZ|a#})6vHj^Wg zyX?|yG#yG%EbVmq`0()HCZaaWt(*$Rs-9ONlLxT1g$|1^sl_6(;Y`w{rKtnT#$?Om zxx$%QS#O5d9sy+G2k`6-$GF` zAHa#2!9Q?EM{fi_@@=AoU$IvMBEm^48gqOIW|^8fi4uru^uj_+t#J-b2Iw1fDtTS6 zZ!5Ub1m9Bwr61l6L*teu!=H`dwLD5QLwW+DeubvA3%nhSDMzaEN> z0TJ`15E$TTEB#%KhN+kL_O>pq)aAeVsgX}3M7#Nb`ZHq+y+TPYoy~5&enT%|G_jpE z&hL1lewHwF3xW}S-)94QPn@~2~U>Xg;RLAdqC_FOGds|#-A+tDk8#C>0z(3Jn)lX5u`#Q0#CfOw!Ij5U(E&J(2tj`y|-uM!Y#Z(h?5qUQ^!yjHuqlPih-}%Kjbn$d7>wqUyp>K zLf*C5aCWAl`C(#$jx^~~7neTxP1^I1d|nVS4D9lmh`pW&5j(=;i{r;PvMcmEK7Eoj zUO)0XgMJHJ&Xa&&V?>}Dq7%iS<7P4%?V17EWUtKU=2t$IZkPAp4er}*+&n+^dP_a~ z`rq6BWV&Zo4hLnB>}a(-y(|bMA^5BxucXyiA;3tE~qRrar5~MEtQzeSQyl_W(J9fh3zbwNCVIY2V>jDgA-%RVWjLkvPa?S}0X7pJv*8`2dDt#ZWV8;xN&A(We zVIRd9XRZdsoKX+=*D})7xCRp63R3ospYf83q|S;0j|#c*O4>}YGPH#t6NXtf?f-o; zieLj#$l(~I7pyx9j6vXUWcW?Q#1C{|q2e_Ojp!c(r}s-$gII<@YZVx9bIqHj!upIY zut)E&P}W?lsiQg}x0hN9y<02qyOn6WVy>B)m?*l)g{_Ho75OKm<>U-g8bFzk7E(sfvt~~_!FOi6 z>XM7L;Jml*zDh&5lE%*-QC^1WzC;6?mnE#;R-`@TI`uFK@P}i9*xa~}C#2(Jvor)L zb)Vn|ThYDu^NPU%R(msh(fN;IKmAd!iq&E;F4uF_lErQ4EJ~YSf(B5Fnyej${)B^R zF)A17%P0MMJI3ZUC*{W`KtILw@i>oc&f0;m$o~Fg%hP-rFT2HKhB=LVf(2nxdYLyTa~q`u&hRI>*!BOv>8U*zkH&*iV`4y>?%P;`Vk?9XUDhSqSmHy)S>W!uBQoxQz{M z$|rB4zbzi=HqnK2wcP*JT?uDuxZFB^ROIKoOhodZqUQB~cb_;*{P%)zh5fVpT5X1` z9E+tg`ijV2*EPtzNmp=IHQNFp59wO<*2%F*%!|m=^s4aIhjukUX-N&PcrqX~1vXS2 z9O>9-oi_2myfw~}%|Vvia{%Z-%J_V@*zL3^7S3=hXqOwOu_ZE%oe=r^`$w64x+_}3 z1RyjWB?T)~!WUHbIT9TtXd%xbM>ff<{YB0XOySihmF zPBP0_!_d;0030tF#b%8`8U(3qmFn-%T`n(NkP>)FQSe}iC}1`ClvgUuqoJmi@=Ot^qGCUdT1B; zu@crF*>0F>UlJ8gTWHqv^eeLaur*!UBgnUEwWI zCP)QSA^4BwG8!J#YgVcAWqkLNYNWCBVgG|4@G(#^J3xw!i9_=M2F#o`W7lj2=6i6m764}5pB}!Pt%1&iH zD8E|g{4b8iMQXShNy%x*6$|)1*Y>?;arxh zF?tXaN930#%i|1^hA~9@jOIA0`E6x%G9uZ}k{T587ksEPW*Whj=oj^ox)=%Dd**$% zKptHb0#=+wgnzZi_KT~>&KOuq@I#vo!Zvc;j!*%Jy`Pg61PgU zwQ$cc)Y8p>VOR(AOviM7lKGap=e?R<`9A_ZZ&^^H&Du#yHzVJyqDm(J@rEhG-lq3) z*;mgTKhjhEkpWQir?Qf=!P`NA+n`W)4t%&bGYh!5hBL!xjxJY@bW-QuCNV?E@jg(G zY=OCX6+g5DX34kB;J!`l)K=!Owce8{Ev~R%2aK^Yhw;~x6Zw*xW;J$L(8-}^F<43}#GE9h zGY=o$jlY`3qzx{e`whP0 z4_T!^RxbMRT%m@Tdze*@d);^2LvNdVpQFozpW^M^zfTr~e4xHL8>@7PI_1J57Nq6_ zG4w&7?f-lkqTMGR@?s@63u%$=vvx$<0eDj4&&WSWWODE$FAPpUG%yi+#qb{sL`6x3 z%Y@>#rvvT32vk=7l`-5QkY)6h?z5wlcw_$hWY&ux6R`D#X8J`2vUMKc34q23>TUK{ z?R|cl4Za#JE1#I)<>?7}+Mnb#J8mVYv);ekwKc9xf0C5f0`h64dNq}q-wkCS*`yXX z6<~zec0bLvd&te%k3lifO383QSfL>7y-ImmX4wIQAlIm9G!*-~aC4~5=2-iW=+npv zetsf(nHtbE+_!w^O|T+g6Whz_Dx@?c{5bC2 zPnKjP!@QC}(nUv7@}c3n)f93WundoRvyXW|SCLfy%@M#J9UH$jQ#uhC%JFLFag<4q zAZAW-G(e5`ty+Bl!kCu@YZ&el6FG7q`4t46Bf2s4x@e{>yw8IhHWSftl`*AqFnpiv+i?BHYfeD-_f=j6e-D1A0K ziOXGNF)uz}`u zIR;KuYMf{gq}}Bi;n(b=lTYC@bb=)kTkGJ&675^CTsgcmaDrW%a_}o?2ux2|r5M;& z=pOjJsn9vdP(e-|;a?e=@Td*7cP+ha46+oc(bJbv5>Z|aB%S$HI+|^-iy1g@LyUe* z7-sPH{(rZVM6JPb5U8J_(%O@SIrJ^D`R;bo_+`Zx6Xm2C6dAIshMK94_I5*)j}PPL zGb5F0!4Fx_JwE%gndLcN2y*jf3YX7z84#Ri>ejYk)kkhTkdjKS`bmiZyXjMBZ*`vj z>f_NCn(xuBsmk-HC1B?tEb~sJ-6( zYJ1~}_VI}j#><8oKuh0uasvCQke^RFnHVV#l#E0f zazrx_|DnZ&899wEEm3(G9Tk3qdsov(ZLY0#35msStB|>J9b@J&?&P#)^|URWUJ0px zRA^RNs}M(3Wb!3zV!@rs&u0|~5Y9$F{@+~!FPW;$M8InKGh3Nr?hKVHtftiLQW{v4KC5@o98lFX-QdYY=ktG^pEuf7^13 zlt0p~nAy9<99z@j9ah9J;RO2|mFnbm>vDz2*+OxeN)Q%yYSFLA)cK6Uhv?^~yCV-M zi7$9KImy%A5p+HmUuP1uP009sQ0Q#OOX~^#wys{DGBDnuqBd-r z9^%8#=X++w-cmA4&*f?^noP|39~J{BLKEdP6Xf#?H&6{)I*A(_C<|$5(7L!{aC1I- zH?wuO+*mz|OYZKJ;U>x#7jL@B2jVv*L(Y&TXb*zr2?BkVynesobNJaWCbYgOjg^5N z+Q%ddaFaiP5C{7b&haWDUyDdtRPItV>2XWISwJ(!5CX0$@cYBQsQ%pYxOxA2z`SOO z-(Za_oES3vbMwixm-OT59M6Wa|9+XF2ajp?VKk?{VUpKW&0I07iy+OPrr*G^K^Z0P zcr?30O`FnFvWL3P`3NjiF*M z5bMiUMbPGmv=2%`*lcizvT6kgC+$n_}fz3rqe3{G87(Chy>b_TWF7c^@}}t_Pd%wzLDS+A|2Yf`m==ah&3_Md%~x z>|wvSG;xMC-{2)EO`j@`v+?7y@H6?!G4l-gos(cubZ`Ecc$$FaH(zMvfOn=V-NoLD zS)Bm6k>+1cz#l}5}R(awtMeR-I zsY-g=;g5mI1L540YgbREn;$)FDm%U#o0zc5Glvyug}7aHNpSrC3r{e+DaMbLG2i&T zN05nxiAb8taX6oneM6_gx76?y8wVdm)V4pv$QL4S2|E6dMdz^DL+bw3YYN`(=iPbX zXNUU0vhK!3#ui#O_4G;_QVvr4ve+hthrzLKacNLZq(RJU6{-Ti{W6c>ySud!Hd!ol ziz=6WY#ek@2!C~AV4WMU(4SoIkIQ_DC=7h5c}bi6mb}GcZeN7BRzA4deOrv7SWCww}Rv zO;=kIY4+MyuGFCOpH&~XJrJq`Y=n=pXSBGC+JA*a5q$!k-lWV9DX55|#>sqES>Svr z9<=}=^g6qo4C-m>>G87scaG_5;`FYWfuwGLOt$HQ|*I1qq_nD~Zw+ z#RIz^m^Hh7&KaC=fXU4g_>1w75os(&zN!%{v{ zDQg7~(bG2*5-xtZoDeT)ubJKObvP6=IY2@+?+0Y6;m=pIAs2_$VlitA|ETRUQIN)i z+|pegiy{rDZ-jrm>imYTn9hk_@(m4xtbQkJZN=qQ{G<;-{i4BBlVpxMEp~|{&cI4b zsPpd90ng%ksTTqeMt{*ZE$ocx@Y(RXaL_K{==xxMJ>&WKg4`+9o5ttn)2W9?|s zxeXq)=;~JbEjgmy&;MgYSW4sc34~M*DXU<-^n1=C-hL%&EAwC4#oJf%h1RBINNS9U zBE6)iHBcU@G{VWfM(~5ra%rEMABX8$9ew-kPvjzp`BbNem02{e7O8PiTG#m=nQ(YE&iyYJMg||A&)&U0;yh0UFsLROB4`8Psb`7OwAS4Q@ z(O>ST4Qp9~P=MuF8Y#KgG*1rCu1d>G> z&4vzy#gf|66O#dOnm8ll(?Bj~-c8R}8;>T94PFN)?*gx?L)7Zb_G|1Iaq$m(FP9!-=TD!`x572M8iIR8%}9Jp*c#>eRA-+*O&$8i6_*p#nzXMU+MeG>H-(%p zIK2bI?YOtuQUC&cqg3<+HZf4DCD`JIsTDFaMxE9;9afvVzYe{H#bB%o7kzz>+u?3k3!?QGUE3Z3{oKtLq7rP*3Cv z8!e4C6R=2DJFOH^JmHn=+&n!fr)gY09{|sX_`L*F=x~H*61+Gt*MMqgq*G9j2Y_pX zm6h{t;U9T8XKoZ@J)BmF}UkPDR_dFtV-)}y5wW^$CVlXmJz&-Ihl5hrRDRxJLY!>*)lsB9-h{W4hl>7^aSymYx^~wy~rpdC@n=m znMt7cdeS500m~~-6=AOimknchk=-laCXC}sbCo|NU;=aM+tuMjASB zF|)&q0X>WWbCg1*I^<(P>Q(IW!6zpnk3%QwQmdUFKYYcjN9Vl~_vx#{!V8li(qyqW zyW4s9B4L;S)LV|LIMp481hkw;=p zs7ugDxwyG4%n6WGmd=MS@b_>0rcO-mc6v7_f<2wMbL$rNb4aS5dASKEhDBzg+ny>5 zoXu_1ZWQAUko%ASRdyBblj|m^B2{8200ttUgRHh?zah_r|9(8wN6Y)6Q(gq7_v;mN zKRLs`q<}phZ*+>|WyIaU-jcv5Pj}$m)9tO)gT&g#S?levd-d#B+1U5wN5i4`I<(o+ z*i=NQf}cZ;?>iENcehcnru@1fNR4WS1k7Zw8yg8;o)_ThCK29``YhdpL#2@SEfzWF z1%I733DBk~7Y`PbQO3U0L>NPrdPlx| zoUVL(%Ze)Nz@WOT^dKpr!`iK2IZ-2ptm*7HkEPqD;3xD^n!m; zA->N%A(e-3hxnrx747od^n>OzkeYXZhSWD;uQWk@W1F#w*~;^dR>?5$yG7u@XNJIKJM3O#;!YwE>%`cP~im@o;EE(FX7u_1UtZ|IL|X-^rwM> zs85_+Daw*RB~A&>%SkD?$7!Y39Cy5^2AG45fr(GAL! z^4WM*MC49D=JMn)u?xRjEH>$3;O>j8T*zhOUhB%F(Zt|4qy+Mq;{2jYAdJ*nTILUb z@d`>$18^H}d&{n?*DttXh|`8pV0G}->EulGv_l#))YSqbyM5RI=8=Y7jD_B zLIPYRCHgdPe*3di?^TP=&ZbTuzqYGG&+#(D!DO@sf>OaCB8JcsqYQcTRQ4RXV>xuK zSy_-WWN!7*7^IBcoop}2$*I$ELM-Ry(K(rrAq5C>uk~yMThHBNiXnj-YRSx4`;OON zPS(~fs=-qukxTWnKI??DUT?=K5DOj{={>VoDPz7;3ke}x_k8~=$O5Y=Z}a0j7s6TQ zJxY{PEbtMa&0M>8FTc{s{~79BJqyY2bdHxtPYO#y+UL3#KrxAAU%nnbU;f#I>%?k$ zcaT+`;TCjva5JD@8+b!Zc+X4w2Zt$(1%eM5RcR0}C~7#7U1V;`(z6 z2f|DA!|w^_VHeNdq!93(UKf~SEC7Y!&(u#GBKXCH)4Z0Qu4&Numw%W4;1730Pr(ZT z8)w%}NtFr~r8?m%D8vU1BrktThG^aDW#zvd{=O{$sc13J4z{HFuq4ti_hyHk1lpwc)Iod@0NBK@?Xix&})R@geMZmB!ef1d&kDsUn0N> z6`!TwSN_$f&-?QqJkJ(sVpW;)jYIkMAyv5bR zBACDXLTR^Y;mj)yf!x@e-TJw3hPpcM2x<}mC6x_ZLo~fDTBzTyQu(b(N-ePD` zEW^khS!YB|X2VER5wse9XE^rx1{usPBG1T%B(;fT9Dp{ldhJc?;Qd?(mIU=XJL0Ux zz7ZuEOq9$pi5Lcy(5i42m5<|-F8Px4LFWT&)fv8ZChftSDaGEI-W~9dzUG%<-fX?c zSyYi!@n|(7@r84cm+oUqY#c zv?L`;!0qc*V)$Gk$md{gMm9jV`Gd(`oNXRcE=}eQ3S$fjI;(Swf#1!*cck&wPW8K> z{e^#C*H6A+;ks`^WyK7;21g8kmNPY+%0}{-=`xCQvr9(u1KC!lzfC^ZmteGQH`0X} z#-m!D&)Q|YdTC{yz8$@=JC?Q?9#tLJlnM%SG>63XUa8bQxLT-Vt_TF66 zTtKGcCBI_Me_Tt}vP`F9s6?~RH}kqM^59_ilcoKMM`wG45a$u66kZKkeBQpMr&6V= zsE7Tlp+#VE;<7`4rybZA+_`dM>yW;GH#3tA9LT+X__Ap}?6I>;kIoWb>{u3%ftv>^ zBqE+%Rl_uNqKmrRw0x^9_7H_+x~=(q`@ZpJ8Pg%X?2E-VE$dgc;!gceVse}#OF!K`vqDh|78igHfSkg1@1w7)GR0=xjyA_{h^$ctC$ah{^lx?)y0 z)*jrPlU=EV_cscg(pqKud zqO60@I2u0vRg667)_4KnI9b6``FOmxb))!&*eHaog9()qNH|4uik*M=^QfggVsZht z{&Zb;Q)hg*&;GaQTK5jvn3RwL_`w!8hRTnfMu!&MM7PJ+8Fvombu7)e7;QkuIGFcT z|9-U)@*(cd^c0nUxaul=zq9cai4I56;Mbu1@rB3#4TdK%dP`c|@?i9uk+4bSQ|$*= zJkk_PS*d#%ti(CINb>_Vso=KCcO+}$?sTfG^8|@rG4$VT-&>k>9%g4iCoHkwAob(f z-4@iWdf(ssGU`OPOn3D15ow`ikWOULr#{1lWOzD_!e@RiVI29U-bYudO(0z<~s!9 z+tWdw|GTZ-u+8R6#COZk0a!gY%6UrcG3;2Mi`lxYSh;{+t6%6`tBuw4wpo!dZ~j9u zJ4%0X&Nmwrnh*exfrFRf7bUH@%!mhWC|m|d6$4B@xamR5QUm{89c33!}8Iv`P{qL31Q=6!kAx)QfG(Gexvt@ zl>WE>U?ClmF)8uy<}BFvvhQ8%n=S*bj}ov(OPOj)FKN-wvsfxgg{TbDsOEB#?X~VS zJy*1TimnaX_e7`w85Sz_Fu*MTb+|M^UYgX;t=Y-PtBrpdhkLVBCHnh6m~cX|7_cT& zehEU=~Ap9ss2a|B4S6Ap-Uw^ysO`pS0fB&V& z0JL2>qatCQSbzWv3;@7DZqbS*c^1Niwf)JdLrJ8y9u>f#W>`c86a0rI+w_W9Fd6%O zSxZY&&f>_2(u$_q$S7b!;1PL1ihMFgDD;$A3~UmQ&W zUSq}owCEM-Ap#`GVPyeHk7nNm!Tm3ek;b-N41JPh8&flb(yzAr=LkZ8l$D@#!+%Bag^?yA;$y9v7k&2ioJX=W-O8z^l zoJ}o1IXQ*jJ!i5Jpek4oK*|L30Jy6jAUU+_SKhttI;bzXW%))6oe~> z{R;K1MDj91zjF+AoK8Yfrf_3F^7#beruwW?m4)jpwb7%aH1Xh6r*&{6!Tx)g0b=E^ z<|QWKmnzjg=>GwaTL@BY;V|E+4Hxzyxaq&bc|&{O1!3X+y=<2F8VX%SQ$icPe>Fdz zlFa`%PO0os)|YD8;lz8N^5uF$ZQ$fJ{g08qC!#H>pi8V$Y4#HG=v!V-tOuztrOt-7 z^bR3k0n`y;1n^gHVNAsge?+X9ffke`ng+@~>)-mk$Y5wa)A)DWUEC~h9WBixN}CZ% zrq#W7noB`l*9E=8d6*i~*pyUo+r3=TOCkM=?~kyMPJhem?U0B4$@O~}e3T;6d98l9 zR|Js_|B6o;2T{_vh;$R~@*^r9W40RBn)fD>WdLYwp)1MnTzbT-6XffzuxhLcaHe?C{~4)#X#<5R8;H?A4)CJ-{((( zb$H@vp)>l6HEZX|WltMB=ZHwD76VDJ!X7oCki+|Z7{(C_rHp`|TOY5nk%1cKf6^`f z^>ZvQyEr;}F3azo@F@z<28$B2oinwKJWIDQ(hlp3M)7KVcSb> zKn!4$Cga6}6cQyWaDAcbrvG21ON!TJB1o?p3nG#w;3bD;{4IYAbHm@R^+qgH0<--) zo(uUJ6se!)1#r}UCqpWlL4^>ki$D^XB1>l0Q~VE7GeHizNh0F1Pp{M9JIz+?ocPGJ z^xrZA%G-6L63{xEw35seIt@>EV}0ZO6=z-G-%hiT`=||=k89N&0`-{8p~l<(vj*n1 zT+|5f-Bpqb!?B4cN3hDOfSdf1qH_sA_jYk}K{}v3vXP&25 zdqw;0Z(CmzCfhh~Gn&)B$HETp-C)*1kC6^8-BOWNis4`osU#~`MX`xle1V67zRf?_ zpSc(s7su>WKbOr_X~bP=cl~#7lfZ5fT(|M+Vg2Eh=HWWf?DjtOL-5l&d_P}2&l6z^ zt}BZJ3HesGtT*6_I@+6U3Dqq=HBxr#oZ+Js3cQEqAFb_CWvqx}6glY~b*;EA#pGoy zQ@YL?+-0Ev$dVCc4)0^I8B3Q26ewo2IOBVp->ro2tnf(y8Q5(A06aGG`J)!D(HSq- z(ekADgx#`t1+baxjf*p@rq`xNBjR2eyc0YAt}d=nCg~$uV3DHipGDPa9%oDcg=iah z&!+Q$BLGU#+`C3rRk#)UktF}%`@WU??_l$VHF)Q#sPE3M)rLPuwGK@S^Es`kUW_wA z%V*H8C9T7(;~pkJx>PoS?ay(cWK*Ops8O__f(oX!pX~z}HZkPVb0%nNU%BWpTEF-~ znAYl6&@zEqZS)&TvGy4|^QCT#dlNzN*Ww@Ir(F*Z&*zsRm-EY6T8M|arMuGnwB%>Y zB*@ekgkcMgd-%^y*CmviJ32+{$?%F|tW1gfv4xp)PYLmygz-Q9x$BKKzv9I|&9ZR3 z?4jh(MgfwRIaD=M|32a*$H`Gp;4$sum6FOYcPLqhZ2Q@Nf{^Kqq{$W$S#E#G-{Q#v z*zK+EAq>9dYC7(>p5#14m5&5jMU3zEcgX8|>yr>@;S@yO<-=KE# z?3wUnv&NV6KH}z_5u;wanTmS88w-0l>pJW1n+$wFDqD9iFFjiflWp;jaAF)CNT@gl z8fH|EG>WQ4?I?c;2pQ><*6sW4mQa4%XVqC~i|#DU!U@%7oQ&4$8}M1%!r{Q7 z?wo&sF0R8r`LxUEVM_9>VM^)Gi9RW$wTcUyo>hL^XgqJ;Y-*#<3v>ta{ ziYEl0Y~iYx?yC#@T~b(Mym~nrDmOvQZhYDQ@^Gf*`!{+?G!!C>QbBvnySdcSi@~u> z`hs63-f;h%4M8O|n?vqG&EftDfzOB$N@%Lj1htDQV|+2&3>oT;lN7^8JcN;(i-L8_ zbeZ2!Je{PZB2vHMpcU<`2EnfCDKhsGf8 z@xsrYaH7vQqTcl0v=nXrtL#f|7q;WkAki@Qdo@P zZFuy0kU^?pfdEXT57erW&)Aq$O%$d*)a_f^Jq!)tkCd?FiQd>A?b#>?+q-?c8%0c> ztO)mABW|#hLX$Lz2i3^&{S{FkKtmPiy$uv*W{ z(Z|D4;Vt^FG=+h6T_;l3$sH|$COaN_|I@omR#T`t-9lnG5o#CiZpquEXfLF%o3$W^ ztxtSq(3SZ`kLV}v4_-x?XzKgQpvvQbOSFy4gpl8ANY1H&qA0Ndr`N6s2PItwG zlqx61*Mka`oKUamU7AL4iTF{p_NkUi?3@dPgF3Ba70n9+l5^Id!z4Q&ZjMjBTZOF8 z>e15cmXQ#FBXm=~@jej9vO{!X1Q-iYc2)7HR|;EOvW&31tGTNz0p!yYy&R-*vnQp2 z#_<&@C4mg(HA~YDX=sBlbIy3kVgNl8Eri>fp|f4x2Hq^cPCg8>Mi6bil%}jj6-?wG zl(zLe%5BP*BxzDCya~Vi^u~C@cqwAbqH9b3-;7Z!bu&|;qH}>490&vrB5AYif7Zb- z06Y{3OHm+5g_zyRB!4$%&QYe*o=KaiJHRkv_jKZ3P_3@qQPBPJp?zzHa`1-l2RIx( zF*P~>>HI^<298h;K6`&A>c2Bkc^Oqcq@E}FG&uLSe{NA8X)BQg)0iyEC)E`B$P2b+ zVEKbrq0i}N|7IM^-F)=--pQ>?$9{L`#!WmFd?O^-ZY!#KytPGVxqX3jJX1k}gw|zF z8*ere-vabe0rl&gK#r^a&YKNkL#pNOlQ<|?UF!OH>HMpWPf{4jxN+*wj-|Q+Bm0Zq zUHJpuMKtI9ioRY3^o{rNG?!XKra81UuX1yt$E%efGkUu7GKdvT|$>sZIylGf22j+|zoTG_5k9K#WAfTmZBpbfMS_j+Z-Nq}mlz1hoK9ng8NDFweYBzXHq~UJJ#zK}t|xkER?r?p(NEe; zh#llvVp3&oS8!07Eh*~Rtj0nir9G5oz`QaCN6&GE&EDOS07uSM`j;WMInnH>e~P$s zftEf7>D&n`dbv_f7gph>vrg+Q&X`n-ceyBuF_P!Ty23_LH!eX7bdTkg=S>tJ^RnA{ z^T!vCW@l$9hN@?^=Ccg0oT0dwRF18!EN9YLN@+O8klVEe#oJT`-{Zw*&;8+>yr=gi zh%cU93Ni$Ywb-_b*3GhImw-$uM}%J-UUayeSL4^X@TZR|BoO7v}0q#P*M z?k6?0SM7hK5F}>+5wZ79aCqg3-P8FzBbW-~aR`W@PO6i+;(>mjS{0h`*E@4s6Yje` z!edm!!(-9$u;_C8WXHhZ|#ur8_a{Kg%jo}RQvvlIfzf>-Nz$60{lGVJrD8hL#twyKw)sV zcWMy9L1^NjBC~=Vlq~WfT(#;a6G_weM~sWD8`+rVne-Z@TEW9$DHt1<7(B#~jf`Xf zqOF@F_ztt`;5nA)1Fq+6MJBFcN|jx0ThLja(JjY~m}kpUdlv3C^4Gd{??UjH2AJ%q zi)e~hqaA|7JB%sUyUXnSklS6fyh%T`oR-)vBPa>%ak6%RX2!3^=#yfF`tgTO+O0SR z#p~%C@vPQXnjY4DpMN8?z&8osuP!1o%xd%BW!*XU`=4IMroD$iuv#1m0KYmAS4cM( zA{4Zpd}qg#W8+{TT|u@)NcTTA-$5gx*>X*tgG~`zmsI|7Dg4zpWbR-5&IX5uFkfKm zN?1l+^NgU`yyRCL3A?0J%+q7Kurd8o<3J$`!FO&krV`-ss{24$hPTJYcAP5A4K7N$LyI2QUq1d&k6Zdu7Y!G|H zquV+2d#rIpyCcr#1kZoJlS*oS%(!m8Ixf4V7OCUZ=W>KDuNXXIpW}}5h5B2F-a@rs zSzNFF#WC zzsOmvS^5E;?u&PMLn{pcNRWcdbohh@xuU2^pFhWB;)AbZCk)Sm_P7LuN4FTjf9O0fvCZ-N@!KMoh~)sdDbn_BwHqiuu3mV%{^dK} z;S3ZJ!a9%>~D~^&(pXCmDlI9WTBJoHM0|Ss$WE|a| z=?`l`NRnR8iwe?lEDD9`pFPi~0<24(OL7ct9%epkP0xiyt zwPJjdT|plzVc%x-7_5>dJoDkbPU7TsL8fgVG45O`v5`k=`QC^-r#X?6;ZJB0UMwiI zC)LS%Z;@s{Zyah@j!1pC(Us{sQFT;vv}W8MQ%Xe602E*(CSeEFC;sl8P`*A9z0PB` z$Ugg=ZE&q^6K*6Z6Nmu9VY2RU~xkD^7hi~@?`vrxZo8&X$9Ku=xUwXcG(kliTte0 zX;1y!Z7dIr$ina6>-dDtpt?#i28_`ZlkKas_9 zcQVos9Xc*<`%MczLrF~R5|+B~cZK^Bi1i&MI+*2ubuu-|h{5>X77F+?`e6y%{aUu0 z4ykUdTX6huUDVL2EE^wF(zE&%p8)t~jCN?x_Vh{(LGUH#r`sg0?F|Qhzr_^Vg#;|W zZ!({XXl2%`4#a7RX+1?xi)tm%7x^ov{U+iG?-s>Af++NB`i3vd&iihuM=b|I7T^qo z>n(q6iqmz{;;;k*Q8J%3w6)#jPUsg^`;y7LXCHs4ZeeUZPEd(&_YoL&yyScJYr9VJ zZhYq$+f|#QofzPp*QpnuST}W%pw_t*u2^BC+yFwpu{m|>adDjiAgB)Jp5g<{ zjlXQM0XEh37whOGlLx$~y)UAqU$ehdiT`Yo{`SAK6))L!G7#4~9z`(vrsg@V167xe z$jW>tstH*qKH<3G)34rVQMq5Yz5Ou*F4&*gYfUN>noNr!BE}}yY|Fx&B{aT{EUmYewwOYpoAO zzIRER_LmTD3J)8I7crCEj;)NZ`Z0DdWw=}2y4%OL&X}{0`74%1pAN4M&bt1{GATFf zD$vX!aS1B&%W}(j*M#`^cpueOg>3g`ej1G1s~VpkA-yBaC&Ut=H#L%<6cnzn&iscu!BL7MlJ9w> z9>jwDqMY7LOs+&VRp^gXUT zUM=Dzhgj)isTfR_hW3E{6BCQ*rE1>GbW(!u8>BNUS88*GM~BgbjHmv0i7=XLy50P7 z3y*N{0zkjN;W$`*bRQpEEcU#OeCH5D^DQWDU<^`25_3r6C|E(X&14 zO_vDK978)gz^bkLL-r(@ByS6%5tXYUHu90s*Y~hp@;|S%!o;>8BQ4Xj61C=~K{wa`R zcc`D80CA_yh#3I2OT3$UCm@OZrp!4ewnbl396OU&MQh?%B0W{cQ3G)g!gwR>;@a}a zM4e^@p$kr$)*OUO!8!m{8b3}W)_MbQ$n-3hTX@+2Vz&iOls zu^GY13$D@JTZimk7YbM%cF;rdt7v@t7=8BL+z+!}jHm}~OZc-?1dKefciwWxnv64Y z7Og_U8&?r6r(0DFTaT->#8g(zGgWIfVGb6LEtgsi&5Fz1V@^P~*rwnzYp_5vo*^G+ z`0I0eNSMU$*loe_fRUWj;K``gHUFXNNr}5_jME!Yr^@vv5r6=*PGsf>B@uNcxVJ&&b+z$>dMy$tVLLP3~9v680k(Z&&9PuM@ z!)37H!an!pr9yIMz?wmIoW6|Td)?30YenD{X$B?m^+v`A61Rq*R;&&!GI@Fru zw?y86k|529;~)NKd5VViLlKbvB4>t{JxA^sKDWODZRuj)wKhfiAd#tW$UXN{Bw#|f z;~P;%mGPZ|Mo_@#C=D%oO0{(VUi)<|X#TbH8hjrN#`HO*S-sv_qR7t?3*yS7=h4Gy zisSNaQKA{AWE?2P&ZJxnFb-%fOXKL4`Fo#PY&qO63`?u#zqI{p_S*K9f)X+I`|JC}5Vx$HoSzqJDpsI6hn^ypGUGQl*SD(i zaTKFv%YU!NjOPo)7c#Ra%I)rj2c+m%ZgPT{-i! z7wf66OBYt4z=>ds@yHP$GGwPaD0&X_s1y|CLy5_|XtkiZzPn5D+#i~loVOKr+0bN5 zYc5}1dcC5ff&z$;8Jt$SjuKQ|@{R%}orqi#_3fb!BDvRhoET}~mh z6eb8B!jI{7Zf3#9`uR~OkNB;Z|Da#b7Q+iom>2^H@S#SV9dZW?s{3GyAwGc=A7v!` zB@%)q)GrShFDE#bY|c>fcLI`&&ZuzOEI(1Z4xH&#E2E$=Qi8_#q_c%TwAFoba)h@o zKWO}mFw|iZu}fHGu(^Lw+_-7I_%obt;!7|27pc!+`gv@806*R1_NEV1|+tl+n@7Rg0kgTVD)<1EajG-8j(gvgW|TT;qAYV zLWCDmM&h^IzRi3I_w@9nml3bi`j&Rc{?grVDz&%`RHOcJ|MI%j@Pp&h^!uoyq_fpl zlQf*}Qt=gORB1{hYg0c8Nbk@*+FwC6Vm^+Sqj1Uk(`$@1<^M(#$gVi;S5QsHN1}+} zcN5EoaOB9TLA%Qp+$z)3yT1qYAq2!Q#0Z8?@*kN9+ylCfsVV+KmWCO7>p?`bpiO*Wj8r4YjgZwR16d&5^k6O!zRm< z%Bf_VGFIrrJ=Z$$(*YYP2~7#WQc3F!e%sZhSL{ad5tHF&7&nRs-CCV&D4*dn_)jfZ zDDL!_j>`YpD8%=0s_D8iOG24WR8dH8*-wu@Eq>VFM4lG%ql7iGRP&5Ijo+u`($2kn zM43tX5PltCbNi8+K9WS5jFdBS&+^=|gd;`#T$ZKD%X)6*UI2h&H?LZly2WvxZEbe8L~U4q z*qJy~o-Tm`p4mlItW}a^;3SZP*6bwBeX`&6_boFP#njlk_?~UVt;RzPpxqk-nv|*l zEW>U_$c+!wK@q2P^V`@O0_7%Je+iO1aq3QB#9591d z4XMvaXt@0F^N zAnFga4glsYPN(S@h$h+f)zxayt%+f^0-X3T+HbGByL)qUvuM-8Ha|DBV%}-}ZgsE8 zQ2Z!WHzw^?>I0~bmLx|OIoFE+k+gZaKRJnnRgbCa(Ofgtbu~KJw}~0itqU5yNwXWA zR5zn7pr$G45_tj06z1xf+tsY3MRTE{ZW%^LcJs`I|1!$`zuSlMYX<}O$W?8!yA4ZO z>a-@=u0i@v7JL5-*PrZNdbVl5s9*nWaB759xLtyW>Qd*~u9_AhAK`4G|y=psz_gL|u$j z9t2o1x(ZJxb8m12?i-Q-QO&K$_>-B=y1dijb_!eZ-mi~x?tcK{R$1t;CW73alBPF< zFh5FO9uH)6qiJo&Sr6`CwpXXd*pxMcO0zdsr|^UCU{-f~(ZsZ<$t-BU7z*<1^8HFo z-K=Nh@=d%2_-P+xjw{U@Dl{VAgdByOWr|K--rYHd6s&dGTq3xQ9`5fCTLQAnuNC?z zP1yBf%795?A*aUh=})zHn#lZRw6O~Y`=-qmv7vAG!yhRnEC2rey9QfqLV^?2mWAc$ z=jzT?D)hCWWWnu~ac&-cPC?crwV;OJAW@%OkkmWOU0L?{ZIl@uJN!g!&yYo+Wi|cC zpm@OlMO;~k5f!)&5XZMnnX}KhR9jhMJ9C&c;)1K!aV#7;){EyqRVvB$E!C*<B1&j` z5IY zl0_M=TX2G67Q3ZHgRekp&)n}l?r4rqxI+y2iN?k(pE*9UP!)%4jhD;{47lWM(HU)T zZf=zmn;bCO5G@$#OX01Uborgn{nW}4a^Xn(;7)ssyiZ2RLE3fmU(0~t{OtU0zjM`O zW@I8f&H*q;=kqMmU?ZcH6yQ(tm=N19!D>OIsSJSC<#6hC*<`t)3(2!~lN`lWYA&Kx zu>=M^mX*VZ6@!Ym)NziIEM>s=yCkLlnZBWpR|2Kit+a;z+eQ1LyO5k+)%bS!S$RZW{m^MFsDq-4T536crR=g&>RZ zi+P?WXgtJk|MTTrUtd=#>nc5y_T$=j&bL!xha#l}E3Ga9gQ@oc$c^1~Czlv^S}NpN zZee8w7r-$S0dn_n4t(6w7ZM(dJB}+EvYIIoZ?%o<2l5IK1+4p33o+YMAXArqmmic? zc})|02$r}~4K6RlkM7b45NOo3d$aam?F%TKhm}B#g)t~0c+)f(8mU9$2CcN$Qjur5 zXi%qCrX7OGOs%5?dA4IF@^M^Y+ui&M%i>w$%bCuL7x8(nZtynGoHdEhyB^qIRjQ|mgU&ya0vH;A+1a_-`T30#kqT&bllgC# zMUF<&_yyF}$0H$Y0=~g4#mI%C5lKm;q-xQ0w_A=){OqDjNchZyhKBU|hSk8ZdI2{= z)~t+`<0Xe=Pz5>Q>gWfklGfJ0@cPcnWp8yrxH%f9!9~ymW8G+GZ)D_n&G^3}9hpM3 zO3R&CgOiz6IH7`o8lGl`pM-rnAKhFB4lh9Kz0C+sGNgto`9#jk$;Z`s(pY5$|U2Ov!?E)8i@*ave ziG}9~%aY%aNs@kB?+?=PfMtd3TJUZNmA>`bj~o*7loMO3T-Y_>FjbqVGF{&*->z? zeQbZytYY86zy6hF-vsA-f?UOA{V&k}|CEZ4jGuSsW_au8%P9-J!w$$5%an%1MjNxT zvaH3vZZA@raOpOK7?lUcR#n&-591{pLaUmrP5xH4JbTS137IB+b(O-UgxNy=p-c4zHVfYXVAkwXX zbaOS%LJ=}=HQBnKEuC5CPg z5D*X$aY$(xP)c$@aOeT40R|YlhPaz^&gYza@AKUI7kuXjADr2HzxO*PldM#@E|`{BD9KfYwQs^+PY%ZqDBwu{Wli%8&gGv57kq*+(mtq1uRt`O^x z7r6YA7Yh^pd$yweZKXsm4nJ~d2bma(`&{1RySa+8Lj zStk1~OzAHl!w7AR1&UTO3XDxsyO~274 z_60WSw&p5W=ROp|d(K|Cg%3~{9&jRuWk?yR}|{^%tq@l zsOe;z%Pp8(OdhI(28h4mQEXTSPu>?$0xR1LGk>PF( zMs;=Cf*vj}jy27SKOw(oOO z@(_ixf{o40SQJ_zc0aTV(8O#6(Wn6a%&GU3RRXi;D~UJny|CNsQ?Pb-7r3o`a159x z2GpFo6g0$La1>$o9($8~5A)2fHS3kPfhaUy(-TCgl7!TBOe=QG6(hN48tR)$4x%Zp zyoG<2Q3Uk+yhcsCi0}BDzy3j($y?}DuP@+YCZ?~#5fnOeiW({@D~M{czXkhmm2cAy z!*~v$L{%@^!%QV>`#mKO2i(BZ+3#?8cuJmQV6vxmCS(y*3zL46rSE9EbYLo8f zM1Ftde~oB^RYRyYZh*PscOL}&>`U|M(dP1nTu&xXI@CM8`Lq5O27@Wxr(vh=+3D=$ z5MZYJ7(RJ>Y;@Fs=XRPtv}KAWHqmxma}a?T|MGjd-_kJ->QOZ@J#EgD_Q8wQ#u}z4 z%eo?VL#4x9j8?1brCg>&&>$r#EX=GI4u1t@?!9NWIZ=PBW2SK^ zWZ6}}2Q$p`^agq8O?@l~?}NBzsi*E-Hgh0=y(PUByj{!5ER8#RtQ*W$dfPN3N;cmv zI`MYo7D-lxZ;j#`CoF9!K8P%rdw^Qq2@O=LHSW(`1FtO1n`3K(g1&@=7AjWFOn-j% zIGc-Sf1hFoqU-PJv~bqwrI8%o(Danc;T5HjH<+FH*_NyTNmAKw9*`kVT5c{g=zVr} zc3vK0j}i1u$b%8k5yxw_yD6rh=7WmIh6#vX&k~hNTDRh+@BgjOY;p}e`>pxz*C!e(NqZ`9X}i_0aWsx8dg;?2yfT(D zHEZ>y^od`(wNUooEBN)rgiG|6dbLcyFo*>>rOMHtpGClAExW)Rb^?%FC)e}iJoxR& zlg17rt&yVx#r;^{Cbo_tD!iQ)>5nc)(=u47q6P+~?qq z-un|()U{@V5bH9d$Vk;Rqh&jhI=fG;OL65!WzmLJhF?>8iQracz$p~((XVw=+X%Oz zqH6WxRozhUMA?4U)BTis*!XVdE-eM=;nDsfa>j-I34&1?PEZBzS&@>88HBd>H=F%q z2To_$Barx^Mm%AdO+q@~u-n=nxp%GXO^wRXj=6fpPpwz3RZ`a9+y?-uZj)T=A6gOX zm-z^`a)d=?vD>WP0l;fOwMr7o90l8tI?Krm`3cwsyXKe1NQq(0p8Vw@t!`kG9`DQbr7IJwp8KW>d`HuK$K0f2^!RvyWhH zG(DaImy~37HjLFG?<0mxR{kv@GBfntsSgFIta20f}WPK zDU`jFO;MwOh30}}-d%s`;zpJYw71caVB;c;{btj(QA*BlK;w=&8e+j^&K@!O|H+AD4Dwti8)Aa=^FN9~ZxScaPlG~R)Sl-->~!HE zO=({*NBz7H%y~KwdLn_O+~#I8#@Vm3%i;KjnpS#tUQFD+@Fa0hY^pwtz4Au;AxOE_ z?Am^jW>JWPprfdro!yt(FZlSNB5%f?^Yd0c8pom!wNQ0Up}pjS9q3O`t(hxujh~-M zWcdeZT!1Ba+Oep;`Rc==-zx!6=H}BkKfk#6y=Y4}bVPcXB9tp=JZq(3^^b^w|5)$o zZ;Badddg>lz6wQsfR0_~%emi4rW76h1(3=*&vM0MKBoXG%ou3%*D5h+m*W6+Un3da zZox-GT;Eyw{zm(7BO`kO>4$^CcEMm-Io8noVFNTJt2{!v|VZ@rqBY=E8b;7xo& z5b;x?N;U5p3GmL@hD*TC*^2a5`NsR6{1@_4q06^=sMgfgs;<9oCkM%dT~lGsK0vLM zJ%d%60M=aE*Aa)Yb$$dbrC)_!mxl!(Z6TL%w;%e$5fu6&t9{|xW=^C>Hkz?V`BPf- z;`>YyX9HiU=%;!szif&U8{~dd>*{E`R2Y#IO}w)qoSTiC`&+vUbHHfNdmFlUhdV2W z({LL^lWYW0ow{JIig>cv1(?CD{fe^?}&L2&D(7`c;NJozdq5 zONMSDY%F-X&A71^Zm?P-=O@rlhSHi8T9eXXnR+CoA{DUoOn8bMvtHeS zU_&9N0kHlk!%GG1_V#wvUWK-{_6GeOGem#DsK!;YGLSChpEyi!z()h1@u=5<)3fLl zz1gm-5^}sFx@!xC)odg+%+AMse5QgAG`6f(wKuhM1)5ZZ3eb>z)0Ezdv&kouIGu3F zwnGM}g52!tgPfyqk4$-TYHDv&Jp$F*SqplZ zIQ=nC!~fy>qv>2CK1H=-rEw2F;y_AH_S?zJF)P_((S7J1KG@+vzJ zSeI#28M5lVQ}3=h{!PqnVhi4)mZRThPn?-B6DPlUIA9+%s27ZC1G9tz4mTQp-(bMY zGWe8TYIg%PKn@aRRq68^%PO|NJ3t<#k;L8kZ3LOmS6Votlji)^vnug{zmw(9dXZbT zOy}H5wt=&{JaW)*|wHB?b{D0k?lcEoDYM) zZ&yykANI!x9j;9{u)S1IHmlWB18jpd7(2U{NR3F!Xq@VAngui13SLA+1klwet7w;K z#m*it%S))mtJdjJbO8`^kccm)puawIO*(K1-4 zloHCNeq{$GxO|^}TEy~{wm+Mq#T64D1fD%_-wVNk(G;y8ey{9(OBdH4bI8&9kib@$ zirmy7YuNX3YYQS|es9LRDJ$r=T@^)RE-o!CY2Balt=eAK-KVL=t9j6CeG{w~2Bc=B zYR}9W+*#YqlZ8~tN#=0+z$y*-RnnsQZ$nOT-&C2@9TC~0$h4w zl$Ov$89z228sa!rcEZj3mQ9nm`^v`V4}YmfsPrz{(R%UG#zcQ6*GPYrG^jyE>9+m4 z^<0#@b2GCZ<*%@6G8g=eQbq`LY9Z~=x{@lL!1Ja+eC(*Wz7;Zh$eti8?0dW%Bfz4= z*}d+u;wd~;giMWA5gk#aqHx(1VgGh_=;54JLc2QYCqaFx#K^``&e16sVISYu3+1`9 zM+;)}aN*m4m3&IP9IYZJoz!}vDlE5h^R$js#p4SkpszLP3*~ruQ88lt|XRS zshfU(Hq8>tah1~#_&JvyIj0vThFpP=cQS_uqgJ&gl()*p%M;|v22_9m$%w_JMrJDY z+S?0CGj6e(mMDvz-~$hcL&7+p$BkGN0TqETp6%9my-y2@udJ~^l21w9CZAD+Rmf>C zU(SxdSv}GZLnNcf`fJc?6Q&n&zo*uyq-o_Ly;83fKImn|&1%uX2b?wQGaFrNgEf=I zG6vJC1*pE&RR_{?kBfyyTYG-S0H56-^oGP2@v?yh)J~opH>;pOGgI z`0F9R(EhhorkYyJD*UmK&$@sa>YrP4gu?M5a%u)*p+K2--<}M@i zfM?aB{FONhEmxX+)mPI`X0AM!iWXWqf5abFY{rHHmG!(|{Ih`)&aZ|%Qx2d?CUN7R z(1v3(3uHY>ReGgwmMnFWm{c?yCNLMR$*b_vZakWy*>!Wr$!ug^@$&E^4IRyA4n)gd zj27IofWl&W;rF${v`rAmA=ErU3#6_3M)5W2?}46HCF!|6*>^Wk)r&dq^1JmRLOZAV zr8~WF&93Exh$jI)7V+#wM6}`ho21N%7W6dF?}1)YfM#}L%~H@e?`V45d>=@DGB&g3 zE-&YQR%>}QSA*S>j?r*++E~c!s7PRvmi0gVTEqYB_2GWf$Y^ujY%Mk(M?s^esum%p zOG8YDu>6oyc0_7azcoXf*4OwX=6rc)CuUEiCS1|c!7q{Yl^8|W`g_yD!^XV499uMB zCEHZ3SN+J&&v{C}xd3jNk7{h!hkEZXaYf%_+%)&u%?K%PdckG%x3UH0Es5i&Au)}P z;|u2rYnY{{zPRhTo0};cNxWy7-Ah$44fK^BHe+ygNjJ-$ws5o+^JO~eQrBZ-NJxB@ zNl_)FP!@JZE3bzS+9iaUd2SZ!eCC@^Tqo^Q{^s8g&p)ErA1OcF%$nK&wfQ{f9}5 zO%zm+V4T92zwIVUVDt?kjpX<6^a#>|9ImbG?9lx#j!M z@lTLy4_55$81^3JP3v3dfl*b1Uo@3-C9+C{&7p zfo+Mdm>rOush=+>A^LQ^)WMa;@o=@}#=Ylk5r>yD1VZ$$#9XIt88u4(A_oc=Us8Rq z6j**bkZ;15Uo0pSrrufEB5f)DKk-E@RKxa+izbT3#tev}qqHCEoJ+s8~tKMv;JnCNOX5u@gEuzTt+ESjjFR_j?%Wzl7(UJSz znd-&b5_r&hT81t>z`@0rzh3P7*XKba$8ePX=RI9gD03(>Gus#G4+Nvx2zzQ{3^ZC* zp+P}GNGS@^Tw8r#xE}xQ4U6+JuQ^Fqt86(R-~Nl%pk^=`c2+5^FZ`rYCfhKruQ+KW z|ND!cjR}am`NLTmc8Oi)j>}LgMm!$ZRz%KdpJ>KR;f)7mC_gA8{f%fYu-rG_Hl2#? zN38$wU|L0G1T#Gwtn@1oZ%O6=s>!ie@SK`SwQ|t~imcHE>iE8_{3IeGASy@Lx=;4Y zQhep>FdyghLq#M1gM&@pG3horTr+=+{iG<`1<2Y`ONs(~s-||ZBa{e)LirO^Q2UAd zZ1A5u4@2CXCNaZ}(adynF(#~ryJP4G9x$Wd>!b1u?93l#7P+He`I#w;Vcq!mHHcLY zXxNnQwnJ8oG){gO6NpyXD~oCpfc&n5>dHBJB*OUmLHD!`fUeuDFOeWFTj7kn;PbQ^ zv`*ZIiQl-sKM$pkNa_0Eh7+xqL;6LenFlVlBbneKNeV_>9a`?$%21D?V27Ff4n-rZV-Op7Ji4_69>wf;5{xG3ZC+mu!3a|LN6aTx^Pl%brBD zuv!#k`)g?8n;d)eWA3P_@CJOdZ2eLT-IRbVkOCekt(Z(d+$~7v7ClgYNuS0MRf{rJ zjJS_g`BPK_lg5jn!voya$D!X_%t%I z|Gc%h#6|Rli^MOhlg}Sh3v$fGpJ;2-SM!%u50WHBr+S%|KTNnSibfh6sElcN-PVR+9yhEb@p6#*te+r7EI>cja-5yfJ~9A78-pdM0CEPVD7 z!Cr}0U7`0b9UGk%SD2!~1&Cp%s$LzD^W65)Z4?@*ZPs3i9*+5reH7{S~ptDkXK zqf(5Bo|n~5u>Y<(?VQ;UuR1ARiz>~$+qCH(G&)ode`S@g#N;1NO8U*F#J7}L;4gEl zjFrPy)R|wo>c~x_9ihd*Ov1_^M;VnWc302TnHr?ZMgJ<+jBAT%;!Y1g_Xp;0kN}^e z!y%Dd&PBDqH2oFw(Cq{8n!4pjfn#>7I_)0|ZwX2YTa2)SS^O&7z@wJ9w!J0xND4V2 zN%>6Abp`c6<*POus$lRTe4H&Gm1Te_?%hl>t@VC?WANGQg)41p4&3EJmo7Ni*I4 z?h@(gkWU@Yj8oi0Z>0xn&@GzkKGIMqxr9bFpN1CT7XTQJEV!lgM>78>j_{SsPTkQ zz{hJ)+529-;Uc;r=M{5)%O~RGAthlSAErOC7}M*eA>k4J#2yZsZ}(yDo07h5r}5M) zKt?$9erQ)kqq%pOh37XHxtx{mn{T)cvu)WZ#-u0azKYx!9+w`5Q%Kt9(?@cpISFGN3gK!$#yp<{bhEu$rp zZ7yw`W2~7pqD%-H*l1m*yj$m&$6y;9C93!J2@%$PO2o9psFNII#;a`DwEDH8M8m|p zCbw(JxQ`%I=}W+{a*<7uJvuj1ZeJHj-IZ(c!IgGI+Vg+tua+6S_jr!ZcBC=}N^=YE z>%~+ApA;lCEka!?WHkE2bjl{(gL`4tg67*q14JJ|>u7xkvV{1&Y~>%rEl(zAgb)wB zv^&EWET-Of1kQnFy|ECKCF88V{XJKD3760PDVu*shZQZ1XKMqDpmF!T@9w?$E+wrj zs+z(^eZtr8#T_ucdEGdOKQ=#(&%LPJM;RaxSPv9~27Z zxek~MN5LAL#AXcykI6w+r z+W1V3H$tl1zx@HL_k-7vnLP}jFqA)jI{WpDM;D5g;zjo&h(K8$(v*B8`!~-303w|Y zs&@8A^u;AB0sXCMtw9jT^?r6GM93loowd}j_hqb*Y-w*D2w$qtV>L58q2$Fh&+*{e zx{Mbdxcin14>6zZu-_rOX3m?n{4~_A3VYJQ^LeO)_{a}Jb-4trdM_=Jzt1Pl|H)qJ zy#KYe_blZYqj`swg4`u&|@f&JBP zjO<=ut5mLS&yZlam?N?Ya3Pxk4!B&8k-^(HHhJYU(uxZ!PK|qh5~0}Hx$0z-oIl71 zot2^~dj?cb~nmNM0 zKJw2s&z8i>h*XRnon;Xj^gMi{_FAD)NTT5twpnZXXj66<)}teLbQaED!G-^GU5GpH zEqI8Rnn2}a`KH;9PbY!|FQnBdp})xNQCDv6ECu`kHg^;xDl9B#&HG+h=wAMw4y>xk zjqPuxD2&pkgXrP++ZS=X{Hku}$-RZ+gdj~k&k-Q+=ZOL6X*bt;MD-44BtVK}Z$-md z`B(SnnH7IKOA*hu!%;#?K0L11L?0>l?y}u>6?iey;yf3LJ9Om^F2i=lAdqN*VgPgV zHu1nJh?a*Mk2S(`zf4Am(4%>_!rju2aUYY+fuHNzd|C_8pJ@SB z6qisp<0Wp$x@5iRkZ zT6pdhu^HS`_Wn5}QmFQ`w2(>m^sq+B>Xew803^k6$Y|Ds$K&90n+QxH>4(_prB-so zi)TkOg5xWa)*=up(Pivukj6{V_Eq-#tG;EGq)*)es!z3OqL*> zvU!CGzr8RQ2~Yq%VB^qNzN+K9nN*7uVi$pcn!YyPrp%7iDf$lI6c5Xck|UpWYeB!mOzh#3 zLMRBK!h`FG53IlSW{={*8-ciKlIgnpVd;~CHpaT8CTagze2UPx#dYE7u+MSv5?>xA z^YydA)P4MAs}E$XDL~4g&?3cD^eT-h#tZ$v^#(`F0-ByV2&SHK$4h}LRShdQ-1Z8C zJO{TOZ;KblGiS^Ade`Z}>@^;HK__C&=$;rFUSk`Jg`XSMiRqHI2R{t(_FE;ciQ|}3x%D?B?m6gwjl>-I@ zg@Ga-wDPBNMID zuBQ<^fCBYQZEFy?{n8e}UgEohD%NGz{k&$kXNMK+e|wGrMYx_s*)ZDM1b@?V%QA{+ zVs_))>~~SI7bJmX6n7Jk3Um|1#Ln_x^rsz^p=59DA8d;R1l7xJJ$eu2xm#NbI-3kajhy@&>c7XjdY@5u!=_@A28h z?1{N-KpglW@^RuU{lwI2Fw2znD8d;7m4^iJ})Ftwxo7f-0a@C>V_;o`KEcyM6D`E zk>Q9WP|}R5Z-R?!4O{aL8isHYVd+Kg(W-ULN>jKn-IxNrs`Ovz-@OAO%&Qnfq*vBc2}f&9}NKgJwY^gb^Cg{OpCC=xOdsu z5Um2X$fOtg%xNWpLRME5v{%LkQ57DgO)1Uz2xr`7}`RPKto&BLc$z@)B!D0@2x zfFz%Bs`?ukC`zD_awbWa@+JsCHWGv&?mVWUtz-1VAWYhW)%=E<0PaQ65;fpdY+NN3)H$! z?hj92pYz7<%P^EZN_vdU6&xs_sOVEAnf;ICeTnrxp#3%9zamqhprq9vlcWgjFasbp z*q{YOAjO$@(w08$W8OE+AjVJvK}O;mlpv5n_s7D|7JOumHRvtK7G(a-Vx+Z<$I|xL zq=A9G&Tq_~qrN~u_7IXp)miD<7_IJAOn?*=9c4l#^5#c4+i_>-i?83`x=!#7)~H7B z=^VMD4&x~RH}k9R6?2QZfwScyiNyPmtVm{;k~;p zpQVDl(s4hKx81K94Kx+n+bMJn{8SihV0uppqG7AzR2YOas+mqTyx!XAH74I#n&>Po zEQxP5%Vw^4lHi!YVV?lPC2+~ z+iQ$JK}B!bycbMp2MsXr44Hpv+_1P7zb8}Q2uArow;~!nPbT-u82Nz`f9EqkrR z8<5aWME=u1LC@ngg6TZFo^Ee{qsq8GS4zrbYiwz9v3F){}Ren!|0Hu^}4Xa{{Go@z#TK0%?Hsa2L1*6cv`t)J2xbh5uL$-^ygO zt?HkoXdnc|aFf-^y71T%jLEIw*zC$;MQh1P@xd z4Vq$N=NSdP(qCl`wz{u$cUViiw*)%#s6)=Hcxpn`inGmLqe)7 z)|eO%L79=07WpHgoyhQYZ{lpp6Xy$WH^INFhkz6A#;#S}6Wbm(0N{22;q|XRL(O(* zUuEHlSfMoio1jEl(C2uG#Bps`@O`t1R}btCQIX+W{#z0QkKJ`2IFzHTC1bGzOH{HAkSP@%qe%7O|g=^0z$$m5r?fj^~N=?98@N1A!giRPfIsv6M7Xv%FFr zxKtVTot@3^fpSr*5KnY3my7*W_5l&c=wI8> z;y~DE^)+=BGwEsVIuU7ovhZ2GFzmF?>(Uag@33}ej=!*3Gvt*7*I z-!wRh4wk7JGEdYLRPlA0@npinq(r1&L)8a^4nc!pJUnQ2|CnrK00A+GzPH90^jY0G zjI-)YC|)l&3pLyK>yiJIqL(YMjt2Gv7mh_KbnwXArfd-djn zwZ(HHcs5X&(rCkq4DkG1JQ+L>dI?83T_+`*|4ZF=Q0K5kVnUc%iXM=RF)%-FeXWh; zALkr^bQkAc9Iq4)_7HBQoqcF}iW)dW5oaEDRkSN2EvZ2~s>4#(hep_54ngMlKDGr) z=UlGFu1il(2oUz?ALDkj!@?v(t^Itc6j;O7wqay=LLc3`mtgiI9~>x%=1uVg1*sgv zrxL|JCelS>zERCR{&$9o$4D0dCiRCM5_LRg2)fNZi>d&8st1 zz3=k`mn~%}Q0r*TZ6HwEeh3c2WZ?U-Lk@m3ErlTmUIsM8Gdlirpl+4)&X z8|}ZNG(3R`v*5(41Az|c3T)dmRz>9?5}G?o{%$aEKJML)>h!Z_ z2txM;b5vi3e~zLYdH<8X5c-sgjlJ1-5a`s$T1hqc`oF1r?}pBp@orJ!(Xs(>m5o1U z&Cs%n&fPMn$heATL96!sNrn_8PxLn-RY${6=xP#Pko~omIGz6~Bw>_K##|D~L2y$` z7EqS+3@h96%~nOs2k>)QFOv@Lr?qsm9$2K}G9OT?OPA~P#1YEA$m98>?Ko)6YeZ@q!ZX}cY@IRtH`bpX4K zNBZvoxBx#xJ3$|ROhGJaH3Om3;-de}i|5eNaV}w8ZUoQY%ucc^99eB( zhrLe#qo}DwfHrIse2Mt5N8GHKl_g-6w$woFTDL$rO{XUEwDXjZOYJ} z(4Vn9E)=r61G@@lVM3Ulkn+KL(YB}jqr+SpKgJ*2Q64U06dU8&+VVPZeF zjexzC(FDKnV~sDBG0 zmNqK0#SI154l{@cD8tGB@m~Q$HUtcoVsjp!F6&qy`7s!#H*5dje$y-yb!xL?J$4G; zaSQsteXlrZA%*Wfl-#xegB?ab6F%fMOaSn#p7I74(v>v_Me_cmMkzx1IsfO(2%|JE zDU?2q5Ew_j`j6KudUO9Pl)@UWH@P18)o&uwlC=Mdd|y3gr081)zu%Qs<`BPF@vBnp zYMsQR{?%@PF=~e%ZIUc*L6d>(9U@k-a+Sq{fx+LwP;GUP-5}1&jX8L8Mxh7!dhp?% z({$2i|KTfK^||x6b1fIOLv#REw|=c0!I_)m@YO%EsJU9yVNS5(YIqNGGf&wOj|DqB zjtBbDL{v$yVWX#0DYllQtlZa=8sC7~zRR#YHv0dV2*de(>Z>=o4(=t%Dr@=i&+DFR zp@MRzlg=9(aigwoW0-`E46@38QAuDBMZS7p9jkH{;4dgfLBNR+91|CK^)grIfs$eJ zKJr_ICAqF*;NRDtojxwVb9F5*JNoJWt}4)4JJ62G_-XaO`x#o-89alLA;vfDW=dB^&teVQ(DSoWt*7V*l2+SYz%z30j z%JctmcN?a@?Xd7_m<6YQ)j{8T-fYt_XGeC;V`ATY=Vp z;t`;~|2P+v|Ng%`1faWry}LD4D!zo{h#Ifn^*xp z{eMo^-%IiTODEzocJRsOza0^sO@cK?R^`vbP2_`W!1KF6{n{&2U0p`-QPVj6G2LU~2NN z&AiqeAvOKnmlTO`U{n z3hHNQj6|9#5?5Fq`w0TDgBt>1=P*K$IuQuEnsc-<&CpiYa%7rLOC0VDUv4)=w-|hC z+V-kt#+;~1V7}Jm7=v?qBI9;v=@#r7H>lSKfBA3iT>Mh=MO#R`M*F+PHV#3+o0hwh z5ytE-e3rqVNb;&=5QSW3Xv?O(0ChmDr{CAJpu7%EdVR9Ses&Y|!@5I2;E0A=r`a@} zTc7o#Swb1(gNCFjI-S|Nyca!EM_-s|!Iwpf<4)G9b!|-JM!4(=W`SlHstFkKzXHAe{N=>O z$1j5NpF$TU&gw;2DU3aY1_xfG`gUn!AAX$9HExh{qUgMGKme(W{nbN9X56$YdQ~Q| z1)udx71R<#M)P$1u4h0hDyRFw!6)-w&-$ukGYh$zRs;;`iA;q1?+xfT)=_xp@cEaH ziar??)kqe7B5iM!u34m*|CA>!-Fgh*+nrLbl^@TSGpvL6N7Pa{8kgeI^am`sXqf%x z-$XJ8tX|(h{f3;bm00^Pr^V0-r1vkI!F+qC>0-LBe*J!fd4^JBO$L#`D7kx35ptef zPg+BI{rYtj4t0d`=wTWU83&}DVSSej!_<_3UHYpM-8?yuh0ZG59w3>Y-=aYfKsJ(; zob=DWxVRY17N;O3-I!_g0!moGGafzZ56z|QKU;p!rafFoT%I8iKqaOr_zZD*B5%EA zJL`wSO-@d_2QD%IF!(^x8iq91*cLY17IOKHLaqUfMezpD)Xrg|qM}AibQ2O1@}zv6 zoSfDdx?+IRjU4PFHefbD8m?!fjMUYq$qPM{l$0dib~0#uGK?8_5Y6>p$uzd|FS4rO z{oUey(>c3B*nwg)9=JhRxEi?n34pMGYB^a4H*9YI=}3?}T@5H7yWNInYi>DBuF(VaIEE7i*BMMgShe$NnXQ zPS^{JGjPZe_;Q=#lG2QwJVPduY9!bGF7!0fGC=17M>Bes0Y+ z<8U~@t!hJX)A`eMM)qd-U{1XUGKsCX+(HS3Ti&bD`5@u3J^wwDCHNSa;^JQ)pwX;V zMaO!DfBw^nCLat?jau(EdkQ~l#kOLDu)rQE>LtG>9;$Z9smY`C2n;+E57`sX3qD=z z58%H+&Uie3L!JnP3Hh4M)?b}>d6E~h7$^SYng%4m3wK>hXRiBw5biMObTkj1^^wU8 z>jSxF%C*0@5F-HJ{y~x;dpNPP*aL)QySuyZ#;GQHHBU zi`A=X>*UFuGqmk6%$*|Zfn@_I@0X}+o&i$giJ({Eh=lLP1VE3JJ6hJKzknmb61e?6 zA#jQPXcnC3gRHSe#~!_o-&$L1d|PB?eZFoycLYD`@|YQ zr+TuX<(``iY)GeC{nE^^2EUz!jJC7ow&R#Kznv~RhB(#25uyxg%t^4&-u~4`6eM#n zW^naTao4H+`F9i-?G$J2H{_22(1A}5tcWVsa{eDW5j&-YY6-%7C zS%>^tQbOP-6F%X+Wz~&w1~#BA2ms6zF3GJc3*@j}EL&eJ$1_O>uSN#1cCcJ*!-H^B z1WMvy`3{N~_AN(14}gSPZszn6(PyQ1->rI!s6ZY3cCCt2X@mz&V7pGoX_muKk9gSHC*&SdTq zcaA%PV3h(~H=md~#|eci{`r1`<4Lpx0f1}u8@K9!PIOygf4YEGTA^SR6O8*dqal;#?oqaG0WW^8y|4H8ErETqEH9fm_P1<%KICmqCs1Y zXn-=lAu))@WE2!_0K!TnQj`$zXRO)T+jBm60EnvBhZ~bhk>sra7rH^g9JpRu0zVma zzW`pmckXO(?yEgsWdq+(Bd}OOD$zLTF|q2>F^F)(H~L?IsxZs}AlOgj z&r2@%|4@V+3tTJ+0285%c;3V_Y^jw0)FH#x0UcJu*Zlg=55(C3;;d8mth0kcK2YSy z-3Nv91$_njc0ADVTpue<2;MK0-({Eg1f+osLO?QmF$)v;kt_OcTsIwXs#q>}ST1X= zakao?iXHD-EhXL}A*gm{asA+>6_Nf+^t{g)4&1%Vez_QOme3JNA>Ca5HI1@9pDe|Z zLM^Nr;M@Q=4%zMHY(3IQ=94t=&3)5ywAm;HNL4P8NSg0)b9-9C-GDS&$h6OO6nNFP zWU08jq!zNT#+#-I=}iF=0R$bhBD7A>z~NgRDRj&NxRB_RIlslYlarIRwKd>hfDtEF zzFNu5yaV#P#LZpG0$KRtkU95jzny9=_R))(q&o5K}C~mZXhMRjrGazi)h)4<3;%$YWW@P#cUf6*sGn95j}@i z5}=kOr*sXo4-bow0|8(Nhk#sa&O7eBYKtT-f1TAI{;9Yl+Nk9*E~D*yy6t?oU*K{E zaXzC&Aq&)@XK?f;2j5nlSrId-R8yWiu$((Uhg_fmEde7hk|XK00x+K-EM$&BM{>5? z6b#5hVCM-!y|$9oS_Q8S(eJ&JKYo{{dB5|-(}G%YM*WZQkm*rcMc^_x7!BT)(H$K# zV_*Za)(vEvWnMLTr1if7JzsGT!kD)mI=3AH3TLZj3@(erePLi=AOpT%1c0+$u2)>F z0~$d2qlH63+Ygz5f^#LXnB;>$K&O7c7&j{WZ54d74B+VHNQ12wOFZJ1{4Xvq<}S@I ziBTUd_}g%^SSv_np#jm@o{TYoE=0~GwZEntatb`Q0%#mFddJ0jr!+*?dZ4$rSN^n6 zKHyI{UGR#1@CqPhUN^iYjymFn3}^Er_5h{xua^PjT*!&QWl_Jt2dTM2K#~QYEwada ztqfd|BRLsaxUL>xgB{4Vv)M?D0etb#QOM;{2&O*R+0Jf7s@2UjUG{9d15jDp_O;sf z3Fa;dHlaaXOCJE@i4&URCek|vpy_h~5nzo}1}mQ}(6vqElw`^va=-}r%d@euF)8!m z$my1~C+<_tVE6P_{<-d{{>}jUnFf@X->OAINJvOT#Gqna34Wh%=u?;5;SkE{hv=9Z zv;R^?$Vo=q#X$vw?FX+vyQf*s6iJ*KyTDOAtG_&)yE?PA6}cT-{@9S?$dCp@9-pBg za|V$sc?WRFjURt&tSU;rdRPeipiNaSi)0Jfu% z{8HBO@actUbO9K?mjmb_dNh_uVLw1j7){Bx|ERTXj`cOU%Y@{w3vd4FbuF>slMDhd zC)|OJzF0`nmAeUZ+|J0puuK@bzs0%|c(&6MvO^cL11JurxcEPN+W-R*SsDOJyT(tT zH8nK|Io!s9AHOlTMVQRtge@A8*EWN@SWUR#`ep<3VdDTD{{VRJ6=#p)%q7ywO4HAG zXPHS1AJpV78r#^|zyfF>mjG8E$XALK+fQCRta1R3lyUH~@s%FnlG>5v#UEV&*dV&W zyD2P}$301c15*tsJQy;-EqIIKVxHn+U-!~GI2g`UWnD5pC<#>c;by!L=fJo7T`_dc zHP31&#rq>}P7&z8j6Jcx+{gne*_ZDl8#|KcfT(vHZn}*mrrUk~!YWH><#=Zius+70 z2WgG`7#p>zI=-5%c0#IRf5kcfHiA zI;OHMgR}3qeK#_$+3vq$^Mx$us|@Zff4%~s8P>KnMfz(ug6D5gdieVK<{*SLk~#AN zcb9ro{^ZS4-@j|CK4P07EB5G7?FA%Fvj|Xr+Lj->zcG>z*CL)PZ9HiNXnd6Asqy^nGMVzynqevF;dm=;Z^{{FrOl5bzWppgEGfwMC`V0Y%KuowlW@aK7M&x zaoL2+Q=z{(Sj}h1e+=kkAt75{c_faxm;eVdr8ieImpkdz1+@2wFW4N&|3%Vu$79)k zamgMLLLz(5WXs+}NcIfbtL&K_B73iF*;__LqGV@h%U;>r?>z7C`NOBTH$2aMU-!Ab z>zu3Zm})D$JIiHf5AMx%r{5`_Rcd``5s1)imUy%OAW;T~NnbTE=6JSVkG7pzlc)?m z9pTaVX|Ve3)~fzTRRPEOcK_SxdDrRqIbNlV5&gY?l6{(a1vXm_kfICLE3T$0piIs| z!pYZP9B^`ZOlw{95;>);j<9P_7(ol(>!lFRI5ob-jS< z0ja6O<}a0l<7$&`_KnK@a&93>tMQ14T%7>~M|@-eMTv9SA~f~ac7`JPzb9)f#P$J^ zpYR~Lt< zu&}jEZ`#&+~V0koNI>bdibLQ z1i|P{ZOB?ZgQF`GfkDp;S-W6Se>{S@ix9XY1)O+@vu-5^0|A z`u5#HJjznvd;t=T7R=A|xV6-@Tq<$49#(zu*{bTMjL5OfHS%}gZ9A^3@L5+ff@y5C z;*YCR&bmm9E&*LH!SQ4IA0lfk^5fr>8&=#5QC_K$QZ#;*BVE-`G{0HBxy(v3^jwz} zEytg+l|tKABl203qHHP+lqZrw$OrCw#;2=CB{@!?VV%0dow{eyTvpJ~aPl$q;l}Q+ z(Lf~sx!?V%HhYYE1(BLrOrgl{3j zf2a5K0FL5rXuBHv;L9VuHGV|*rGJuR;R=TCJ{`CdL7NG*f3v}qUzQ2;@biGr4m^%Y zyk3h1HDlK^b5}EnG79?Mo(p!DG!V%H*l2}_q+1wdIL5`Ae|pVX@))`=1Op&o-KJXQqsPV)?Q%%bo40p@V;;&fQ;EOUx%&ZC*aee(lzVIfIrcJ4t-+~)Xf z1D9X*0e@0U5tfo!QX7@sLawiftFW!d8TPdAw>{>KYizgOD+@RqnN}_;=&W^{Z!77E zY}vnRqrQLJ5k{AbFhR2A7$$c91bQ@WI1v19Q|HbxR9m>szet~#X=1JyP=-z(z#d5H zwN=&sCB)`2Oe+DTA^$KJC@D{>j)P@W>3M#{<~rH`{l`oJ6i0#?)K=yinz8)adF3)2grC zszFEs+twL^Dt8{)C7N%39<)Zj7zZieFE+f!S`eU zU9FofP6D$!Hb&n6x}c!wSV*aj9jWFaQ4JjM7N~TorPKW|&uHp!DjTwdJ8Y!mw=_1t z)4U6(@XeJTHid7vJlq5!M9Y2K=ryYb2D)Z`!Z6F$FXa}#rWO1cjRS=`bcROSVap`r z?UqtZKLbD6zo|;GbZo7(IeTAfOZY=Ga0c=Z?6|+{|C@A@(OutD(7W;cU5RhsC03Sb zxD<7ivW>S)Px&qd!gkfAkCE0&3#K1#S>A+QMXI1au5rUo7)@$H-nL2IqsEzPSnr7F zaU6YUsBU9d6!iOZsVDBo{QMI`=3v%;{i|?jqBXjvd%j8I9=-1CC(dKRb6&fhGA1Vj zry?Hv%d28HXGbJ1*zcH7qBGj8D4%s0Rm_*%?RJ9l`nyon<%ixwDCqD6TT{LocaNBE zegqrsUrxXKNa4YY=Wfj8gym;xJ-;%qfGI$6$nPioM&IRh>5u1~T~XnX(U6sub@Msv z;xp8iJgX@WWBs;!cr_VqI2AXs9ZN~PInrrnsIbS^s(w-q*YPD$FcR^xi zS?l=weHLl&aO4?mg~)H;aFkLtEgJyz5INV&eOC`M(!H`iTi`d6H$1@;Xh$ zE+^aflPw-^jEv@bdgT3##DDp*`x3}0bOJhp=a9*84AhVFXlbh0x8Q}PN6 zSINGZQ@?cRj!tUv!MHsY95vmBhubcRR|z^Ed?(P1XUfVhzAHd3LPJBLxQIS^LN51j ziWR5xur$RsWsK2HILfTbt2GteLKgOPSB)CTwg(*GIEi zPdvE2k74@hRE;1KRn$^uX6DC_AK@#=pv=0YqMIy{vP*U2hS)XqBRr8y6S~d)E(r@t z-+sZSqrSS4**~BF-RQ+$yRH0Bz9np%oo)qlMxBU~|3k>@!otmY5!%HXpEOwDleM+^ z0r!jvi{`@NaNc&>jRBCqT{q5glKnf=l;BnC&TMP*RUwG#z?WImJ`x%ZAYd>k7!xfX z2V@O!j`77qPzR$6>?svAy<(;Zb{@=~%AV4dzPXk;eTpnDfMPKAM&|>94Uke&;-zdH z(M=yb@mu>Cdb~Z|(7z1=oeyAl1n}()B5KSzaJUG(+f*0%{(bGhEPxZmM;r&MbKA)@$RA7uIM zeMPU>rVk%x88~RWxoE>9DcU6I?@}!G_xA^_pNrI>OcTzSCRHI$5>5z9fnNDzptOFl z#lk7724334P`{!jyc$=&EJX=}!|@RaZ0%$7rl}a#^q0Wi0V$1(@jpa@2Fbbs-{TrM zd2d_``>2NP@ppk$->q)nEr0ZfjmxM;!g(IcF>?KxA`gzeBU`O47?IjZ55D60avjRr z&lyM7c%6=b=$a{b`s;x6i64k;P;n#wd*xf{mW*~BJtyzZSBL4fOU2SY(hO6uZd zR}3Iy^W4=A{J@z~vkSoQA0CR3t-K5I$xKcCG-{pE6_oL&wY3#a`GBu_py}7!O@ou- z4npvp+ni6*i&A&q?bGAuS?9k!U)Jy_M?QidEOv7sHg|RbtNr+mp!ezCw{PE|NHH)n zN_jqB+ufam4+-Di3aVJvX;@a{L6q<~Mp+osFTy6!oUl1t_I^Kq226Yh*nvsS$7fIF zu^WuCU<$qi2q~18^XPN`28GK{Q9mPtG$R5^HJ~=P!?hu(K)~Vd=_FS7ETqyq6TAb?$?Goh0tYAkW=W6-b<=CsHKu0zq=e0r3 z%MMQGheg64*vF=Bqu;qiK*M`-TN1H!+vAt>Uju=MnVOoy*Nh*vbPArHo*fpZBL&I` zNC8gMH2flP!YqxAvxS$A0V09GQD~(oo#a9z^$jeY3|?C+>S$*CT#dNF}ZIPUO22RZ=!Hv)gq>vyOazAST0y_FScK3%R?jM3aM1 zU_<}_rWR~^_=p}?eYn+rp4*4`TU@bHzVAaReQ0kL^IxxKxJ5-pmH%_vxExsmFx$oH z>tt`wv2~f?gM8NsuE;{`Y=eW5KuzdP$Vm74NMUz(H{Qc1?jZWYZ8EU`(_iN6fW-Cq z@d033HuBo$y^HFHChh;nrUB&+9El2 zr(ai*&69n$LhfypqTaE6Td*GN1y}#P>7S`G1(lsh=EvWCS8vppRRSPXFD}=vqDEl2fWQzx7~*@qE_H%9(Ub; zFWAdaBznxa;RDXYu9uw+Iy}&>;cq3AV=Q$Tre@hMaM7fI8q3Ol2Mqb<_3PJf-kfzW zq|V*_q>TR!35v`v9cnIk8&tmc{HJG(ReggrU&a}VzCQm z$>ysEhlFtG)?#?Rlg$PBxd+Tzs7z=vR+XK$vxM!q%kX(oB(`Q-g{i3*CM~ie!@~n2 zT!b>7GAs!ffT+GZ)9eNpgWvv7QCZpOx^qwXv;BRm@$vCM2T4uMM179AFFsyRpZvlJhg zW50jncN{WBEm09Z*oq`Xxz+rx4OCXBybiN19!uYG1HT%}c-UB4&X@el?mSr^VdUVj zAI?+oIX_fmA{v}Bm{{@N*w`r7|C}?viE6z?-px#eRi{fqPTu0YJ`5AXDl55C3z%aA ze%s?S%SNn$m~YtRybjlSs?ASKd4%ENisttK4HjNf9IXmNipQ_sCuqImB9)CI|Ln9R zOIO#REvgXvvdVqD+`xzA*ql_h=WZcOg7r+3%f<2bh;`kiLc996!QaKtVSc1Q;>d%e zvB(|}MWfGK;Ng8JDq`B4eP&t5!pS*TZH_Ib)LgLi*0NM&nK61^frM>z1~bmX?{l zWxY=+(zETw#bLPD7MUKfEy|T?+VGGyl5}#(1`aI`l@3l!oPzU#y&mUi~WY?(dU{`98r2*fOfBQ(Q@o2Vd;5-}R-a&v|2G zV=~jkm=W<4vKvo+{sz#7U>#QH&YK8J#>{LP%xU8m@xLEewKOzJ^*?j-@DS0+r1^YY$+&+^ll?IuAb^-d zUt3@QR~Guz-@n{Axue#2 z(C8oB(Vt|u3_Jnr12-n$bxF=1->lQ!K3pG>;O85PE{mrmAt@ZSR$ehw+K$V}fOJfo zWAgD(_Tz;Nx7mdT@tW)(2S?*Z4o*9Zs~BAr6pwRr zadwuFl=P;ruYWCWcz75@i>K)VcMD`kp5)-rkBM>HytVA_Qryp?0kas62#U8wG9`m2^087@Lh0;pzNe)`q z2?`2UwCsnUS+no?e2k!;A!=IzqIta>_bfa-eBO|6eY;F`3Nru$8~ZWYdw^-Xu->*_ z@n3|Oy`AzeSO-8v8iuS}b_|6Qicyg{TkGJfGz=l6yv$l3c@z~!cus0$F)Z%t--?3>kOg2ViPI=#XFR;A4tqw`YE^)Pk#QBOB;wc2^~>xN zJjL>#i!ts8>IZ45fhPw~U0wM!ihGZa9lo5Qbr_cZGG*{FEY<*4NOCAUI3jMkN)qwv zb%u(Tj?R5TLMbfGDSwb16XWB>FOq8scqsmy0FbcbqfblNzq-0Q*`BuO!sxdc%8_B{ z(ru^*3`aslr0wvg(bU}B98R9z+d-Mx&APc{{Pg*#IX*E_(thUEJ-*GyZR*9|D=8NH z2M1?km#?P7=R%6`EO}C!nwsD$-k}uLFV@igtW`spq|FvGS=`h#b^aI`E~v6@KVPR}PopxY_YzbCz!*>qaA|iUvk2a59z2)oGnl~}#aboJ+}FVtf~0RT2s zck%co*9pZ*$?%;`WpnetfG6%@q2l;bW8+BRgOv+UHaiEBi;D~R`*%uB#u$mB_l!e) z05qF?8n)!o40;d63eYWfdgVI?W{;oo%!y;Zr%ymxfhXI|&5hCBM(mv%{;nAz!@X=P zU4}RrIXMLW&K?bXw#u|UIy!1IR{HPYfY#k6vwdrf0kGBY5ITi`)Ya|nR;Q)!KYuFf zQO;o<9UX~X@0mV*ic@;1YNuyJJee0y!ya>k*7fpV5GEn_e})=S*)mzUmRVq$L? zFf%zKG{-x=#gz>Z&}jwEsC zdRCOMU6B}hSAF@CxzrdEg0?B#l(@n#aJ{lI3WMIR8x!ciFXtan)`R#Bt|HNJY}xZE zAYUBcw14coaxy$krmFNlv;;$;90+gyjBeEkK)x^bGeWE{WE`-3U?=9c8DrD_799Ej z>VIKrDGu?;@gh7+s0!*BfEAud7derm$Au-_8Q}W$LG1F~aVN^_(>;^&)>cfelF}X7=fegZkC}k-V_{ULJHf3s%oY4y1Ijd!=@n; zvQgv_Mif+Z2(&#+=S3L~&v32M-U2kbxoMM+dnShMF&iBn4aCa#V!NTT+uqlIuYR$) zIMB~ZQnCZURT9AN4G0zsQJ)X7_(N`PK(+M3k9_mD+4*ff^CwXFzlSTe(IEO@qfV z$oHlFgd;)G$bA8I(7mh%aD<*|JuMB5al&#b%Y(hb!$W5t2}~=(_L-S0kj@F!{o;gE zf1|s4@{%FSMyXCs`|0SS*DBmIxZE~2Hp)B0rT*=j`7_hgUH}*S#{OKou7yF;(F87L zT#1Le`f!`R+QTR+ z_=&4fhW+%h-3|D0zE?+DZQ_r z3m!jn_{G0c9DZHqpWD_>N zOu&7?`a_ABq2@Y?rp&RiiuwKh{Rs4L#!94pA2HK!MSz2r_O^P?YjVatwprKExsjJI zU((Rho?TvqyDtB0451>I-;*}`X(z}1i*wtAqT zptyShrJ9)@8L9ERx#qjKGe0jrmE0GoGdHypDG;vGFd(+pf*4zfRDA84fGz z=6t-Zy1F_#oh3$o>dG!wx$q%A%16zTldZ{42r$&y_;`EIOO@+AR2#c%$wQVqbP53Z z=A`YWth_vgdJgluVuE5FTt-C76AFgZv~WcnWZL6=J9ua{(TwZqD1=6Qt9{XnSH_|jFeNp%3 zwWG4%rKhJuVDM`rVL6_B{;c?L-RTwH^(qDUR$5)%H=$e01yOjyL(3ULe{@yeVD zzV~?FT%B|zKq_ONs~48%KpKBV=o>tNvhP0&iZB`Y7b9?qWRC8vwZgrCcre++Nl&~` zG5?f(SiGPML8KWiP=X`Lts}Y?xwo1o#uB7bh@*wl{tfmn0lu_#XJw{zWM04?D16)H zeuVg~5EIe_#TSN#vtI~@k{_9(;gaQzZ=PLkaHh$!VZW)6rvu3w{ZmV{H9{L|YSMK# zrxI@6+kBLao^YXU#fTArI7dD;Hn!Td`;Na57kQB{aE}9$empXso?&xTR8){qgp2(6 zUWA!XLR@qC?_YnE+u30_zKuQ?e01BD#mnEmN#~hM2vV7)Lp~o|Nl=8|o*LFOhsG%f zPouE;!%c_mcQ(@ocpz_FU7>%$piUmPl)jY5t3cd&lslJLiLesQ^`0o^x%B)+AZR0_ zn}Y$dH{>UvI|&L1OfQvru>gDqV@uPHJ_Vix^t0FeAS+id2DnM-v3yra5hg?cT4LkiAhyJKMJmfi z+}zlhm!FSW9QaAi<#0{S%zkBVTs;GL5+0r(V1fndXF2QowVIu6Br?xo)%rjUhNh2u zFg|86=w1mfi$C2D*cA&33zhQK>#w3HZFq4y>FDV2QB1fzK!iM$!{rgwE zj|RmdP`ChP_MJ;6A zp5v%w1K}r_$0cI7C+~%cQa6;QNIy(RJ0&}XSm-$bNPE5AFh@p4mK|2A^>EfyM`sdZ zE@F4f z3ezyce`hM^yZx*tWR()rhtKRvRGAPc`_II!sKf4O;f?=#cf*9lm_BM@04Yd&|Zv%e@M7%Z75Zx zjZ=Z7pB41pC))a*rbk^ZYTR+q_WcFg*1*MERzpTnA0M?b!v$3i>v|bppt=`z`8(M+5?=BF^b+l#5icT zd#fMt*#deK6K9~p=m_r)9kPs9%m-Z3P3Z>`E@N$S`$T{JTVgRfLUFbeJ%`qWm>3D@ zKdM)1pk`m2-j?@T-|#m64WIH)s~5>PWLrg6@aE(1rke4rt5K*-<8>i6&>a!*@CDY&vz@9@^fTot>>r$7G9KY6MjV#Iq8O zPp_^}Sy))AYijhags`v?(c`qbni{sWjEsz|EY235q664~Q0#gir1U0nc5YFtQad?1 zu7Ps`o7=Qo%R%Au@B)1UgXik%kZqhNRBta}lEBZ;&j-Ky{ZI(G6a~sg===fwVBu3b z0{?dt!AGI?fv<02`4?F2acuQ?>IB+ih5P#sv%Po$La`1jRN+=n-q+z(L19&*Aal+t zp>Mx2<~qSXY4Cb7w0!RvnuvU^j>aX+LxWQa)>~RyAaxI3!PF&LhDw9S!K;*~vQs-v zIG*m{GQ4`lAhy6)Z35R8-Tad&sq8!X<+U{dFzZ00n=mB`la|d@D;&83{|3~vnqp6i zuTV{AeNJYe)&stCo>Im|3zVi2gva`Op_n2J>2yA+f@;uL_muF@7r6q=ZTD1 zu^CIh!t;wrVgr}?8rpJ@K@?4nQ1;dfv(QjckAUMYsh`Y{yj?WV*Y^eh5FL%*YUm)c zfZJOQRTPo4e?Ug2dslkIFq%$BO|8nLGh}UT4g3j?VktS}a_YC8b}lYQfaq17oh4i?ZA4&Ye3@ zMByuFn1+Uiz+;Y%k9VdU5NM+D50k1b7)aMQ!?G^1C9%uXJ7@zq+i-Vmb;>E|w zS>f`6Afk(xmlxixlCm;{HHl;%$)b6n!lrKkNnE%-9oTtHEL>b%)YSf_g0b~$x;W46 zeWb3yW7S}}*X@LyP!4()VwtZE1wP`Yh!2HQOLC|nJ%aStRliR~?QMurz?LqrfhRwK(+PQbur~$Rc_WF3 z7$WB2z5KRzVdZfRpdt;4k*7nXNPI5^jI72jCndXMk!~R&DM(9b@Hk?ffRNl?%JF2Q z4)g<<9D>4`^P+I%1?cleZ4X@^PO;KzZ5osQm#>AO6|}pISn=PB=weCO0EGL+d7Yr> zDL$d4RfdgIZuY&m3*sfC0Q&dg|J}jD3LsZm;YZ#M$SHz@O75|00@A@(e*NJ0=Yw(T zSh^$6E0Cl49A+=!stF4T)q8n2D+?ury&q2bNjhZ;4m`9Fg%Exy^~flgmmIE=WPJB;e<1jMt?V6_B-muUTT3afZ`00=@{@g9j4Z66HhW z-MoB!0gH?+F#ARJJt?)B5FBX@FJQk^I2c5j!16skn-oGST~W^1p?~SleQtyyL2w`I z@;qNDiIleUwJixlfz1Csju?5fVmmbpsf%f*Dx`1}>P__?JzWN!w8KWj=%vi3Jo<;_apV0!5<~pDC@38s+ ziUp7?2w4$u3B#m2V2r$SAFNf_Fx)nNGruFMi;r&8@qo}3td?KU4>32_HnK9d)cqk% z`jPgDzIYkdK6;ULHeVqAds`l4UfEc>udK6{JoW&wpcYAm;8b1DfR(WbGW(hqwIV9P z^?a#mfZGPv>Nl_)5nNhS)E9PTnwXwTJ-QFO@X}lm-wwJpjd5&rrl)&?q20 z4H4(H=)K!i(L&V35v?sP!*H;rx;pUkcAJ8xrgO-r0$&0uw+#3tJM8HSx{@c`C6KZ? zvBN>BrSFl1>M8YHV2?J;dSX}a+(nU4iw7_U_9fWux|iio86K13KngjX&yFni{g6)c z9;-rZbo<;gsuQ^zBvJO+L`6kmi9-)S17uM$!;!CpezVt7y(UK&ygQh{);k8f#OUZ< zWvot+G@s%JWn}^7Spp|!acPO3AWVTw<2o`ZO*%&DH#{_;L2&9f8kbw?v3#nng|ik- z0lL70=57A52s|Wc5be+-6xdDq6SdR`OdFv;L%vq}`P)ay`whwx!x(#pPf zgfhO*W{C@VJIxm_5|sJ=f)@#!iR;Pkd`X2WWtDb%a&n{9NWuR8>o^G$X71pnM(}fn zXi^!3mc-GO-j=>lQ~L+%RC{B+`h8!#<j9Ad;zcstzX5u80LEFv;9Fi=5zZ1A68YiPw!ibJF@MC19pNDUx6 zErulcp=OUJqf9Lc-6z%(|2 z`VadV-4fIVf-vA;jE^73S;n3X9<3z6R_7?2q|fmI>IJhpOM;~Ab6x2yF~4hA*5C-F zzl}5CCBuDv^dT$^(G%MJ{{GV9A~3hpm4jQi(VhT@1k?hG00Q31$OM<^y@23NG@SS*kLmF$WNGVIzV5cG#sM=@cUmqAm!{dZ10|>guYgQRlr-?&!hLixjeFukKAd(=0qZ>R21O&)LL&F)I`bgQ^ ztgY=N=tpcUEay-eAU^UnS%o*Ir-MY!Y#RYSYdzRwYrK;QkRBWmj}7i zh6etY=TS@GV!0ix^ntP#9fV96ERVA4lR91O@k=EgSErjoWoujqlK2B%K-x5*zVmOqb!hy<^@L*@hD1Wu6-|my|^Ac2aB>qx}mVvbPDu^;8HJ1SGQQ);`*Xa}(@aKv=XI-y>tS7pdg}Dj%va5`&4~uV>#f>#`(3 zTki4t$SOhsu6dFDSHxB_!Gw;|RCB%tF&4Ku)42N9-(o^g#f->cKC+;N!E3XMnO z5J|)sc!F+!7x@3s&?DG~0Hi<;Fzh9;g7nWv(URpDda3~%y`R=^7|bbHCSU-R#s5$F>nwslmW5; zs`egOVP)_ycLyf?4ky%LEX2rq&?-7TMowNHLMaJE!`)C73HKWTf&r{#73`A?LdlM5 zM7~e-bUqMToP{8<36~bsNC42%&xNOTsot}JAGo*Y2xqr1+$U!ahlG^6xjDTpOUGJC zS=mgT4M|YVB+&=cept6q!f)Tcz0lX8plci{>7yX8^-NwK0}G1=1Nr4rnZ82A+SHUT ztHN#?Jq-7JdpAvJKV#?PN6X65`zaS$t_{KXVpS6Dscr8-izX0?)js9M$V>-QcJ z4H?ir0FboqM|t(Z_9qGJGAnsEJjUW&Z;|3i zT)O1Vd_ZVDq=Hc;<>i_D4w+9G0!!2K%%gKT6=GpAfaA9)aEGUMYHBK!QRIDn_B8jB zDiIt2?F$xP)Y5!3SETG?lMa-L0h9_`4cs@Xg0P?;80OiME+rDoM;T$a#bd9+BM7d!DUj;P$?X;tkq@j?6l;9O#b)pvSpfH z5N`&=+|=Bh*23E%NttV<%SJ4fkjSQk9Q7bEm7bUPg}ZQeQjscCrsoDMah2jxYe~*ZdkAMaFsFBJ>X^@DS2=H z2HuCnrcT8}79*ONZ{6LnlHbkcsgn8ObYKT(J?7??l#+r>eu8Iewki{(nJldi>qu0k zlEsZ7+~5jPKV+l(3b8ODB)at7TqRf6eOy`J8Y#kCfQ;<;^#z1kWYA{yjcgn9k{NxE!Zg;y57ss28kj| z^Lr19FP9g-rUj1ktxF^m&-pr&V&%aK7_Y*7v#dp3VGEaM&md0lQ|tVAib*mgJqcTw z*f+1xJrUzx&H~4mCm{$0*{J(0Fa02NdCYbPh22K$V5zR)-CLUA9UjeOaA5JQKEC-Nk?+(RrNk5}YrLiLzX zEnowY7(;I4PbdhT1Rh)r^bSU4 zCCIFkKXJ#W6O=!{M?r!6Xp||csjJQmYA?A%UzUa=$NVruB!3hX!XO`3 z`)%XseB98hPccy26Dzf#P&v)@@6UUK-E0X)nwn_ky&b_Sj;)VK-WpRul;>?|TeM;T z8YvM`Eoe;{Di*$?_4MFJ0IP<@8}_$o@_|2#lA4CTzCM&kPaEXjQR3``m4PhI%Pw3B zAtDtrLT!O=r{LgVs2<*hYp?wMZxcUCEtvW9Cy~?0P*)cQLHWZ8EPm-BK%O0?y{cJ$8pQw~@pLshmF#+?VBx|(SU2NlUJ-fp1 zc|Rygh+U`vjfi2TE$S7xGb_k%6Zo7R9Zy%%9H!K)AT|YH3%1nx)Hqfoc*uy;p^*7P zkO#C@E?AB zvT8)OcU;N{rb~x8P!3ih0eu0!pby*y-!pBiZexKcFZ`Fr#xl1?dDz%~_4lh7Z>31n zfJ)R4Q7l#2ET~QQh>5F}i^Hb0t5Q=_A>_J_>-oJ0)F42=9UYR@%>5}gKc>+MQliLs z!{>`Vtn0{Ln`3I^fx4%`0t~}bQZhijxOV+BqSU>6hq3qeo_ht*G}wxWDa-Ed^|rYG zqY*m(@$=|16#n^n2=0UPlU;|utZSpHME8h@4&mBFJ>XZF*C{p*{_*Qqkhcm(mG-ad zQqYRscF51;5{{`A2=qnh2^6%oC&1WBOHYT>EUoU&EJloM9&vDRK&Ivt3^h_xQVcf| z?VH8LMfg;YzPl;~sf;+ke7Oi&llk>^D;W-wFlodr9c~MZTejA)h?El9FyZvASeIBT z3`WL6m_Q>opTJ(}U8bpLWaKbBMcyrebc}eI1R`?cC3%#gxrPsjSpmC#nDqYgFGjgY zGE}Gem9Uu8x5E6r)u7zM5__qmSvcH3Q(CeXX2+nQ^vgq@ke!&P^D4m%lzuj3c?+2+uc@y8)djT*+;>@dAcb zcqTWzeS9DY6ZCA1Y8FyR()sE!bSY_RULdF{O>*jKm4FbTp`}$rjxaPRgwZBvf-33& zXYx*J071rYU~7p5U#*RQvH><9360F>6c%lz}o-)6+1 z;p20K6ak34W+N*W5k^6|9}svQ#!67X2-q`iHOz{MF)^G6=>+5+)SDv z;+8;8qIN@%#yAC&3ZW*z4*{>*U2GDF>hxo)7-f#1P5vs2kcYDAmg$oJhTRy@1?dW(6XSEplTEI**i4k1QSx=F7VB0xRY#?vuEHr3yvKxqi$=zz#jlg3Z_YK8j$lG&}1OG3yuU> z`s&@-Nv`u4q#)`6T??d}dE z!Ln^XA3mE2G@{Rr?0CGa09?-S^XtEe@fx63a?5^i!tnokS=P9VDvY8K3(FMn5HJU1 zR85T7Cw|vY%mQH7{|@uAhK!~~oi?4DAV(C<0-1A2Cfw6_9T6O8FJ?7NM<~qS?F84F z{?{L$viQfAgV~~AeMob&^EAEl)U6;cAzpT`2r?f`OmEqqQ*;AL3^IBey9`4|IQ{(m z;sT?BydMb&XuG#?xDSF2n34o(Z66@ar=#a3a2BwmSvv?F=(uEbE%nkPYL3wYCje8) zL1q_A@QK)hhRgc;u>#@Lz~i8WKeQPt=)|n7M({xL@o6^dQFL;FbCW9@32e2Ch!+Av zG%R0`6s=#Z6b+SGti!^4syN{+2tHFdA@%`b+eoxkZ1Cl?UnR0jm2*zAby}h60F_Bo7@{r6~z=OVCg|`%AG)utvfb>ZpOtHw}pNOs@q)z(bg??Di zyXj(JFazod^_C@oy)xm2d>taJVBNC*U%y&l{Yl`%riXuRpqc_lsSCdRhH?7sT-S7# zy?K?7x!Z}#AHzsUxM$#wU6@`Il6tLprc$H2aa+KFoY&DtLUQtiAsN)!K+GUIg(gAO znTY~hX6fe>Nznypcx)g;km|w7&iuhf7tEs7Z^-Ur5R?qp}TvdMTgo=@n zmWJJ}h|Qd}gix#GMF(zL3JMA;E2Fu3@$Id?5Jz-@bEDsANqsrB#r^7sfT9}6a0Hp# zI0Ofyv?T=+A9JC->6*ZTC7V58N7EIr!9oEheJZbw{cOu*4(5Fo%Vw}e zrtBCnkjeYOMFUOqEbV4YL0+De*II+Men`CWR+tG)`$1gE%mwm*%|WYQWw$A^x1pmE z63o|=#(l^X+;nu`nqCf|zi0OS;#>4ME|HM=vFvwauEyqOY(H>~e({Tl8-21Vf`oo* zw50LxK^_BbZSb!of73@>JR;2we|8++^FCOsT9U3=2Q9{ z4negA)MkC~icJOvdx7_}GuJj(09icue;^@1w*%JGqnK*yV!oAU$5fRBQz z!|4wQ%P3QgI#KzUsZR|o>A0FDR?9;+9Q1c!!l8h!aQIq3mn zq^e(Pfp85OTQ3<-2((Ck@9qxlV7P21?+xo(RLhn1N29rnfE5g7^Z4+Pjh)?2E}vow zx8E8r@70KvjN=T|P)NI%#HYK^W4pE0KBZI}pG|aCIdkFJ(wQvgQ^V4F@)|D#->Onr6#+zc)U2;REC20gNn4G>9GV8PJ6&zOw*ReyB! zMW=DodsqR5Oh3ULeDuf(vc8f*?6tC>3OYi2VD@b7VvFUbhNdQBj3tPLwF-$jRtU=g z+ODA0uuUdCEiEG6m70J+A4g3uXta+|Da)o#*FWH{fZK}~S}(M;rsH*=3J@vJAoR;y zl5_;P51%BP3SyvGWw`T0&)_4|@8aI6`8JSDRm{(^nlh8|H!rBIji0M1t!0!^gM1Ch zD|U0mRR7KGKu&OtrCacx;hQCT8^d&Y7syO7_8fUP=A2+?92sN@ZAZG4iGjpBG8!bJ=K!dHA=q_FzsrA= z&n`O)D8w2k`uglHPyaz%_`T~cfat$|J%#O5$8>1bPHSF5W}v?x(VI$LUZ&Ib;LW^u z1t0_Lmb;`&5EcMw-PswUo&oW=W8K0nV}JhqsjcN%Sm3jh$Q_z+mWSShmsJyEMc3tB zKYpx3R`cZe*gomUWqH_IikkT)<-eI1ZLBz{=#2_f?S*#gYT6=c)0gHeh8GT3Z>J_*y zUWaS&q?HLw1bVRqjef)fSp@LnDP3mfdF6O)qA6US7FfMk7S`cz)N1KbeO6~XU6AUpIqk@r?YV;3~D@-YKY z1rZxNz+dmV6?VH-DT)M$Bs7m76Ry1R?}c;+bkp*65m(_TMOc2;=ZO1Z9A3Fd|22yP z9GkJhLFhpk#8iCs;>9SifO#SUe?vwXI}H%-GZHN3ro(9BZeP?+k11q+6FhA^eM@KIP(MGu`PxBbl)QW~ayl5iZjC#K*D0J)gsK$UbgyNW2S4J5 zh#V^`ZBX3<91*<{eq;tISOieTNJ-tHGcj@V2N#`!>spHiCYPLKdSP1HG-B%Kgy{!! zGc&m4Ik3k=zi7UC%3=G>4Tmxz%2%%U>cxN@`$E2@nSM#b?R)|-NmzJ#W(N8c7T%C- z-8^3>iAG#;ug;wcrYE9XeU z2teow`1|^^Hro!y2u>44ZzU9N&0>v8KaM>^tYBmns!akEPd!I+bcR^K83>OKxEuS! zfATS9KL}o`Q)&d18B#NlFw)b5hFAf;=`-JsAw2`lqcxfC@IM)1`GiO?srjWFgrr+tWSjzQl z`S0z;_eH)nJPwW=X~d!4+BW+AedZbF*%rSu^ChDVOb)d91J3E^BjWVQcw}o^e_y2r zwuI8Oxw)A@LqJZ);TD7*4JZuwCFrp55I%V;s73B=LUWH~M@7lYj)$&T)0o?60mW$7 zqpKL=I|F5OH6HKs?51l3d z^`$>wUy9e1WV>ceo^}j$;l>UQ=nGno_2Y0nF@ij|0iLSiX8Ud1w^zeH82}L>H=#A7 zt*f$6Baz{+zl!Yzlo|5-?=NZN05LE)@9H|WE3I=Ey+uiyU54n%Qf{d6p>Arp^t%cp zeR(qmDs{}quph%}z%K6~nH^^2*KsZ$=^BHJ=hWn6*pkoqz9z2ESPA@D=J1rM#i>%1o#Cd1scp3F)u<}LUGk_?Qkv- zH`rXdv-OBsM~gF$;mAK8J=-D0+!pkZD0uLQXY1an-L)TZ9k>_G9TMDzThV%a`u2^f z{Ry2OKY+p~Xc$4%sd`vSrqcWa)+N+s{vLZ=zQ-~Psl?8HeLw1Xc`sTTi6`hh61Ha0deZeV9) zBgmX-M=keQzIELvA;CyXTk$!zdtp&rS3}kx zKxeX-BkeXV=BH{SavjG795XT60LDaUC!X0?1n8O8q31kB(6Oveor05uxE9BEQAw~7 zN=!xBeda$bf}4#A+YzQXA(~KjKR;G-=pq0%jRnq^U>ESaMN55!3JhlZiVXHXj~llY zBzHPVn}d@DQZnK91$AV|n(j0E?_eb)1~bd$IQ+d?>GjMdJ% zxneiXBXgG5uA*=9Tdtby47&AYVWB;K2PB46=4bV!pVijX3>Q?;2b8Xm4fYED-Ll_* z2PEBHTPaA}{b)CIapvml>sy}J-FUpkvcB7@w@6rb^((7RGKd}Ebfnoggttn&Ub=LU zBAD(=NY#1vKP9&2Lt|r&ZRdL*!%U#j@8hK{9HKC3aL!_v-t3jd5R>QFO`A0e+#uFE|hGG-j32?q~;_EO@ zdQh1GY7=c07Bj332sd$@#b5)N-@A5atvolEB!T)7ZL*+93>|V0#-9e)o%=%3^O~`9 zrR0r9hqC)$Y#!wR8Z0zY1Vw^&Fz2la$LMNndtA6sk-RcKr4GC;J^dFb!yp;@Kbj3) zdGWo_v=~EC6rWa5fF%pQcr_qqb^J7@iXhH{H7cIc+3m-rxFsb$>N3fX_>`0Z)?>;Q znBzi(VWE-#fLT1p&*{!%Zn5gF+9_z%QE!j4vAQHoJ`TEHCmbbbX1*du+b{Yncjvv$kIMN() zwU#D3+A9lN7=nnpgMn)PB_+%Dhr8O^+gbI0mo6BmRFU|EE&ANG!?>DYxy5mkAL;i^ z30#_xPjD}bB*nwBjGh&Kz0gHw3+i}Pl)t}MTr4Uf0bMQN;teW|)bP!TxE-(*!#=Aj zefXZqUdN#3n;UVQGT^`QIrgi#$P?70bJvbC6vit%>ngRhwK;(y0ngvLs1~>uN`*C- z`z~1gP&XWr&%R=R#nisPo9`(;lg7YTjXk#{1ru>gy?Z0XwG}X$Y}sA{7B56Y3^AOq z(;lNS2>y$A2r@4Aojcuk6Kzfw4FIS914MbF2NeuBk|OfO@Y?aiHBOmR zS^0Y?&PRivK#45BuG*?67hnK*VEcABiS|7|{z8d*BJ?Cw=_=y%1`==K>I4(eQI21* ziGq6-rl##rAE(j{$S%yxyivVG&PfNv2)d4ickisUb)0nGUVSOmYsQbQ5nvsmzZ2#R zZj|2z-LA+YmCt#?9Ixsdr?T{z+5Q6&NzYY=iV_`qD)*#b&lZ zu=U@At*5*;(K#EQ!(O-L^j=_l8`7z3T0f ztp4Wr_Fb5NJ6g(S;u7HPgzyqWuLIW?ZJ1jn%j<7XhW74fi7vnnXK2Z1U6Gf^FDhD$ zs(k2@d*c%ZiAFTCkxf%t6sm@7gMTibJQ?D9d~H;BXeMnXCx9Xk=y;-I!lzod#S{(f9Mf5XXDdQ+ zT?~qj3$x8C%QW?i0>*=UP_PrI4hkCJk`^^@H;@+OLVb5ylt`$@Lp7 zH=HjLz*iE~Sz_qWVA0c`JE{=aaCxNC1<^?N?%!vbeW03LpnGQ>Q9T3`|JcoUjr8Zu z1BRDAb-BNLLTY-Ay^K2E8ZxiiTEfLzTBOq9(g>OB7&Kp$)YM?Fg_hC`_woMZQVcqI z^2fd=)n5cZO3MBj?mJXukP0NuTt8Lz4dfd5?~91k_{_79hmrq{JlmwqptL}DH4k*Q zJU4lkYf{o|`tabO-F{OwdF!D)4w6iQOb_}Em^IAIGSbu2Q_0_6eK0OGkl1V+6(5f< zg(Bg-0AGL-3fb%Ka3X6sbr@o2R++s|8Bx7rl8bhZta=y_B{*~f>0O|_HnGN$Op#ul zKKFL4dTCon^W@jBW2#@jUopNH7Z+9UH3LcWS=#@yNIlGFd{;TOP_iQFy^ z&$%s?s>baEfVZjcary_qG+$qE56@mWK_2`Hdm|Q`)WL&1?3y8=luvFni{`tm!!79E z-MfdP4D=W3VSl8;^P4SE&)*Pr+RqXOI+Sz~!zv z?&aeH|G+Vz)=ClCstmy(m|eTp1E3c$5-6lEZ4^gK2{Wsr_(*ph!DM(UL3NDw=jZ_M z_%^aX!T2^v8V9kHqeAADfod%#kK*iY&}oH!4{WNzj%|22NznY%sYb|r;cItAcgU3| ztIbxjpr|O!M(+qkW$R_yV6kkDmpaLnj~)SNMvnxVJFGNm85ybe(4-9B`bNoHULv0` z`Sx@gi>OQ$F@Lb>aQ5-_U0GV9qoZ3|TGG|igV+zclk`qU+mPM<=P%`?DCHEo5DfJ= z4SrRA3gyP=DHll&>^(TjMMXs~%Ku`XwIfaWfhtyC_mS*Q&Imhm)7yr*oK#_J7m1xI zu;CCdFYoT%cTi;wPH&#hlyyl)6AZmQadV}mM=Bgcd5w=B2gR&J*o8ky*k0usT186T z9RP80>SJ32^IYyu*}0W7^G(wGSfUkWGtwrXg!GXI|5-TcOgbN^##S)04A6QmP?A=RO^^Jm# zoj-}jgfY#j%(`v`j4oMOS(-I|;t3GM6ziS|Of)5fpc-WJr2L1?pm{%UT5*7SDjAwm z0aAkxz0Mck?$kW~UUKEyq&&hFtd8^RBuh!dK}FjolB!ZV^#l;m<-wB}SivQ&T*o1D zI$2xLknqx@xD1g3NC@7c=`ngjSV@m{dLERN9Q2tpF*Z)ddLP$m)@^6*zZc|&KYj94n04T{C@>V(POkS| z7)joJl+a_v{ON}B9k3F-lM^z$ln)*}8T>@i&5pH>}YK}wrvy_yPt;9B(R2Y@SHmx`1UTGDh zyi6Gu!27wB@wyw;3LUZnqV!VTj_geh3LI;bz zQ>3${+8Y$11FmmL?3s6SBvbb}+BsR0X`G75QC8=N%4LgCUc;sS(;-{ z4lD9*b!IJfSLXY{VO~@Lw zyR*U2!lGgqZSM;UCfqmCc-w6#?j85bB?LNUe!i~dZP%Q5bDRKI>` z?%_|G)@Uz1s($#X?*dnbc=#`VEC~HY#>P#XBp;1 zKf9fa7v-k1vT}I)P!8A>h|{UL_^VV!;9j#WQ7EF85w6!w+*P@aa~CA>(bT4p#6xVb zGB?=O-d@B*AD57T8A!Md*#GO0(5%=e#eMzw5&nA2^TvhRf?SfeBui({ZB5elM!ADL zB|M8G7E&izvAryl3FDc1(K>OUYRrIFPzSGBi~?t|ICWa>P$y#e(9R>POV!EgjtXDn z-Mh|cxICug6X)$}wlFZres)iM@OEtM5I=vOrase^-2G`zpt$}(9Y#04JyZh2`*^tS zAo_zfxz{3K>qb^o+sA@}g0JlF-(0#<1puCmgoB{>jIbOpq$wf(9vcurFq=+ z@9mwTuU8d!kcK=SSb6JKOOLbmb3b=GFkxm2@jUEy^N(x$HMimpY zO|*3UiG;G!_PeJDLI?yS;T-j=bwlW2cxne2-!r|NoM4?02l3_S^BwMQ3qmkpTz`3B zJp6{o(2VF`8`VF~*5hHkyrI14M9JKQokJ^~8Lm>u}7TRtLaSjmn8+kfL*WoP^wN=32rm~1fyeze58BE<-qEYane19-QnW`=5(Rdy;=yIZk zN)c4awE&Dqa5RH>FC@xcV5OE_EcE#*$Wf1gLLS|1oaS(KwidJvA~x??G^=I(Lvaso zVoZ)&33z=~TkP#8-I^wDSKD~da>hXK={Ft_vwnWz#;G#EzmSu?It)8;_&DKYO)GQ}l}emF z6JZCK0s}PcWkiby)}LDT6(;db-Nrpe0Rd}7oE8WscxRPwzib55!C?hSLbxO)rayYj z&L7FWX?*WBg6Kv^>+meKm8opG-G-Z4gVG4fN{FB+si^v2-)cYpW1DN5lR{cus@FLn zqQtg|>r64U<*JCLhK7GYfNJPD%U#U%;ZV`QM2ua`j3n|xrBtcD5U}%yutub~#DX)Q zmDTI(JB8H~x!VR=VD_&T2F-gWy@)xkTZqbNStijIt1~jaFRSTmDx8!&3 z+I1L>FE)VN>gq3Nyk-}{#KWcxvt7ar*o}^gFU$AygyDJTNPK)>Lo;r%D68W)+4$Z7 zcI~&WiAmBwvO*`zrIypzRzBpnkeP!W|K#4(#7EX0%NY9-fEPTGi1%#CW}lRCcv}Mm3GQr$aQi*FVK|LE~ci^WvM&-q@G*jvXT$(x#@$CA^yKJ+QZd zXIoNVAB0snD=Mg?Ms{K~)5$Dh$}D1V$4{!T32@N<080R19bVLqr~WVe@dXx#ElD?Z zKuTO6JZVFFIE<(N&X>dGo9prOC_@5}O=hI!w`$|w+oieGlGj2^N6|Qlz;Df^N401G z=s4Iz-?zIs-Y9e#X>Z?yOi>JH)>%5X>&hoUyI>9dimB-kv;yeFkI8-dp69Hq7qzw> zO%tIwfkH}LOw83Z#f3dF4i1{Q@4Ri^d${*_*g?Pq{qw`6&&%t$qhTjXy<~kIf3VCxq{)ouh*$$x zEs}xL-lY_eteDM45eGUlNTeXtcMYrspC=drRgX@;v?#X?Nl9VVAF%x)z4Z@hBv5#A|fI!tXD0_^!7kbMnz?iDg6n* z0iCK`>XkrOJiPdujEoG>ruX1yLQAI_Mn(9aB1}h7@x`YlOf)dvCU}D|a>U{STmz`B z#Mv>;32x6fl%S}1=-kI|cBt00=UL@;K^>H@7?@zigS0XE1V4_Sy@?+ON%C3L)(`1( zv$JMU31w%);ouSkNpQ23m6N0EsR}bJ9t2|3+Cn@1jq#UC>%%YL_81y`F?HozvMxW} zbJN#njDyJov zGnjPaMi4@QU>pakw4a?h`=9O$R`}TPC4sq`Bpv_Nxjr&~SU++^xU7gbne$*)#P)WY zLKXbgRs)Aen55wFikFPtbnm;wSU`vQM`dLt5k`ZxMu^}difIA z7kI`|rLZMeju!;MNFmwL!h%cjU0eO5W&!M+m1gqI^DZER-d#Q)XF(gtBS^_?_7~jp zH};)(gL+voC|LadeYo2FRw`K;Txw5ub4>J;2S#HOjP11e!2?;YIH5K*rh8yXqIi+% zQ6?`l7GtOHR3W{J@BleEIZt-aV>GT3*(zawaIagk)4`<5D>TT-0taou)>tRMx#L#S z70k3SV=|FQr|+ZdDD;x}5bQCY16s$m!yA~+wE_NtItz-NaZKUC`z0u&R#x1B*|XFs z0j(5&>l^UMCOD6shOta6yEscJSLWeR_%Ldp&J#(^G9#uya5LslYRV!zcINZ`4%V?&c4on>&V8e`x&&Cj?O5KhxP3-Rz8gFg4 z&D4w?K?oT?bP2Sr{DIGJtJ-L`aLf1JiHrr~vFnpqSEvU8CnKfcajl-meL)(2X=#>^ z+mH^iEq$+c51i}AF&f4w8GA|pk}tDPvLRdeIZtjs0L|F?7@-xy(+5&DrU&KUyB@m2 zxGunaZG~qPNCq;Pn-7q#<Y-D9~L{Q&Vea+ZY=5ercuqG8fnY&sTU2_Vx5Iac)mN0-<#^~++7z0 zk0ci~<@m~HlUNY0%CBC$2*inKN*)*YqSNWqy$M2aa<`|Tiwo(YE>Q3l#{1$(4eIwu^?0Ga&E(RQ#NdQcM zD)&8(4!|N;t$Ow^e9W_1lY!nc?W42GA!g%4W~2bA;3+=QS|vs*s8d)_0JS!(>`mPt zwI=@j@dL!mund|~kwf?%0R;A;INWyAv5N)ASlF(SPj}R}r24)9jzRd4%g8(u?<1AW zWUarkPkTDdMm5kbyZ_XPqPvPVul@iG?GQ(}C+d}A48wqeO0|xz?mG6Z5*u1CdhQyG z!bU5zDSyPBDm&7zy(7{R#`4Hd`);5#?f#0qxH9aNzR`8#d)zmbgL=jedQ2IMF7-C) zv2k+32tg&yp_!Vcb2s)en4Vl2xL{GS8WMKkg=UYmn5IRFzXm^od_2^;QApx=frAJx zwao{+tJBA>8&=~?CF)2#Z?G=&#ZvS324@ws791NmU?d0meN42ybOc$DYX%W9u;SDE zKKMrlRhea}z<>e~xnKl0!Z^{t&uXAbyf$Lr{VZ9^zMmF-W#w~=KS=0b7dLw>_k4Kc#!MXg74kd_T$+y_RpelkV6^Mq+i11n_}KE#X3K&&8PAs(BEJ0)}fqF0Y?rf zFJuRzg5?m5T^Z#D<*_Y!I;ZxNPKgQ{W|~o%pQ_wid&+iyZ!ZS9nbJ;lT@RT##v}H$ z+J1H4IYlB^nZ?iKJCPZ_RxT!*(rNGD5EdHRbh^i86TCU9J&ZzGD`#EBZ%oQpn7zY( zhtYEAxhu=B=bc9j(B%M-08tdXZq-f&^5wGP;xNPKD6pK)oWU&TtSncW-^c4sHK}$S z@B$$G$@k3NSU7|~gB}75IFgqp&FSi{(Dw-{hBhiHO~_ur+}NBx)3Za!<`?8&nz8J( zl@Vs4-}Vly-)E(0N3O_u4CunyysdsI4b@##OD>3w?E*nJ@Yh#;+4w?JS6S-igM#4HoU>Zq(v0UeprDYV3CG3&4m1&K?VE z{xCf)hbIH6Pd}~S&R1zfzETTpxM`O`G(mE~dUBOYjL$oSQ(s^gZoydpcia#*F<3e0 zuhaZwmWp&$)TCs{E$RABWjTwfEFT}~)J6HPq$gaOu8@LfK~8>7^hM+eq20UN-n^;i zu_0xn)JjDD6d>=%N{#~O*v$gL208^o10Y6t8WsKtxxJNxLmqj{c_NR`oOx9(tVXS+ zYG%f9sea*=t>0Qt#P<)+tJl-T|Kb;~&TYP4#Ibsri zVzLNoa^mg#ZSzUqt59S>-Fkmptl}H*Cp4~o>NNbCMmW5EmnK+4?)1t2fsNf3m?{pP zt)%R|4sJJ`2Btugc;R(%_tN8gT+qG4yZ2qWU29f6lkC<{=pbJ=(3A4ZscE)H1#f7gyDO>ahfP+NFkz22>qepQ+9wgTEXMYA~ zA&>WZ`dS+~6+?#18O9B|tu+gV%R?-ztUnOpgKKXv@Rm08%B4wEZnzC(^__|ng+tMC zaY4w&HRSLurY0M&5SEhaLtDH}GsM%t$OLxTV(W29s;_ud@P)}yV%Uys@m+eG(3t0H z$2g75Xh@l`mdk4kCgVehovMd6!#nb%tAU5dBD~M`186<(e7vcWr#B6r$q->QS68-i zcP`Kqu9s0x<}kc+ci)63JOX=S+U&*hZ2wMghznUIY0Cn<&O5P0@g-{g_isp>IHHsH zrpDNq05^i#XB(a7Je&xw+T)?WxpS!Hwm!*3>)6;|z)%E1Lp1!P7b*Sp5&2Ooe+5~6 z0%*ap@NG)PIpI?ixjpC}I_k+PPh2Lqs+I_V{{dVke2{gL)Y%NSR)|IOMUm2L9~9aD z%4CBkR9j!4$YoO>ko9z*@2(sFmMFM}c+ry%TaX`1?sS{+bCLt-|G#Z2fyLoRMgWsGG`JnE!N6+NtaQzh{U6YiXHr3T1C5} zcpfI zfOs52e^4q_{xLhGcYq=u8#^AJReaZRp_Jh%njwiR_4>(ENF2uzCd|>|reHu87C^FU zDZC)4gGx?MBE7oh>b9Q~D2Cvu!S^0XW?3|(Ob355KkFE_IMnqJ;|9V~Xul!#*KJ%} zwICN<-+8NS#d7=Zm!DEiqi(Wk!jHqHIg}C~cG_p&P%FhBu5+i`+gfv570zHrvu=V6(>d+%?{zG&Ni!OJT)Iy$6m z;JV8bsNdn;@UiGH8>6yAP*6QsAi7oKF`f@d_F<%b^Wa7Fp?=t>JB0do^VyPc*?Uj;Ul@${`R!hRH^AvmNU8lHa8o{Fzb?5vk{{>k0$pZKEj&P^St7dH@n;tMN*^f!1w9y_?Fz?d?bM`zkNYR*2Dy%!^G-Pp`ms z<4$2=sKCJ+2Zm{tBt0s_kt|`lcoo1Yb-TEC%c`!P|sS z0%5?27SGei$8B6m&4qB#LrK0|2U&t(Cr~{t4ULTevwNBQZ%9ew;$xP#A@J@h9zE32 z%?9sp@0D%8fE9^&wH8QyX+$Y0lD*&Yyd>p`7Sxd!azF`Bc zbXv}yT<7;RJ$wM&CAwM6EELyOUKH?^N(mwc1?GV7l#KhL4YPB zX9uEH0P(d-8sGDV_yUr)F#T6U0XoWx3w@Z~f{^WZ8bR9_ivAoqS9V8mbF*gN)b}}x z%u6!db#KMkGQGg1$BUieTC`roSN|pAZ`r z1&2wk(O)~!O)j@?wRPBprKR?#1r$8r8x z8FkN(b7rO)>3htt(?2$L8OtBk5G}5YN?FD73Hi=Q4q*>UawYSfLB;m|{d@5{S^1ik zXeppYd_1(uARujBtN%OHX3J?N7k6im)DOa{#-+sZHTfecFXAsyxN8ja(XRuZ=)?OHHLqGsUeRcOBhg&Qk(7_IytZ4FlI zIh!r*+n@gIKZ?nGZndAGQ|?QsUJ0^M`?F`2ZFM1^-Xh8#$|CTZ{VWFJIi&1i2u|2A z_1#a&pWUS_oSdiW4fA}EkKaciCZDiR?h%QJiI2Yooj3TveBL#z9)5nCM4%K>8$ia8ctg|{tbc7a@@ekL$sKCZ z<3LOwKN?XZWY#Tk3|=TD!m?$ljnm13Bjo=&@QgW*->%gbA>SWqijBt>fS zCxwH>(~{MDwn;SeL)U!Bw#cUdt6%}hor=PcabL-w&f_*fxVJL}n zwG0e+!NCs0g|AC4>|vixH^i^|_ErP^;bCVN@@Qp#$;Qxn#l3zxUkL7mj*fy$2d2{< z7{f2AC69tdjJ|G2U(Z&6iEY#&eojQ>Gw4?sQ#aPtDbbaO2d%1obu#99cXgMR zw$XJcX5j6C_K*A{ecx;O5`T#bGuJa`4nSardH$;mDe|EGYjWEyi`|T=D`#l;10vc) z4~Ap>o_F%x3Dn9}XW*Y#B@6EM*}cI?;k^0u9Knf4O_Q;OSr`}b3*qNcID7AO-b zr~~aI*z3^um-~-gFU}i`YO_ZzfjtFwsDvLEhLChuL!W-AdcTEE-+Uv_wm2BI8t$)k zxvi?LEpGwstXf%d)lX49OQ@OPnX!iJb{NW@*&m7R)EfF~DkD6XU8aLEIK}G}x$)V<(gw80d>%HXQ=i zMeYedV4}=SBL&od!~uu1tGgCj<4Q%hH0iejh0l;<+w zD%d+*z?~6&=Y!Cv#1ta7gnRuM6J`<<(vp_1PR9)z&fP8_ZX5tlc3gj_gK(cD3UufQ0 zy}x;BdKmLdA8eewX}qd=@n;S+1yVnkP6YUV0~B9kQo7jGjFpDmoq7}N)rXFQ_Wnu|X8br|Wg)YsRQT-JK%LOu>N z0cebHnt)ygi-nng*0?7`gM^HJW5xmmldvJg6dpU;X^sf?zPO}{{fTu0xtc#g4u;17 zZ0~L+CBcwj>tl;85gZ|xFD-t_J#5JKX>N{-hbJd3O%8q3bwz`+H`NY;p%`0ZvVPe0 zZ8^>dB9swg+Pi-&`=nG(LZy%W8ss7zD;T4Nyyv?sGv_Xz7AMg(*T9TJIX0Fs%f^Nb zOeO(%B4@moWh*mEXoI3KDN*)VsO_Zi!Vs3~qt0q>CjFE*Z_0a+lPu{F-amdHG8(^u zDkPUm{=9vCJusvPQvrcNbldjj6Na!g8)49KcP^1kUgEGyKCppL$*y7wM`-nYFVsACLWDXMGmLqN}h z|Js)-`h$f(ZVT^KP#A_i3S4(bAgl*o4UvQXfLnCNl`?7y3I-6%u}VGqS^LIR4{&sc zHIJ*g?8(yWQ&m-k@(Ea6dCPt!kz9%rXfNB^b_)t>6c1W#CsPR-=-P9LE1S0V+~A#V02?6G+c1+8M{gK-WBcaQcn-7?vW(A`ax?SMSk zcI=o(^SNUmcRR8=iw1mJCl9EkQx$~)oIa$Wum;n4us0EX84LqVj|LtZ8k(qtgqxCf zYI=Hlpo=Ei#t3$;ggW8G#ENS;J4jd}62B2;JsyyEjYukbuWf)QZ~A?>IQI1=fnfB*c(m{Qb=B1A-6C}kPOwiwQ# zi`3E6`|0tEpb!>Zhlr(RFX5Zat37*ZO?pdya7o zI!SPmK>H2tjRF>U>0w`?P^!M;Zg^_S~o7FUpm^>nr8fdcR zY99%d0e@P5)7NJd5?-2cs?%95*3;EZ?)rp9lQ;e7oewj2yOE%iBo~8L7-CoD&*P2Y z`T(#~hRryp3)i?6@&?b^U4L9_k6j0^ha<-@C@2V*r;T4qJWnCPfwM~K?$`IeW4|pC z(3r=czux$U$X~|62rIrf=mSTjIbq((04sc`M*&OVnUO!s6jsqPuIUo_dF;)b1Io%9 zz}jtjDrNg58Po?V5eOjbYzU|4*qzJe<=`_AU;>w{GwwN_hy?8l6Hew!xLy+R4I$>EF{b9)5H$ z6>2}}z2=|ybo^Gat+2IVId2!@(9oTFaH3~vre>Ip2ec0^E=8c;L~R0V2|Ryb;NlS! z$a)u`C&$DW3UwJOb_#0hYaCMiP=m_KzMh);@l|X<sJmo0_uEofQ1W zO!2n=$giunK&4`3VL>%Lfbkss=CcaEU`efhd6wRBe$2!9>6=%|JAt8etMPld2y{HZNQFtfeOusXdm_K=9fQ~eDxICbO z0!b8K=IiWmG%&NQ-=!_fr4JdGL6Ly?Eqh8!R{o^BSTwW3C;8Nr+b@83K;)@Fq$EQX z);u@X_b}0Kk2CcBJNB|({?Ijx;FF39L=<4w zh?VtedKx13^j!x>ffWMyzjB2HLzy#YsJYnI&qYe`1<_uynE}@t9+nt!0W^kVofYB~ zP*|WSxnv~B5)c?jMNRqZ=NyhSPApk=cB%@N+|uf<(%fcSVKFh7$E)qsxNZ1)a1d8! z*t>fPq#=$7)w3j&PFY_@3;P z6fgWICWg*C1cj-W7hGL2oAdMWIU&qO1ch;qOtbQ+6%S%scHiVaY9uHztr|S{KjnQ2 z)t3<<+y$&kfCpOlMRd4_~G3x)?EQk@gN1YuAeHD&wXoFKT%FxyVG18-7)W7Nqbh9SYX9~FH`{`#u!0!jmP{p)l{z>$zyTiYH0V=;P=AArk7os!uJQIb(z=oGDcB842pU2FgQ53o=|!(Kzq8p` zxb92wVE*M_+M2BVT@Uun2sN>X_c<4cj06Qgx00HV@~JSlYuPvz6rUg^E%s}(eWNO$ z{;pPjQr9O_u6mw{ry`f%`o=D}?(VKb=(7I(Epwt4-4?Eji?)sm?4dhfb_tFc2D3Hn zb1Y?kJ2G7k(5+GXMOHuKZhEotskZOGdc-gB9ExqOzU&j5V7qZ^!ahor?UUmu~xf*1`YuM!AS(d zFtk=ak<9caDJZ{vthT_Nu!QC6dpmI;X?bTC2WN!SYFNUtJ@w-&FKGr8GJdM0>>i!6 z=gXN>%`+c%RN)^2w;PbW6u0_g1_t z9p0O&d)v^=Px{L&gI93HG0Ru24AbPR!-bvit^76bf3UfJAT2CTHzf&KHW~bd0Rei7dFxu_m4|~C=UD1Tz}4?cVNoBsM0cc>~ztar=yjlTXVPcn+8=||GXo|K6rx0`>AaD zqtT<~U!6&Yt80(_QKGi|ZRz**7KfhS8>~)g>H-GBw|7n=hT5_?`4Mx0Jj@?y7?%Pk4!Xem28y_#Nb0o_U6*l!LXY zXgd<-A1od)qWUXzExH;1_Mp-Mp-MOd@Mi|ta5p!eAS@C>cDW@~XZ-K~1XC{>DE;qy zh;Mz*M*LjHZ`%6ig#Y|x*qUWXs_*~zLinay5&1%%|GqBqpN#W#V=%z{-)nL(6lRD1 z|9^sT$ozlbP%H9(>vdRpDuNg=UYdqeJr5sr-omkP^7Y*S4gm*a^@PdV`_deGzYv+1 zZ-<_tu1fm9>oQ~I>a)GQ*=##5UOQMIJ;a{-j(Rg#-aYsr=RS7(-$4 z6{ENas&>!HC?sNe)?ls_@!#d?LS5ef_kd>En*lccSm%@;^E4+Sv(8VaB#Y9-JIzT4 z`8~$PgD)H(mX@+n4aAcW*7+Plkic4QvHS0`*0W^x@u3`{7JTHptJW@c6^<5fA2fnb z00gFQ2F(pd0jGW?t?6;G^4k`X$#r6A&u^V#mc1F*&WRt#1EBf?D}& zuq%RI*T&pjhxr-MYD8I_NESp0Od?A!=CN)%I-Q7S_WANw3P_e@+@nW7fZg7NvHj4& z(=#eYZ1UzH$$)v8Us{?_=#u92k6RSJ`u6LG(9(g=aD#-yD%I24Cr`jf_jXH_v^Ic6 zNuFkG)%o`^he(}H2&D|ZdGjN(+)4uJww&kFGOibXT}NYEWRNbUy|^TkZUFU zV{u2}wLxS;BWiieurNB2xKGf=q4OCX9rg3km-_E5cl(IX54|ghDe#ie%C|Fz>J6Aa zfg)mj0}(74eTd<)m;!kEJq8#U8cGH|PVyA=@9?rc>^34BvVOQ$ODF|Q@gtD5?4}8w zlwTCT78~1YX~{!9rtpmUrIX(4`DKmc$(n@iv@};dcRPB`uMlytX`(^}1ye6mdN-!d zsD^l>zD}Chi?V5=YzEu-3pg-|+JYU=sNz+a!{LgeEhtY)@t^$>xlDfqyN%cefp}x; z7Ga0JveS`G)9LhSc6N3aijar*%&Av$8MIna{T@Afl+VnfcyJ6D9ZaWTh&RL=+;A!w zQiwDsyRvJ%QMfp;iZWH^r3!P|FZ*XELSYb`7~$uZC8j{lE6g^K-PV2U@Fpy5e}jyM zK;kz;HG{L>wV>XD)8LP8Q&5MCfeH}Y3k78*Rc0s&a>tqfyO$R#c^zJ;oL>$^QH@`N zb}7Q{!;^;qiXdHz3=gOIc>%pBce{&h76@8bcF8{pO%V)0;0+u`F(9N7q3*v81~@8J z{JJae^q4912Y~FbNR54Hu~lROvl`wKnR*aJpo+PL9B|0#FL3`Vdi=_kmj=R4Bz`ZW zVcXa6v!7U|!7f!FPQ&I8^b4Pl&uH;p`e;ID(N;M-J0oxY>C>mpwpTX4*_Z|Y73Pm1ea!ZCSwLFzG*T_!2`G;^5D_ei_<88R}m#`QT-$=2_`-OSB1VI3?Y z(%I1=?>%b>LzBMi4{k%D7RGeQP?(JvwuLu_Nyjyg9{jER`Uj9kYDR8Ju6S8}83y&_ zDJX?tS)$3r#B^0HWM|}NYH5kw|L(!yyJNaa9XY6+!6zk9vuKKo(;(7hfTo$43x`kxL7J{MvdEkf``}p<*>$kad8aDu(q^3_ea8dR9BenEg(1mUFNw* z_qIxlO_9&{)>VEZ_vHRHGqiiO!ZJN^ONGq%R%Nc*I+d`KepGt&&*27-iseWA9tM zHcqDYwF6yD^OZoQx%h=OlLb;;v!)9-nr&hCKXhb^vQb?RZMh<|{!!zLsn;U-q=oZa zETv=?EvbBphm`pCg^i^6IA^+KtClC+;oq);+S4h}>m(%$agy zje;jsTQ-x%@}!Wak=;hOHZh0nS-kHTmE-63V`U8#pVW zgt$@&%|6ie&F|NOkx6o!9v1~>HX6nSsB2hwbdw!{9GpKdp~{>%@Cr)KLx)a9^&2eu z#`HedJmNl6l{3^?$NQya{#;wBPpldr%Q&0{%qHpct&UslV>TP@6DCdRxipX8peDU7;N$pg`Y$lf+$(# z!A_LE&xTR0M)wY!FOGvA(|l%(u6)2i)~$JMbyem596Ra6F%(dPXB!`U`S`K_xj_C) zrpQPxGE6{AMAORr>FdXyDTETSN+&EWyVBR-kZyT$7wgZ!2CaNVpSr^`6c)E?w{a@C zT`z}mqiOL87`d02im}690SuNJJ&6rRRngoSRqp+VKaL=LBS-!5Xh1r}FLr8g~rnn~x!BLEw7k04qM{BBqeeVmw3zdR!qV3RvT z4g*~`&POj9i%}6(n8B+Sqy#wr#9mdQ)K1n&p?!FOmXnMs>^6Y7;=!dE{*dFD`jr&(?8cyv?=>(q~?vvkoUN(L|EtScNzoz7~>O8(I z^N!+qhshfgb#|((LWr;%`fpBfB;8%^@(u`4SkH7a&(te46n?;Wm&m6M^LP}RmL{2J&z-}Ve$SIw+(b~);632a?7{7a z*9AIv3i5y%(}b~H>Z9!#HGspuu&~f~GrAKNRuTjH2WHF?KAB||PFR7anZR>6EhUBA zh>Akh|Kdd^BxMBnuuzcp(Eoi7S2aQtv2P#rk-|2uI{zpI@Q?z%N{VngduuCcmTrjo zw5Cz+e&A=A@gUju&Fj~gU54?CG^myt3WG0=+JXaoDh`gjiJ}jRhjB#S-;#Y7s{!76 zSl+L|HWwCt0vWm5?GI+9kw=-}Hv`@tWNsfm5OJ}+QAnXS*{jLNLbCC$x3?VrMUO2I zVod0E!Pdujqh37YjNgmH`V_|D@JNNW1LxO`8_@N!;CC}KLsPPN=BTvZd?lGbrAErq z^9%1VUj0gQ`_Z+Q_JxtoS;3{^HNd0RFPb+W@u4*l=Tpi{NQx zqy83S51Tu8&y6sZKGU-kptmd+498MZQf|wrh-nSAJwS-*xACV>D=LzBY2by0!4H5> zMjplMyfk2}K>LB|sPGM^VHD`_0hz#)fhL8nrp3VJ0LH?gwE{ZL zD7N#xI)7p>wEyf@Ht-v<9Kv3Yc~Lx=Lve9ad-h%V%qX(y%(N?>3vqGa4Z(Q|e*obY z#ZsT_mi1tSz?hktIXV_VY>1>87Sh*PH;_<4BbAX0LKeLu&c2xU* zctGQ|)RD9{fB_~n84O#tEDRJe$reT*2w!VJhEu)3^A4+e@9(%kkfdR+hJPB^CW~kB z7A6-y@yG}ZfwI!rQYP0`PL;1J#-e_)W#;VTbt3o`NdT~wgCQtf;Z7B$YwyNf5KKjk z4rr$kHp$vKr~UD< z#-&StAb3J-w4KcWccxw%%<3_mC}o-Y`_eN)Zh<*N(hD|3JIbjBBZnt#7z@Q?>d&Fc&E?#!RrZ>OF;7tNn`Rb4g#IS z#S@#pR@l}xud}jQcp;E_2|)<&fv_SHxUc`?5LKd7hq9{axFmzaJw_;dKrsHNi38~y zNBx|)VuT%hP_b7j!4ItR5}k1&2v$*11SjFkeJO@k$!;tmmBZm%(Y^FSz?t9dEX%>U zb5XSY0(M8JayB$bptL|_wQa`cuOE>|PIDAKd9qqkJ;tTrDHxTUo6e7d6M`DB`=QW< z&lvz5&?nf{0U#h39fIzvDUT$&`UD=@!AGe^-*!Zo{I9h_1jzRY;K0F>9mV zE$Lvsf@SIsG9t7D-Pg!92r2=dC?WwJR*TlwIeoU_dz?0742p0GIhFk-U&CN~0zA_z z0p|IA+;DJ1EEhNi6sox;CW)`x>@VOThj9K=eZBH51aM=zf5b^&(%6_35AHFzk#RWq zva9O@<9ixs?*pj#CRbEYAcm?Pe0FS^8;81sixz{iQ9&iG!GGIjWN^?21Jn;Pd8Wlk zU5dpN8!3{i^-|;a-XhCUK_Osy4)#2QhEGx(-EvTI5n|?zUxY&R$A(#2ud^%$>ngAM z!K9OwMQ4yNl-|q%?_`2648Gf{sw~aht=JWb$e94zyWFd!K|#5Xu>-&apC&$c;4{FL zth6+tR9(JanxHE+DLKw$FU~9h_ zAKzuo+juah=4h@qsvYjll#3D2L{HZKVxT^t(@Xvzp1wPt>i=)wc8oYi z$X*Gl?2J>HWrrjy5!o`5tjah>ibP~Yq@<87mCOp+X)7HgT0~MR)$e+LzW3wjkNeU0 zbJuas`@F{Uc|EUTV8h)06hRW>IA6wEkL%{fDK+lK2LuT&ObF9sX#KjxkVZ;8eaMEQ zqoTfgD>oB$4Ag2Mn{|AHQ>f)bnSVUAKiD&9<~BRjNFvwgbq_SD1|`9vp=PG0j_mC!ePGY?K0!**QnhPk@X!H5Lg#Y2g}e-<+g3mDLf2} z38zV2IEvHwhx1VD%*+U2CXP1x$J>)c9s#^`f%{Zvs+;4n0K%A{ee?n+{4dfJe^#&n zkKd9Vo<0SyLX;eR7aZ5~_JV~|u(m+&yt@#gtiRCruDAg{oxmfwuJ9pp8H$ChwvfeV z&OwgV?GXQx%9NnJ$m)eElS`c9BVDI=OH=p^)+<&XVv9pzeq6sfUrwGs72PHJ^f=27 zUVOKBypkUw<$(!HJo_)iyg?H$eQFJLQ+}C3Dx7Y1Ai=3E0}bQwoIb0$mDSCP3bi)D zs0J`LjCSsnKI?MT7T;7pftnEDd*PT~Ktu(r0zC8^nxagWR?#YtD;> zg?skwLB?KjgTSY^N>TS*tL*XZ-E^pxhQm{(rreN$xnT4BLZQ9&f^w!N`_V3LV1 z3tp{NKvq*y^rjvd9X0yGbJU48Vai=P$TmW@P6LQW!~ucop?qTE6D=%O@9c;9d2wi- z9ta+A2zm^rn*M-7YG$t|2KR{_zp*wVqocQq=qn=?qUJh1nof{{jJb1*id<1AV>7{V zL%)r=K#s)s{w4hO5P`{qF@@a&KlHdIr)c|`n3;*3VEfN4aPRm27g`y z`429*=nGF3RJR+jY^IY8!JvT92L>`@(w{YjgUMxXP(QVA4GLDvX+4gLe&6@V5| z0-CPMn%r~aViKXx7uE{_V^r(M^EdljA6wa+PfqSe6-4+h94Jh(-0$PMblVHatlNR< zzlx<@Lm*qgw(9By*U+#qs-K=jtf16|zHGBY^)|(3S3@DsP-gq8A#H>s19k#tztzDj zL#bEga{Dpr?_~#2PrUk)uboa$M>FBS91gVyRsjAI_K?p>&TxPIcXS#>ZPn)Lhq}a} z%K7Haw}xI}4ojp*sKK*06ALpDCyyoDXGb|#+4pn9>VII)()6pO`I+LJ($MTXM6gKa z^Y8yJTowa=3o`;TEq}CnYl=pTeOP(>>*x9TR-`^=?Mn*FU0scdpH2M-zcCMTdw}oG4JB(;SCo6IP=vUd_SE81!-9@Zfc89-=r+kl}1j zFA!p~ISC0}&t}^EJlCk;^NX8`;c8*Un7DipG7sFkPz5C?BYrs{fzjB;O=o)d+0fgo zsJ;vmMq(}`9C`_eoiN)wz^VSsElxSlyZ9^&5rEpmPh)X8ytu5)3($^9KJ`M%sw5i3 zpU(4cehs&7!FB+Z!}Y5>+liL@5*B8si&>+@z)oMJ!w`h#6Tf<+j-zK2hf34hFdrn> zL#QEMsE+M@y;Wr{MhmmG$75r<8X8A&C6ePSMn7PU9f79`D2`iySE2erf7&&K?E;(~ z?%^kh?MWgDfeN{ECrb=s)Q+HaeZq5nVCVa%Po8*sdmD0U+R3S^KBhGCs)wKufwBq( z5BtcgnnoP`8lb;|RRUmF$}s8T_TxHRwtN9H3z-P=Kj0I7SssHC%1OxK=dUoFU*7`8DG8I5{}#zm6MA+u&ULRNaPy87Xr}aX)=p1Sz5*wnA+W zYau*7f-6RAryov-xW_jNPaO>sF!v2>oESg18!{=~7l)0(-U@p{ACj?-zB2+=y`akA z^4mtC;NFddiyts|+-3M$z3^c9Ft$P$NCC<4?a6=lV2Std9(XISXqRqz2)(z#h~_mW zeGr{2ETHKV#oK{g3<_R=%*Vjn#A^}PaodSPccGO@u#{0E{y?b$zgH*U9Iv=a8yJlI zLakIHruPHhD5h3c;+g@uKp#O(gE3wRICnVfygfbfXT~Cf%@+t$U3AF=As-jp;FqH? zRp+Mw0hQZ_cPF@Gu}i?FzjOJ?FiWk_iwF(KHkMc;t?LV@;oe>SGRW z@yK-9$nqu}T8kcq;N_2dU#t^2WrPAiNdR43=@WmqTJV&A;HjTLep@+^hQ*~N5lX}Y z=)Cx%*YWaVwuD`TTbjTNk*)*9I?iz{;;nCEvSfSl|1*rZ{d17w*N-C!mm<6Z`f%*2 zTSC8s!9)@S!HP>FyW4B`C?@{EGQ^{Ogheab&M5%`2^8hXhGRrCw< zr1%3KSXK6o`e2Ug5p^9fi@uaxGb;=uR*E}x-wX3crd#G%wpp@Rmqvw z-M9hvdMik`&`;2h(feypu^8K6Yyk~E(VbRTvsR=Lv(f}Zu)9zsySta*qc1FwPd@;2 z=ek|zMacH_v^^a)Wn`w%cqu=|G6I8c$(76&h{W*1tx?O0 zrkTUHh`-6p3(hX#Yl(9RiyU;oG+9%he?Sld@nz#km|@%1_Gf+!u97H~VHS_UHrWUS zI5?Za2YeK52+BtMDs;?5emM#fx4J97ckt1mfZ7(bqI|}FP>G`$9i`*`Yon;>xjr(^ z(Wa)cXv6RY*|8srd`! zdf<^(*BEsZ0Y72l2mU2~=LW0Xh^d)14n9y#9?uM4_%=fLxQa*B|0BU-6Z)SS2RmG00F?h2*rp}u$7+R_e zuX(#hL|#Dw>qIYMN#fs1H_^p-W0eEMBjm<}Komv&N$4!$bQXx_5{TT^mo9aa;zMs0 zwXr#^t;t^}N_>BzT1BHRH^gy3JMydS+u2dj;UU|aI>2@SOB5Dta2Az7k{ON(Zi(&v zc@F~fo4aK$UBZ(kgy;Art7e_0g;1Md2hbj~{kwMcYMv}fZ*Uy3(742QLm+yXQRr}&(;BY&1JwL;@Q`3^Eg~E6JA+ny1ir5ra9a;R5ga?wy5QxR}E}eixX6S!zSMO z(Y6ukz>u?Wje}Pcm+;x)9YozAbSiiN&{D@`R*_6}me>#5QOtcknPfC6+Kmx9rnA^P z#VLm%MUapbDhGZn>hbc>SAzBvn}NMwpGBT?@nIcn2P(ea2gwxYsf(xzvs* z#Wt9*-J|k9Hc6Dd@*|+`Q23)C-R6U0;aP{W=xk?xtkAq5o*Nw+dkYc;u41=pGWC z+$Vwip8TKTF+jDTd&@b)W&J+t2oY8OR(~Te$aSYHA4;u;$jSpTcz16we=eiMkCM0;j0@ zgDWd50Wa%}DNp|?yza6<+!@&Vd^(yHPd@?_Z}O{n{o;idvQqoo@tNpuCzU+^M2tE> zN2q(C5rzV-c$TO2+-zj}RL@nf~6BaEHBzT;M7(AaMD zL<9&p**iBG;kH=DApLY3l|au2%1!OMD`@@XtFeYnT;nA%&Bd}{Q&H|$QVuaW0}WNi zL5J@nXMB@yL(K)2j%ZLvqyeL3np|+_R|@7~@0OduBf#eIjfi*}7+%;~m2 zAl66@vf5z;@DQbU3qp_o^F=GOw;z+5NVBr+1@g0>@g2_?lO0q#& zp=1h_BJrl*F$W8uf2|!I4?Wf*HMpqCol&TbwkWaAQDh z2;9AuUZ2H`Dv2yr#o&4&BcsNno2x!LV~Q~JjH{)y)9~!cfLExYwT?@FB|Vty!S_0C zp{W7EvHud+wnn(Qar3 zcbBUH1@Z28*b?hnee&b=Yq1>hf$*Diu=vBMbi8T}iy{225NJ{yTEi6qNqX$&pwqq= z8mXn+B+`pUe?dFu205Qo6Kvff>Xsahdc@wjLyicL8s*Xg42))dUdc2QSP%ST{g3(; z>v9MvwR7cRzCxr}S?TpW>!f!V5EC=tZFta-(Dn@hX~7HLG|FOycDn!J!!uepQO@VO z(4Bwibvn810}df}LvMJ=!Wc)1fKfXiDJ?yNT@e1roN^}3E!J@rdu zQ&32Csy&$Kc#uIYfhXbvbaKHTH5Qz~(A!v^pV(cvZj?&`KoNF}_C!*rsXU#Fc|7Bb z+q)@?OfPv4>21ES#i;>=AHC+w3#$yX|De$37^q>3hbu%<@P<%^Pi*N^fRs7{#U;L? zEV|V~a>yYd!n0>#n4VNLDNOaJ+o>cYg9DU-;~?;9R7?RM6t{T3a`#8W$T!cB>m$S} zw2^jHI~@mJvRh91`j4}CLo~YXYMfDM5#m&buS0G|RP)^YP1BwChW#2H*nPF9aJ4?r zjzhnQ@{YCs=~KUk3m}Ab0k{v(DJT%R@9$<|QQ>ZBoI;i|xL@e`O6tr;xf9rzV2pSQ zyteav_2tCttix=wre7&%$sb{1jI{KXhOT`WYHMg{c)<_K7hM8+Zt`_mQ<$SBzB_DU z(4!gvs2by$kQ)nVDv-7;9aq7o^aEd@WRtbI-V|YgOs~eef(sWS9~fX?=!-0R?xPO8 zp-a(hVz=^}-lY}`IXG*;)rQd$0n>6>3h-(vFYobGdsQaahIUXLZuy1N=C&Y zy1yaKi6Y~6c#Q?7xC^4AKV*$<`HgvHMd>@w*yp%8+E>~C`*h$Rn6X`NY~=Z$Ur;sf z{3MhV5*94F%}ae{iI1sd^aC0zsYB`!xl5D-fFn+oPNZR62m3&9P{`@Y0h(HFN`9aP zySP9gtdg=PksSYdfF`3TY8zv3po>H9@0WjngCNZ;PxOw}3+qJSh3M-L?eqj+glbyt z-p%^fiJGCzbF4x+&jO~!z($g@m9otWFJ8p$o!Z6cl%%)8N%fMpj*e9x1?fzzLK|`p z0mQ_>^dOXmqQpp?s7-}79Pl=meSY+;HC^a-%n9Q6Gb&?3y9Uq(MrcvT0+<5UAUJuO z1u4fJ#1N}%zcJmwS@#pNPQx$f&DM8gqlJLH$$N%!{vVEYKqWhyn7UoDfAY4()+h%! zTsMM6trpJ8BzLz-*!316KuboF5}de>0xQef z(Xltg#o0A*;Rx`tcYPP|$qf2{X$RQ0$Ye-n-d?y>LjkSyW&O3bwjKS~Uuv70%9|O$ zO%S#JM5)kYf$$HHna^rVr4*Z!sVGli7op$7@f(}Ey-twq?~Rcjt__CT^qRZ+kXIsINv|Lxw}b8c6~fjHsyqbFgi!Z)(zn3!S}5#VJOk|2DH_T(w8tdQabq_7BmE z*^ic5UcNCKRe$liWBD7<62KZck`H=hehAI?>V=V{idQkj|99fgu{(2>$gB}gLpOz4 z9)=KeU^Xe6;HJYOxMruwoQZzw=%e!ruhVPk;>U04EOfN40& zC;Ih|oT-(?@~N{w@m8^l>+U7=-eS5u6*=ys&QO8~T`zlrgs8u*eDs4!=0%tT0Ae8W zhGLWWo-y(_S!|w)6i=DL4Fr1`yr8=+w_-OZXlYDG4L|z!jW=?%XU0X5_W{@O;j20? zZ#t=yR#aG+Rv#lpg#-^ib=>DJSK3+e%9>_kgoJ1PxJtO0ud0ssaYL!pH!!{jE21{4@EE1IHU;Dz)jzYAtLAAbUF=nl&^%G8Frbx1 zvlBfAB)I%}puA15v_HAIG8~vFK(K_AFbozzy~7^WS;@M^o+*p57zW;xlamYD0mt_& zF>L#88J*@X@Ef+T-{;?q{o=kG{^?POs1jYKEY^Wu=8M|c6iE`DN2t&k60s0$nf}fx z&F$6Z_8d`Vw{~C!u@a!N=eSRhu|?#vKA>>N`zAl;mU@Cr8a%glftq^XP5*mG04m;xUuVeL4mW44UcIiKq9%U^&2)I)LUwl@G3{~+@6ZODnRAuJvv{R#LFs#!W&?G zeB4^3I|K$^ln6y`1r(1KsSnJ z3$>dFn*wZS4jed8dH7xAc2JZMDgVu+C%&9n;M339BG&zu-AVjXOeH-+NdRsCZ{SMO zjVQXL87Lh5d6>D+HP#?Wwg+2dZv5Vun{sT+&M*>c=1&nReXuY3J`A`Px-WQh))Sb-luetf|R3#Y(<03#A& zO0f#^^M$Sb=#O$dWY!FS#EHKOc6#n2N^78YOl5C$mN>)L;XWfD6DF@)r@$%gbMF`$ zQT(8p?RwRN@&!biJiDEy@;UCNsh&bCOzb(YJU#`7heLp&tk2oywiGuw1+W)2Gw=3} z=W7=-7Ho6Hda5)K;jrF3r}@rna$Z|&t9Ymd9mn(sA_x2=Ln*FA2!-K<{_^!vfBy6- zZE>R$&?vbJWuz=oxIg>~c3Z(!OE(@e>_5?MRQR^f?a-Ir)- z^8_u_LPzN6F;vKy>w{^PDa$tV3s4*;0{;G{=2vYYhj6U-V$%*Em(IU)l%^g>l2YKH-u3^UZI%bqm%-Z<_)|EJwi> z-7FKJ%)BP{ct?n+vCWI$jG+>C`}R$J%OO_2)MZ8(#{(iUB6XY_QzXUHvFwt|8i2k8 zC?+9bh>4+0-|}1DKp6tU-F*GJDT=~P1ma+j@*g1z=kvke?me?ij*Y>!L~wB^Q&S)Y zXdsyMD`KtObNvD)g{bI7AhxDephsY%LYXhsd>)GJO`8V#A5o9dzf}de*pp9ZM#*vK ziQJ!-q#a%~yDo|T!j9q2Hx2%vE<@3SZm0U@&7>3Xodq~Q{HD?DFJuF82`miGPbZ`X z1_w{f79NAKA_{mIU`X+08y#Mrx!?a#+%J0u6 z$`Fj??t)_3;Ebs%444DodDY*%x0TV9K>ToA=7wj=u1o6h?`0=t(zIJaF$9%x2cT1! zEClX-7u^-){0pKUMg|7zYZ|OCFxAAL<&8opyB4+z@w|X)j$4XN?-ld-o}9AJh4l6^ zQ}cRm%q3R1=w#V#FJCbqIJ z*C(MvGOHKx11&}J)TfX&)ObZ|2B`kSQii(KnSmT28{e^ab-fAWju=vW@R1{GM;_O5 zA%_h(9k3YdBpE6Ztjv4&>DOQ-k7qo64|JY!qz4}Q((q_F-=YjkdKffptm6cE7?fUS z`fHY0u~_1Ba~+@6x8IGONM64tPp(qEeP^&z_u|E?6K6ZzihZ3nXCZ)GH6U>R96r2W zhNo&CYAe{2W4H*#ict0bKae)y#XEl{;}5`>7Jxh#3sg(1J!dJJ1*h|~0^G7-o3bWJ zn5LckFU(7xSksgGEaV{j9+M>Znz)jkrKe7xUi$uBGtUB09m__|u&K42S4_Yk05>rF zB7ewkTG2OO!FbtT=nCSyHH>Xg5Cca*ZP-j_IZFH+P1t^GBR=ckSi~tKP_swiWsyFme8@Bd;;8lq6EPo1CGa}HwO%wk_E>oT zzetYh&`2-?FdPVg(EwV80CnNSPR&;41w+uqK>)S-RSrvwcSsc35BL!<@_Jr2Pc?>$ zmEvMa`B8!Hbi%L+e!_dD&(WD1W_pH%ECLC=3my;PLDWm3I6%u2Z*(0W`Ls;&w)&92 z%;U$$b$;Cf_W+lc#oe>!6gCs<)Po-tz>7Ehnj8%+;fQ+&oT zVeH00-5)IFhHB;C;OZ1VZc~tFy9(~QuyDpECO1d5P-!O_T;(7@-RQOpL;10+1iU*@81>d3@s3NeuY< z6&5MnEjpxE@~QD5?NZLc0mgl;!|H2lCo3KJKvAK2AD+-6(Yrq+8<0)7#WQ80@7mhv zl(yw-0Yf9xon(DI61HOR71fEm)KJl^-uud&-%B6%2Uh%k!d@)YeZe7ghY|Soi{RIk~*heM}N!3{QBikS( z&7^AYvo?3nIa#*9dsd?2E1AmI7bPwT3I2yqX)=7$>}is+>uon3!&X2K0}5P|;vJBS z&#wal*O@G|(phIEl+wJ^d`n}Fs9|&>7TmB>P-jaTA}Ai#X_&U?^Ps6dk85B$-cAu; z*MoDZ)MYw-kWmqT{bQyeNK->2&R_;eD9Wgtn{SdwX7T4-Tc)uHl3;T7m_Z#&qgUH2 z)3R3q6|X~8Nt|2a3Li50Bgjo&gza>@P0m1q94-fmuYWKUjq}Q${rSJNQs@pB39T8- zk&`&rslr)PKsXv*E4>758*1va9r(+ME5YgQxP7>zai)y0A zeP^L~O!<8{Dyq!Zp4Shn+-2n7s`mU+*d%{rh^Ss#zp9c?Rb^F1>UIX#+#MvzEI1 z6#t5*tn3w>8)E*aNCd$Do)a^7LKloS7$!4)L5pI?KRjN)BB?s@gMB`dfM+>I4pv~ff)zm=wa17>bEpd{k7nghT0=dSmPs9TU!>&$(yv{ zx}{)4s231O>T)5j94z!^I5lY(ozE!OVCc)dbEolm+lM30K6@rE@f*X1IboMNHQ$KX zH}^0B#HEkH%B{4SbS8a*GYbbMaD=sv|59htgkO?qH|U5k-*?6F1kfDBSI~X;H~Q^` zUHM9Yv%BR0lk~H=1kcMNjv?>2T=^i)^zp~ohV4w6b&M{DqeU3sb?Du?_vP2M;EM2F zFegUqc=%eKn)5}`ZWT_$bNrMbjr&o~I4uxEJqa(swd{!_KIg4@spKx#*>uw9(ZMCk zP~R2R^tQuG$W84Kr2BDhHew;4C;Vr)zodI_i0Cen56)}1(?#sl5(zR*w2!@Y=H=b~ z?_UEMTCUoRMPOh?ORrZo+u?9=XBwKto74bi=Aq}e=e^u z^RGYC`NH(u$EW@fVV>hTm*JZ@rFGrtQRt@abLW|Mc~hIuP`dZW7T3@cV!t&Dbs3>9?CP)&$Lg7XWe?(P%VUcpaHk_+g@v2e+Rl!8_`0jp%C(m*ZkE62hJzV4aeNk) zo2)f^Ru>pZzpco51?fa=rYguEbQ-jj*l~uPibYNv$AE)lYYbKp-5)^Bsc+N!f@W171S=)fK zurFT~5aHg#&-_W^$+AJ|tE zxeK5c5UCC7aYCfCEF+m!X?)YvU7M2ilHE_cb$+cFtXyB>&-*fyJm{lt%lq-9t|(_9 z95;FY-kn!B{JTXbik|{emxBl9bg; z2;Z?)!Bb07Vopr~B^SMve*3JwuYJvLv29Te_wH@WO@VeAyme40Fcty#9|A90X++VN zrxh3A#$#O{QuuR&Nzp8zswZ;&=RJOZ2cC;!_x9~|4%@9?JE|O?4jGh97fd}E5LueR zhpbd$WDZ|M%)!xSv|u(wCEw@%4Okmf>Y4tc1QRB?(!jlmhbb~F@b&i@E(QXE!6R`R zAQcaajSU-Eo6W4FkWL3&6ZW2?UR+DLF84oLI_IwWq_j?-46YM-X`PaseCmzr>Nej- zA==i>F$W*4Yu$fF$Dq8nmggZd&*_eVEQ^y&Pt)_bfn!c%eLb?KPvw0qL5zns8d-slz_Z4&l#!_A!>xQF^P8@FiL@zze9!AjB1clXi-U zwDbi&3fI5-kz!Kn$|Y+$CML7qqX)PzDu^{Ic~#O0vTxTJJ|Ke7YHB8Jf7enA9L+Dh zWomlU?*6^~z<#YHuW|WfdaPXS=VVf>q66t9huuIQ2UHFsg*P>W_yvN%Ip4m0i%|-W zF~sG3PIAZ@=`gi zy7@u^p9G8MAHH9adGr@}xWAud`oT6TPLmi$B?kr8>NEgFzzs164@$oe;xRxS1_A}7 z1xkIH)W~U;iC;&;!bAalf_WdJ^&Y4KlxCNJ_-jQv3}DnjbQDMDc$p^P)b;tZMMDH5 zZfNlDB&lb1%5aT%GNTa;OL|d10)}$t?$)aNM9y4Zb~bbGWwHS(7(#*c_-Y?q1xwa@ zeYhSrW)@=-h&r48Tra`jC^OjmGs&o*P zNSMLVcOTv#*t4_>8BR^16iR~C#a*B+hJ>j2jXXs0ot&8H&TYt<^N6_U0ra@(ex=$2?rWd3T3Z2Eni#fq>G~AIL=JaxgKz3;Vc2C%LDio zrS_ah@~vD8xf~A#ws&8??0^;%rt}`5(gNs>%ZI{g(xn>kO7*HfDcj6e<) zFw@{+)hc^u+#1=lcAFo=sq2~Fg)JoEm-0BN6*(aJ!2TjOAEdm z>;1DS^-{ntbWxCH#(C@_!I0O>%j@rG+D9cY(4}^08>GngqB`!W9`V!EFkhf-IiHL~ zK1{!z$%lec{9~KkdKuNN@UN+UYp)DsEnHGN@ql9Y*EpLpd=yH&@lPr{K@D=K9l}a7hJarV;ZI zKbtl{KeK2(tE#JSEk1pb?Su?$&$_ZQIa;(--ZZHDGqba--(RpKNbT4mp(^A~x_#{P z`on$E&TDtJdxMcW;)9`J4VgOCjMg3VURqXFfdNc0q4w9nQD3iS!c>_|M?GhA-3~X=3;BfEpFr^OB4ZcK!oxL9PU|hF4^zN_d`cM^&`x=t?D}K0?-6m9$IhD*{ikikmpKb{rNqw50{mn_7=Q2jUc%U z*}VMxRHm}O&qDCs;>;DDbd}4(80hF@@4HLQjke-iujSbqp;cH}iAb?k$m%Tu+p31{ z>&MB&eOloUCbiNJ-R#b#1_7CpX4_I?)oHdBm*1^h++17;WS-XIvPg!Z9kJTuqT{9@ z8HXknGdkL=x+RiM8iivVKt+%?dYwpjlm7ATymO;Mv|}H>d^tI~Iv_o-`FhDh7k45+ z-GzP0I~;Sig-jax`6=eggVO^l*v7SHB8IPEi;A3Fwr*$K&bv)bJ1m4PN1-jm7fuP9 z7ujw5+>rkI(~qEHVEL{MXC7LD_!ucjymf_MOzY%ZF^G0dvNSjE8ytLi026a~2DYE+ z5%Akjy69Lts_?Eqq6RGNmcPF~38y8^MxHV{s4S!jEH!XUvy!!m3Qv+3pSG_S7d$wC z0#m{Ec^CLF-x&HWJHr1oeT}0GVe*gHfas@0VC_pl7Z`HY=pJ(h+#`Jsd60#m z9TcIH=jJ{Oz~#nMKiW}u@ensf8Y|SHIm~(tbM{qih;AAh>h4uTHZA6HAm0S44AYJ8 zM#=JR*bsw~h;QK2o+~Ku|8t8;AIK)ANbqiDl4dEin*ARDV`jkiCU?N%uCA{@$Uc3! zEcj(k7J!j;c&w?ap_2dWxbsd+!M|+vL8^JmSwpq2cZk=okuowZhdaN$^to7$h7lj6 z;vItu&iS{wyqqB^@NKJ@?irJ-k5Tmj&FR_T&9V~78opV|gwx-?w~&X$z2G`B_IS1& z&6M3&=|F#Cv)5NuEp?%!64~?0UL9gSiY!`Bx$(Tm$BAM<4f7AP6DPKtWAX z_U>HoTJ!Y=AhkUm9a1_v!rKkq#Fgh5AF}y`WUAis z3R$ljB6QXxa zGd(dQMKT55&xaYy?-w_H%?C#bSf!H9jq>spkn8%TXB}bP0JkfAT)bcSH{uAhQv@A@ zNO-)XbUUN|+KZa!sdq$}_WoC@ zO3ST-TkOx(E`_!6-{?5R}y2k(Jqp&A9Jy=~~*<{#VUCCc`f zj=+dkPgyzVP)E|bT~;3-uU z;A1!hB`5#=*_v|SAxUG*oIF}2nbHQeCbqhpVLF)@L}?z+2g68gc$!o|Wi$a$-+~+w zp!74&m(vNX0)FbWEp8Y_l?cr^I0fGB^?cORgLr$!KIc4(nYp<@xu5lRt#5AVBU|C3 zy05K|kGw@iucw)b$qLBgM|BFZ02I`O+Hq4AJ8O99D(YeO>}+OQWPF!wmF z7pC(D-#loh3<6IOvfbP33#VZWA2Q0IioMZ(f1qI_qLONDdpaq=>2zJ8wF>dr@$A9Fi4yuI5Y8=-lT_*2h!Q>bB=uC zlfV7Q45!f~;yGo4?ic!&ATa}UHC$t~-9@uVZaj;1Anfw3YIOml5@35Tp`ZGGKThn= z3y-sKi;7)%0?Y$Ro26&BKmKsE=IemP3CZGf7IHEQyq)w)j&-sc8hfw&`)}H`MCZi8 z<(=vGZpVLGS5kcW_3M~Ryu#uEOZ#^Rs|1t=YZ~4EK{;Qx`_ee5f4>s~qKkts8J%Sf zn|r09rPW+k#&X%&qY=v#Who=e=N33__+@6sj6T&WqbZ&A#KGc|OqDjQ>1 z+v}9d>=8Wl?B@PxNViZt5vu=hI2@rxXpCI)>VFvfEV1kZScE`zo*_pTJTm}B{uO^Q za6yHQ)+}vGEA=wUTO5(|dJ>UETk|Yb^8$v8^`R@m?)cA_46eR6544F(H6Q}1=|nWv zBBX&OEiEoSyBDETXqT*k1sLaouT~VrZ(<&Fv|iQ34TiW#P>T)NM+thP z4cItX3AOw0&0SdlQxiYfoQNZJt-j_P_4LMGr9N0+*Hm31#1)9Qz)`}QxA_I(B(zQn zt-a)kpb5Q04s(&fE5^xFzh&;LJG`X)dS8mgryKoUiPGQGvqDSYCL0`)$mSp8eTDB=g|F|=SsXZzSI*4K|vu%W&=z1-q%MPfkHt! ztDzR8Px5Va$}fY^r3LzC)b6mLiarRo3HZjr-~RCZY2Ht_@tBzb3Kt?umy;#Qy}@Y? z^FBGNiy(wyOXpGGLR_d7G6}I^Z0vb>Iq%s=lFF=U60A`u-K1PU(!L|W&ktkPouwfd zQi3?TBbY|lf4bL`5Rjk+v2SgztI{FXJVL688M~I2L+QvFVQn01H8r?Mlu!PR(PAse z&j(`}>_@UeP|IS{rAwC_w>7tcroRF;$?hV`_0bQ+^$yk@enfQT2@a~X(*x*N!OA^F zJ}6YSX;H;A6WlED#A<4+uZ^OEWIOsfCu|DV;R0J+l>mledNv=Q;>`AUy8)zSM2y=L zS)(6`+i_`QZ-(zZq%ORUul`VSSnM75LeE zCQp;`nQfva5Uz{~Qc*uo>j6ORTAhiC3>&QI>o7brWU; zo$4MK3SqQKZWZb>lPGCNHyn3|A@vC4V?a=vV{0gVbwroEoZ_=dyY_jdBjmdgMb~_V zDLgp-a3Zio^V_2}I$~X1RSp7XRexW|b-o_7@ z3WGyKJCIHUKI`$An2gxpxK7|o-|SkQtg}c1NAJJ^#EK|eyzipg@~;K&1C(ZXrj*}q zYHEV}41gH0v6fKgY5J%Or(PBn6N7=}ZlS#sm5yJw@5IsrSZg8Ibz29;J%Yggf>mIT zK%)P=(!ry~(IY{L2!`TF%Xw{s8ZZSJnXZ6M9c_4?24CL*J_ibyQ=a`jSd`d&FrK<@ z$G*j>#*KbjgIZEwdw?>8qig8Wjsp?tNE*DpU&Fyy*5e>%=Wx0a)_&2`TP^2H}KQ<=n?UPNEB-S2#*%YxO_@v zD3(J7-|+>SeoMrEiub^MXSE2P@dX~rCkMhf`tWkPtlH(t;xx(Fju?4 z_39{G3Zj<%#t&}00L+SoW%5YZ=qTT0frA8J(JTRPr|nFT`G@Ry!AnC1@TXv5VPU`O zWpZehBxyJT!1@SPi_&;LNaeBGhbbJX$_=hMHoSL*V z>FI}q&KQw>upeLy8Fxv|fgvGq31)AkuSt@@%K7hiX6wG+29_{HD@%|NfDB@o2Z}A~ zB5|EClM-vlVAIyA%|P1amS9F^@}ar6;k5$CTeMZBdGDsYjd!nG=k>}hQk_0`JE z!*_8XxBVE8hKp{@X>m@!B<>p7n&=JGNF?hJ$bz?8pyI)zPe!eUS4ZK6c|N$l2Iuv6 z;oafzbn_a>5gl>*btk7UPICVEo{jO6CaHE~*YOlvLf<_)HXg}(CfP9uJ_D$w-vaMK zj^L3aBG5P^B?MNW;qkGi@MQr=dzSK*B5-;9pn?Rh@t&h{+XB-%Tph$+J~ijz;h7Q5L3T7$%tmGq zp#(;QI|BRBanxR5!4TGDn5`l^Kz73hobXW2fr`CIkwC6E#QKC(vn7IOp49K=tbKFw zxI0I%duw9WE`ejn6MnH3!bK?7y0%@mmX9dfZD9fCBzm3YA3r3tf@!e5@%QsX*U-*l zSz-;q2Cb6dftV3EEZr2(aR(p>|1aEcL_#f|Y0Y0A;wHwNmI$1M;FNF%A?t)rr{J9Z zYarv8!@RiB;eD~dQbqV+(*MSvN(*o39~R)w!vX8u=$CCR62~OFDCVVN6h`-B?dV_) z(zTDneGCL1#&0UtHkbg{RGhAuTc&G#v=n1!XNO4`TrAiFgr2;FlD4V8U_g&oR-ad? z4-G6~6^t5k9^hT#o@ztc?@*t@A}F!UH}$ffwuic~Y364@+@OmQDmoJ-)|zwnt;c0G zf@OeHq5@oNq^!7U6J{d2aaOVH;@(NBa|E0^Ya)Oydlur4i8Z{DjxRnCtgx?2#f4j4ojenF@0h#0)ZkLGQ?n|f zF0;+B(pFK`QGjRU8V5C=P&Q7g86yJM@Y|rFk28d6CI-ie3szbS^ro3ROI4JWgK>CZ zH`yy@dmZL!+KmYVDDn=t!3lW+PM_NQ!LKVZ@qw~VoZ}z}+)h=`K7`?E!${8wPZfkU zaf?xf{##Hi^b8KpgS2X8W)^>xL%ap%5j8an;7U0tk<9tywcyXRaQ#|QzV;Eh`)Bn@ z?tKh>jmQ5O&Z=&I!H`l{hT?oE*UEMpqG)Io5)Pj+1ugO=Ahiev8%_7FS7T%N&r8|+ zT%pIF8cW$7G8^Jw5Vy@5rEMd;A~4(qex7i@3T@T@ILtg{IkA@N!L)X4y$uN4fgv62 zSE!OM=9!JbO`O_V#wn!ATFq#LMM?LtBAe;hc(J zzW)5vySh)URxsLtufty~QMl92gOPZ7{Ue?nSAB_zwdKYpBPiKn1N#>Y|4#Qk#){TN zq4w7iwojH=i6{}leD4<3Z5=Q!`y<|Y`~mcCp(aiQ(Qdb-nSEGT!?N|As(5!Hgn6x9 zd-VOV6v#+dgvt*FJQ?dUmnz$bvH%YizxX(cUg%8Wyqwj`F{f? zb=gQHJyKlMzs)7tkoy;%`1Ju3L;U&#UO5E?UyzJpc8UXs%t3?4hcHI8M8g7gb!_Tc zNfP%ZkIusg z#S`6&+c>xW^4P{3vF|XvC1|&^vqUN+#_P!Q?7HQq7Qe$I@gtNlu!{qk{0+D|nX)`D z(+yY4KGi>E@G|>77(2V-yw-tlCA`X>ptE5?!zX82DzfOZyu(dM2;i(X4SqCs0rL^|i!B*Z(1}Ok)PlQXGaMOdk8S##-Z|OQb><=z z@AGF0Kzw{+oXZ@?AI%4g3J}92vO9uBeZ9R||7H@@A|rMN+TYPCz7ek1`v|BHnWFeu zvqP6q=fj2^2P0yeSXdZt`Fp+|AATzi+3gQb>C2tmG&$r(N>Wv%vF78qAa-{(Cjp9UJ9@PC3gR;OHZjT=5ZSgd8uvDP1%*@E3 zGdr4S5T{`SB3aPeR&3vV+ybXhb39wfg;8?s%O(&#KZL1UI^R-xXb(<4v|pmK+H^vwG~;X-8oF}U+B@j2C-!jRs%GlhBG-oAtoB$2S?afu&54aw{!GwDMEmmvLmY}42gEW*kTq!#c8QZ!gKK&i+}CL;DSbvrJak>(fulo{ z83S225~^JK2L}&B`iW8;_B(iz@b`#S=F`fd|Ex+SJ8*Fv7#VM!GQ{8F`r=3>H#2k$ zHrlRrHKq&!&b^Dn2(l~8lgvO81_h_5r#%fGKRAItJgXW5PK)sw@W-H6CMgB5Z^qP| zy3q8^gjAE-d4%0|(-Td#-DE0=|G~jD=S-@HLhf3c&ri1%Mh*upRZ#Mo|Ghn9on@)d1whs8jOSVw6rzc+$hIuZ{123HISD2uS37Z z6l0z#{OJMhqe|(7{)C;qeG^U_kfZx?nJqteP44UTRIODSi|cc`zk}q=c;hW!tPl;z z9^|vno*}RS(}(%_^gTx${WctSy4LH-Fx5UYiQB?DVEtbj0=>vl~W-L{-Tf<^2%yI4##Xs!)U1#1f6bWPU0C`oE`vLsV$ z-o3bf7Xm`}Trin{qYUqda0)O3ku{1|Pbs-}oaOnJ%4>khC>!E35Ux~r^_cyyME}G7 z7D(5!KApIFbl>ircah_RgH|QhG|I`q$dE2JXEepI-T<*c@43400H))bkaI?Wenl;w zU*IbpDMvD)>N;`5h@;*<2kPCa+(50ajUrhyUNZJv?CO|m9_QeE)3c^wj&>h)n3=@y zZ8N(%sm0}evF@>3j(b`|EvA8o9C4oGF};6Z-n8e|b9NRcJg;~Pt-yHU7#pk}P$(1}qN;d?JEFHYcEbBZB7I-F|J zM<@1ChIBEy*VfJpsoDBYT4#966IFfT=r!Volq(tEawSQ&2JC{_b98G&`Vc#3TVhR@ z)xSis97R4r&%|qkJ?`~8(U%PWh`&e@H_+MGFYA^NspfXsMJ@}Z6pjR$SC_q#ZyA0I zO`pO8RlYE``RPRqIW#Yiebi~&gIVdeF)uPr8~HTmUcD&-hF_@YkbZ-4-?ZNxbIQX& z{{4=uCKncLnQ~tqeQOuTEou4~`-ZKb-ny^(CBurTGvk;5sOS*L_SJ|Ez3fSR^e)ux z2D}azf6h{2!n^;&<4%_R;s$UQ&FlU3>WhD${AlkYnQgD}w(` zWr$=<;KjgTb=s;9!oK_V?U!!KZn|64uB%Xj{QL-`NAT-G)-+D&7~U*al5&?G4OF@> zPw_=PwO9>0F~;xwP)+8BGK=5!SbOm3)eMfcW-ZwNPy953*yv zdL-krsOz<@@8dq)G4=6DXAKJOkLH+DpjI>fWM)#OMWoY63zxQXe0aHTAwKte#_XPm z;)Eo=$^rMq*I_DpiC3uivdVnf*7I0hv`+W;*sVXc>)d(Ul4@_ud#B zc&}8`9NeOJfObIX3-}eXiOz}4h$B+Uo#KO;c z5S;fEXO643+KBCOHo9C)8N>Vs_;n;Oz$Xv$EJni)AD&040=!+4bGzbXQLN9yuc$TtKepZj9Lx9r8%N|ek{uZ#2_buA#GQnY zRI-wtnORB7x~+z!2$fYx$X+3vii|{(WTui54JACUtIzK_{{Q279>4GLJ$~OK6j&N(Qzy;`eYF?jps;0TAmJKY`6HR-Rb^;&7fI|Zo|Ra+O_5tL7k zsG@+_aRw-Ocpx8Ak$!MoEf%ePW7y#J>W zz_kQw3w4c9_g&*;Yy+x;$yeW%u)|c^n!tdDU%$rhqSevva(iNbIbyhy&c1Ft+g#fU zZ;S~gCWN0gSkU3#Eh91gpM&F}G5_5yHT|PzJm3O?B!x+(_BI# z)ANSuwnR=%jhd2D1%_}Cv>J)^{iO@QtJVQI2BeB+?*0a*p9u*GmDL`6)coo_iy%<~N|e~KBf>K$sQ&T50w#5sVnzku z7m*v0i!rLjo#O%5d)g0{To6b?0U*zeDHAq)ylbl;bBMt!@$TuyRBsIXZ4Uv)?c2i+ zR}B&hAlE$9%d&^a9hD3h!axh+eSEQzIrml^0oJv%d|&>+gMm-y6weRrX8L#e-0QYn zgCrAQYo3n8V)^e^kNj%!p>d0@ zG3t_MYX9F45-KFYsCZt)qvVJ`7;xb40Jx6A=C?}f@33LgHMg%kx;e-GyZrz8V_!JH z=mg_AxYlw|m>8gAcC!}1x3_$wNWaYgeA55E^%n!qUI=7N=jy2Dx3|bv>GsP^n2@1j z`rmhi_`aBBTHJYB9k`))76qmOe0jVs^>ik$#^Bz-O@<9waeuRo=*P7o^ePXUi^iFqQ3ex5@?IGprGSnulp`2oWTUrs{$AFQs?D)D-( zr?=PiWmf-5?-fx4Eug8`^TbFrv!6<6(w!}9Y%aiN0K-49P(>)>x^M`?&=G^liX+g} zVvM$jFqH>ko^j>xUy-d_F{0E@fJ)9QmIu!H>yAU%&2mXt(1$h9S;O_ikRaCIYDD!W z-H{WlqaG8(+r-7jyai6|To* z-4353`MJ4-e(0xUnGND^P{@!1>($smu~O`~KB$qT&ZC){1H!u$mHjXK;4y_8I?{)q z%;oIewNdpWQf>^h!}}S9X^#cQH_1zpWA5pfow{(KQyq9Kk^}RPePBL#>6M1(hA4Yc zb_06%h#klTH6%B+huzuRuXvaw<5aB?%yj3IMdw$IFI|7A%^H)iYQFJbWfjrS8jv`b z(S4Rpg|4$$bZ-{lrYPQnFT<2t&n-fxW3X?(^LH3V&Y-WZcil}Kcke7X!231J!cu$K397_(?!E!kjek=DR02)+Ct$C z>~U)=w!dQL4!5paExy-EaVC5M;(d}E1u4v3h3@6Ubi$cC zzp!9FDnU9xRj_|6Nd)sCO=(TFZ8eyKl$O3ge|SaDNES{Xd51qi9{_v;+1r4>xVX^J z>nCt7s1NoH4T*~;6mtAFWv()Pa@{&Dg?ycw;1GXt5u5nZq4~6Y9rZo~@w6GWq&+0I zsUNiK?_ZhiHR`4$9$R|4&hHp>)-b-4*|sgarUq24M>RE?fWp9|XqMOJC}yrb@imQL zAe5H}KxGe0nZ@tl>$WSu!Q@lLYZxs(7*y%7NCTj!HI{n1pbjD&Ex1u$&~oh+0x*32 z>C3avzNXXI#y6A1wL6k>#Q;A! z52_Vp@I-RlDeaPzc3~0^BgZAeKMffzh zH%MXxz6t4VZOZ&x#4QruJ}^$C^ukNE|HTW!MW^VT{xAC1oY9ljSOsoinPC*V$z>SS zwJoZ4W9`O#Ul-K8m@VRCsYy<`~ctH;5j|t|)Q{ zWkhnxE`x-%)n5%DbG>-^5(k$k`%^ITF@awL*wb_giIf#k-vZFP+ZTwQ9P?Ayav)G~ zWZF*-d5H3aeIjRY+sO@_`0PKSES0;~(!DF{GTEHqCte z7|S}LYT~*=ucu;Fs0{N;*rkXXgiA=>J~c*{b68Tn*J{KiK@<$m5?ps_sXWCL&?e+E z02i^4vfthQ*VYsZr0|CQX&Ky7>HY2k_^7ElY-9|2raT&Dk5|by$aw%%!x@cZo|l)G zfG6R~jCns1I0}~KEazS?FE5yF;P(pXx8To?53$V!?P6IKr0p!_?|>mM06puWo3~Q6 z9i;~`+`~u0=%~F^s%O!BfpPQ%{7MP8jnDR=Piblbi`^+j<%mEU4{pr_p%|dYJxG03 znNY5{KB)@L$YPx>L`(J{!R)_y=@Qgzt}Z?$0UuwWK&C%&@wpFsVPqwat5J4{J?UL0rN6R35oHuwz>VUEjCJ>Yi|WSvrZBod-5rtW4EWdLWkge zJunUY%#F;<6b1T)f&$z6&3;+MQZR<+*tF@y>0QK*RCVgWTS)|AJlj!3vR(yIb2M(# ze|bjMyI(}UYi?zu9fuRn@Rx|Hmh9l#-!aSVXNnAS*HP^&s(jp*pCS~4IXhvN zWd+_TOgZdYMxLG?eIEMFpe~;DR5+3V@jz^9+%jHCW8{OU2)@QmOfjxqv)5eNs%j`i z0buGF&|1);B-=JT> zAp+wRVcPN{Sc&528!hjM68RgjElXYb3qmmKvG$)|E{&ZP)GbL?pEzq<^omd#5)Rjp zLBK){6M(qs);+eZUNpC}gsytHy6W%We-5Hu5TW1^{=6#wiPs9OYk)`~n&l=??`49q zgMTSzCX;4Pgj+M`zZyI6chN*yT|uG$bJji*VHeq5Qu}&pA1ZjN;3Qud;>*9yt>lwaItc=Rj&l72Upl9iY}PXn5-ttb=nd zOkZhpfP$yyfCPBw)-AyIJ>4k^GwJS&^)YXrAJsm65x^A@l?Ltv`nB})KY3lHhmUOB zdyw@$K!h*KNSp?gsg#LPttuiY98Cjqv|6RxH`F=zXP&kg8C2_5J+0cgBBLviwO3qTmDiDZ-%7K zsXEt$?Gu&MhiO2m#3KY%p}{^kg7OkMhqAK05Jzp&3R$g#<1`O9>rW-@i2#&84p+=V z0*?STQEfevy{{-wQFHqI5qbwrqJ?`%Gh(bN(!dV%Ys4`ho%%oIvEHURLsh0EjTf6aS=4 zXZC1@O62E-m1XRni8pUDrC)`NyW2+q`K`2P;fxEYqB$RaPKgVu+-DHQrLC=vE4Uq@ zJq;KVR$_0Tv(H!MN5=55(MoXrv)1I3X0MJh%+qc) zkVk7FBn0LORY>BsYX@y@;gRaF-$n(~?}TXx(tRvkUb6SCfJ;|#&8_36DSYR_S$N4@ zItQaAgh`^d%RBtc*9O-C6u!}4&=C;un4zStOA^&L0&IZ-SLmLa&}E09phrexwJ=CU zKZD|;#0*OqKKlj7YJVcO7&MxgWEZ_wuf00RQ9B+|AF>97+RhmuU$va?p)F~f+1SQE zeY&_G^Y!+Q4nhn7ie#oKINJYW8nI)zrAFyWlgxM)GK)6>@CG z7eNVyZnK16=i)PTf+x57gu;FBG6qaFj~;C%Sg4fnkKJ%?#GV^v59X}S!I$uMM!9-s zzxx;2(7KWYGZV@w0IIc!7CLjL%4q-kgSM<>xXA!5v%rVP%NI2u4?Z6uA`A!!0ItRx zRnvJ_<=x4~YVe#03y$e$iFRYrtf{j0&B)IVNiFw0e#dg)sHkir2)9Sp=k zhPjx13UqxGEv4W2yt(Yo2caJXAPIP3{3z#MLNScNjb!4eV!PH)O?4P5qo^8JFz&}W zw(ieHkmD&1q6Scr5o+L%A3p*@@L|2eujcphMK;DsbpiHwlv$c1s6a{F;=QGFc@#j3 zup)?en$MUvsg2&x2;KgO7Umo&M&?J4R=M_Nf!^4wU_VN`PDXyr*ykR@ zOY%d<(tSK-0$QQ@64g{h7y$4;NGUsn^StP_6YlAXlz67i1vb9Uw+>&v=;qBtc2^e> znZS~<1aStYqrSzhpqvs~NYs>BHoSH2(|*6P=8s4Y0PlnmAux4<&WL{GEm^LC3*8qg zK@irv8i%?cfz|{_Xr=7H?j4dcur+vvqJnpT7<$3X(lIA&1BFtvXx&#IJ=!;T#Mu zz1-P#0EoIcJ7dS3h(3l-@cy0gl1!qZ63~5QM_fQgI#^>sj;$d2Lm|^yta`GHB<#u0 zfx8OLCp1oQ875Nhq0i~MdIp~Z+#DD!zx{IhVxS#*I2?mpn#qxk>DU;X2-Sklkw{%q z?+D0&AgK^U2q4KIsc0ump(O13a_tMX=WaX z%W(=%RFc6CHp*FBZdgND)yaHjg9P}z%G_N3^9Slpj6`5qu@#(x>xf?fMRn)S=qH@a zEn^luNv`hhL0D%1<{b^#Cpk}g=@gn{m$bYp?aTzeP~wC8Tjpd1Njb+z)#?C>@1&(& zK+vc~UOY)!SlAxT;+oI$s#~bq2A(-1xG{rv-blVXSUNnSWrLfPnMqwCn&9=yUJgqi z8Rx2-r5JMPA#$N()!D$T1U9Ac6+Qp-I|;0R8Jvtjq-0eTe4ORB3nd07O##J&ItflN z+A}m{G?8BalLTOp=2mu2&TxXx9k_7MAUzQ0s&w!u^gY#LMSRtM8qunFUVg}~1pW3r zgsP2!d_gZWzXfK%+N9Ey#K;KO+BWddOf4)(%{M__ZCG19hxsz=i{lj1?1x4{BYpkq z;NS8F37mWT6yKulg^Cc)S!pxR*~{!NSQNd23pMPch%|6m;t85Jj-E*~KglK4+58-K zA7GH?MtEq_=CP|`5BJB#o)U!OaWIezb*PFU7;K38-MYX}AzPg6XFoY=}VwNz9=s}N!Vh{_$=NW1{gpEEpk|VIQ;f?6cI=6~nra}?tvh&VV z-_htnA}USSF4Z_#Z3=DQOA~a|f`_J4CzJ;gCn!f}3jcN$IE{bb`mp0cL;sglU=8n*Cn^B7ca6qzBO|)GTV$M6Z1dyGZNzmO()4Ute+^TWKx#`>s`=eAyIQN7=+dA4=h$v&C(o7cje7G`s$9(Qs* zno`Ig^4+pEWdn)@yDpGfX~pl19Y|IJXTUuFx$2&>P4$FDO3ikVtRw7}}M4IrqReC;Q}8wj&}1BR9C07axhZ z)C;?Aj1O2tSQs=8pG}KC97$#BpE)Q}J33xLJ6B%H_JyZkCYWt?Q}RXg&KCXVRO+j- z$1nWO{ucs!F>CYY%}DbAm8gU&dq9Q!P#0pDVltv?v(2noSj&<^m$b7FH|Zw3@pTza z{CHPldRD51UDUp&&?+)Tf_^o)|7^#!THIvK>|1KDjjP;c1sXkPw1?k3txT!lh?p^7PwBr=S)wAw552X?Yf8QEWy`$L-JWqo<=I9Hk}>k;yjt8gA2L?2<3k(js<8 z+RN*Hvx)vTmMX4&LACC)egg59Jbb8J#>d7j`{ppnGi5$WoHakHIB2PD?pqqjK3r#M z72Q-3>K$wCc=!Ar&~M_Z2D~!`cPM6ClC6Yp=_lNH>NH>aZNJRw;>>ChMVHF#K4}3Jxb>1h z{|WgN)wGrf_(k3LD%!y^xvrvwnMp0*w+Oy;ih@NO4l$iH%(9day4fj_@W@TfKW>sD zc7)TwR|-E!`8^)?r8NurO^mUBPVRc!yD#N1cGulw`AyF-QARz~my{uab`0`uivB^3 zp8UfPq6LG*MMP-K9qe}+x<)e_O7>UX*GIq(~*6ik+fcc88&@y-R;N6Gfc(x6IvCc zF@Zl~-|kj5Y&m3EO~!;Fu0bop>(1_G0T>7mrkCfJ#qXhxv+SIbaFM1BAxGJ(CQS~l z-5K(FUb{^{;hrex-xGQs&D9uChYJb0rOf3|B$XSJc)0)OPCu%T6e4AzPJM;d_iAWs?o1Gm+^`}Q43Rs5KfIVq%Dhfjmn9Q62??!46kfoah%m0gZo?&i^% z)ngDsHd3XInds`(Eb(wr?K&pbgdCCKOY|oFaaP>-VPb2RT9&TgYeaIy(>p6n~`Ye3D>m4Hfr ze1<(%_ii8J=MBkh{7ogdQ@V{wAC$gx-ttRM3E#sLiEPI^Cfjdz4!A2_uFNhjIAY2! zDJ4|^XI6|06nL1WI!mPpsba{=>~WNSPacHdM<5gEyT$zg>|hh)>51?Hv=$tmQ171u z2^xi8HOj9+I+dXMRTP*$4zT7(G~8~SIyF8X#!7R3=Vj5(Qsx%%ZH6yPwMsI>db4Is z9&SsUDe7c7&}t4p+%j-gF8 zNOD3z#4hWBABXCmw`pJF>{%4mNlLug#0EnzXZaMfyt=&CbV=k==1#u?xSVgc()5n> zcw^Ko?rD4I5XTcEw^U$m=z{gC3TAI4Plc50ls)8)VZ{TurVqjWw-pb9+~&3aIoh2H zNSdf>>&#UIjQat8*!}g;9NI7=(8H|cLP%lH=RT>bI&i-$8Jo*fZS$|q074qP4*IVPDnLAB9TOiRl)e?WwSzX|~i z{E29fa&{IU890LR1$soxdLb=2GCLIEDW%#qEi{)G7* z)+aJdm-3N+LD0nQe)o@zfVlk+D1V&L*A zSjFPSK-ev$X)-dB-A|rqlK}HYG(ju33@am@R1X`_N8$XwOIQ8_pHbsXC z-rm|qTLZ)sp4|g88D`1mo~tm4u+zVE@uE;KRbx|=J%9s9+CbpN`ZO)$dW0*g^ zp{jp+-Ah^G2||&xS2kJLKvjhM5V8A#t_ZJ^JLmU&zznJhuhFLBe*F3M)jOM7s--#n zsAM9#g$K5R@fH>(CrSW_E!b-^(jv`c(J z-k;Wl^$riOhFc*%`aXey)m$D^SX20dZxn3(?wF{cS8Z(69tu)9?;RAhs|W6{KVg{! zf{9A@McC#8<-+AyTJpLMqrTcx8(q1r2n!o9R6$-|P2e|&y=`l>wGfUhjk3LG_hxGL zU(cc^_4Z*wfzkedUxtP>R}6J^TOl6=8V{~tp}x$?BX20;GBRk5>V3w^SbFhph=63m z8NOiy9pnZwEoU5RN5jXbr;nJLQsvmw>R|3~2a!VI#q}p8ueot0$$L;ty^eVW1qC@e zPC{w@eOJAa4EA!yNC)`NLCb(I73a6m3^a-`_*Kw?^2;NeF_6$)q3QI5^)m!=x@aiy z$xyon2NULK?(X1>;=`jnBf=j68DZ#tN`j%_1diGhR(g8vm<3^tk;4D;{KvWrqYhi4 z|9&P|p_ zuf&Toyc8I^V>x#{<_$COo#IryznD>Q|AbydB7=)UL*C&lJb!%ypK{~I@m;ZTagTk* zX&fCgsQwuvfyEytPpcD*DY>FCY`B{rU?PdYupOL?nU*nEul7I+*67QR@85PgHte`B zKju+H@FPa?Wq4BL9Ve-VZd0P7AT=C=jhg4lla*R5LG^;05qP)&Hw5EkS?cJw&42ta zW&x$1@{I7nY=k?;BwPrCG>q>$-?^S;30XzAig&%ly}0cGJ~}aq;w?45!VG> z0?nj0!VKDWmG8UTr|oDP{H+aEL8&?R!GUS-Fy_F1gi$dcJ3CD)@7{xJhcqqYgAk7X zxj_|cZ(76dH4Iv1~AanO&T6^LC3X)yLP@N^4mBL+%=vSDwi zR4!!&9w^mGi1A!*tnAxz{@$zE@ndA|1DV~h&9Hl6o@uIm{IcHUMXnvBFoVD7^T97k zB*ee~wvgm&p-ig-4LZZGOY_-~wt@ZCv2q8QhPFq^0I?qYn1 z0uCc5YSU;8`8FRpl@p$%2<&fRPB=l=vCB!|b0 z{~i@f65ynVw)MmbR#(UWFmP!Qm9R=heKJzg&DUT$2p|(+i#0b#nv06&AP>i(g9qi{ zPh7_-ARvIKyJw{ZCb=*%dxv@tG0OJr5*M4>F#kjyfUf7btu4!uCbh=5q_i0bg^ubc zQA-2$wui@}^M33<7;Q5;JITMstc!|ATLm;ar| zwglQX%W~I)u%j)l{%*fN4q-8v#G{V_Ezs*1b1bXx&2a$D*y@r_$cr&UhZ6d0t9h9v z*JFvw!+-fq z?AE7jI)0tBvm1jEgXE4KZ?fcnbFa@UDDcFShQ!C^W;O~`+jE<*xL}-s55oE7z|o_x z0JSbkPpQAk3Y$4QdOAlSW569RWRHDI6buDwY~uJL_F3o$Z>9YCIadoe4NS!EbH67) z*=n!FkruB)mQ0L_j!sweZ~t6e9O)MAyY3qGKeI68KhRXR(9$=UUzMfAU%J$ZI{@9j zP{9VKASyBjW1MVkNIHJH_!o3%Y<5hXqC84tqfHIkmT}OXV+S;UUw;Gv0~qa(KcMid zzm!sZ)P9_3@egQrmf}{)UIy0%BYtt&M%kT(51xej;It8tcY1=&gc6a_|B2^7dU&}fy2~R;JNfLQG+0UsEy!U(RI<7AK;5sQ`SH915V;Qi7LLoFgbKJbZLiqE0 z6qM)HWj>q8&*1~Y&LZ#+D;Xd0zUc)WIN4g_2*S7=2`GF^UYUlhJI~e8-7s`^a+<>S zh+B2y_X~Ip0KW>3iHwduj^^KRD*#DH2mA!nC0021Lkzk-KG;&nz~E_yPQ-j78!Icu zzA+7L99ZZ}NHf)0@u?0wtDr@zEl58L(-I&L6}S}i?Jh>ksI=_)LEsq0DI=xB+6*KD zHD|M-&l^aL%I%tHx@J0^{8J{DQp3}|sdg72k6T1v_bq;Gl_lK=}qtWKU#>rG+Lw?sq@r!6+aKyovsr1|D2$v55yOJ$4 z8Zz_dWi*ytTJD>Qrv-hI=wyUy63jWD4E3h^5lOB z-LSiYqXWEWz`pzZ;X@34Q&%&cC=HUi+ne6L)!enqsygY5NzMxPWUvOfPdhzNRZ42Z z^2D1=tvuSC=y;l@k)`bbG;rn7xxnyK=2qYs0W_rL6HZI?endv`3;noBi=^`ycV3{KLd1f)is*teRykfMBy&h(a z92^`aY5XCPYCdROWT(c@3Qic0G0@bmx3CgAADX_-=WAm*z3|khPk&%lXZ1P)dtAR@ zn#<`l(oXZCP1WyvdwYozK!J^n{sx_TmHd12_>`2`GuuuUU%%cD3y+=09*N#Ilzm+0 zyv;F3;42Nn7x3m7-BxA072~*Q6+FhYjG_x5hD^Cs?KY!j;I8*)rQ#`US=Kma_fX9TqebTufXO77qfq)h%|b&z07)2~V8d0}AZn!#7?t zT+Y5w{1zzE^=sFtX_=FAtQO{ylx|siINUnkO}3YjpZmovsG5HIv;6SL$n6_9bo&+i zCWb$K{rc`fHlrRUF~$Se49KPg<%N`6wpgZ6tR3uty zGX@D1-XGt;6YUt{V8Md4u^GWHz!Jjm!jcQvbqrz!w2J|}>mE#7^Rq;1Xy=d7X-Uuj z?nu87{H125+^UeA3Bv}GaH1g>JG(sM?{J@C-it)OFQfIfIXN!iWa1Rc=tJ@m9H_F~ zQk{qBn7F|m^yQ}?eFAg=N<>U35+Ta%$xRDloYkO)u=Mq7;+S*EIuAN**w-zz3niav zbk#<|58J=9{xE>QJCJeyqqq1V(~@&*NCoPZkrA7;nSUlIs5jSs zg{=L;Ax8eN^Rd?!n3e-hTG~T!24HcwQBcDIA+zb}2NFbe&GB@=-4ayuyH{3LCY!pe z!#88HS(b+x2qaPioDsY7msMCd!3lPCNDx2+9rXP}7%SoWv1d;UtOn9=-C~+j?eIk# zOBCi%&Z4yvYDr1lCVI0I6bV0|2hfeP-)_WIa_>P^QwIZsSUfiHQmnfhU8uHrQ+k)8 z+t+?BdoP_mbcnb>f(zO@Vk$Usp@g~Cf5v1!SdRqOjq#mbT*9<_;8^><`%W#=jd>Kq zg+S#*QrcQ&X&z2uHoq~Q`AC%4?}op33h4zyYYCGyDEpD!Qjl#g*Al)b98A(xSZUaT zd5cC83p1!5g8w9EtS%yj7|j*Kp<`p_yl#_gEAQ3{kJWOoQpDNaD+qtdOPh9{_VIxt zS_E9K+M2npC?(SXH2mP7sy8bIL9C0w8k`C8Mk~s1gmG zQ6B4|#s+;Ec(LQ3%lqAKLLr@a=T1Ge^zge-y@`z`WO+r!kPIhRgC`q-=AVKl9?ezw z6{JWK+c>zWmCEuutmr-v;wo$Mkm*nv79PksZ#z1o%k-5lri+S+HIJ{4UV|3^?Tp%! zSD2$~Z95VpW*uPoI4ZqYq5xq&goj{!~}l za3T;VC%Q$nLz6-wtQVLE1$9F)=Ax;D6WUW}zXZ`tuS?w0&$_xOlbc^QN33Vrtl?B# z>;>KxI$sz;N!skdR#;U3(+bZQT#6C*bHwWmB2l7YmLy2oac799ARd~HyABa!-5p>;0UXOWOSSyy`gMwvc2F@X;5Dd<}` z$lz(h!QbSs5xuHXlf?0s8}tZ<-cjH(*c$Q9otYhk$)YcgY~X5Z9$$~obf*LNR!lKs z-Ljfr^q6qthbw9@)2}?lYC=vtu8hq2`JX=p)lYi@Lj~^_4-#A?7^*fPqFk1V#%~Q} z11zYCe8upH@NbW321WjJ0s%6K15XT>bPj9^^dyZY#C+$$gHM=9$$HT0^eUn~@Xp%y z^{sSQk|eoRU=Lnyf+AE_771=;n5Y5l&5O72 zzJ(0T&+EOnJ6G#m&nL;Ot<{>Pp>Hm+cnsHY_n%2}-u-ue4@BqH_ZWz{sFs{FLjv{4 zod?0TdKIiMt;H^dMolm_kn&hyQ?qb0J>)goD?9;WU;`pFK|v8ZM1NGTz16j1`0V0) zhBq+Bc!IfP7^6TcZ6S^2FvQEzce~MD!j=Pf#Qs-ivUxt?)UB;U9jUbWIlSt1{AE6? z^LTIB6+pVwFPr!gq$hK=cOrj@@Luk54%!k_ZoT!%zuN$l5&gh*W%EMN6&f%_z+)z# z+5k%scY@slN#LeUH?glG0DaFr%ljKz!!TD>eX@ZpE-Co{y55F^&94TBhssV|?alKW zZs{MZa>;P}Lo{}OzEcY@-S5NH1-OL_#8#qguNBVx{OEh~(H( zIUib8FiLG6PKfp6&+Y&C;>C;NoXvs6loOUtNdM&SVOvdXvKxZ%J0L*uM#rHL@J2c- zQ{0BZPeOCD!H82lex@sB@j4Zh2%ufcCEvCF`1rNyyhi$s8q1xs7=F}YDm+Wzqae|r`mHr}H zY}c;M)=#P)h~OBd|G*QOMiMn3^!y-7nO{X#1n>_RK1PZ==c(m=;}e;dsctkHMp zzSiG?x6QkTHGH0swUuAsV|;_J$5?tx_p_%@Q3AKb>HF0LW7db+0Z2 z0_QZ#h7DYTHgLY6U8$KKeJbiU2ikoeG<*MokQhx3O7Zso)!fG!nMS zlQuO6#FEG_%CHB)=8My;_%d8MO*N}Q5k>{QPfrgE8MXGa&zISSCq`*HOR)as+1Q$z(FR;!cL|TP#2jQmWceuj%B)eSHQ0aVi6Plc-=1qjUQDF);7fVZ})R!#-}FI|vl*RA2YS z@%{0HJ*Tu0}dIRF%}! zUKf0|TV@Y^XS zTdOE4!j|v?2x{7aHWfMeerMf0jnTGO>meX3#`hBRDunG6Fzz?oiqZkA zocvg48eU*p$~(N6yaZaYlQfO@#)D{7bq_35lq}rCaqH>nQJNwF)MAx;)TBQEZq=xz z$Br>Z!lA1tRVhC+v&mB+^>;Z=I&=io%PNpglM1w%8moA&EmX?Q#h<-4^Bbd0gjTW9 zgm}VJ#-~=vW4EINHhP6jxfv{1SLLslW7*ojBy>(Ay4w3x!xl(7CZ8il}K7~u>6S=88?qrk$UsGW0Te_+p2Zpz(CY-$#iz7&{vdUz4H%K zsYE$j4lMy{z^9|$8a(m{}9P0FzW5$L&gNG*FPr%wNG? z^WpU>X!KULGD2Q*Jj^KVhrH>872$c_Mbb%uPD$Iz@WWeJ_Mrs+HWx*`%qd8`JVG~V z4ZmTcQl$}?|KLF*1BWe9x5<9qNFpua*HNOS=ihklzkg5|8ugfCo>P9m;h<`TNzWc; z(wp|Gm?kHwAPYIGI{9oeGc#-K<$N=+8RuvOACQn{4I1Gwy(yIV_4>7p1?t>#XUmde94O6XiQN?T%0ObS+yKx zTw}Xn*D~sQ_}IO1*o#6DT{MzbV(v>79RznnSlh@0?*Hb6o+PG5e{4>8ZFgFhPDO0g z8bZ}#WtFe5zv1ttd`_kjf;L3RPC^g5e_|R%@$MS{7bxwjk zRiZSS=(Gt7WT0yat4OQJHv0{KYMm~#Slwp-!Z(zunVJ2f>}fN{p}Io7LG2%NUf_3< zsF^-%+}`|w3s@bEU&u+NSU($WVb9~w+`fDovqPXhL>+34Gg(4Gfn&SzHGNU3mQ@6! z!&kHVH~ zjL9w>LG9N$VvtLiGG;~#(ySBQuYIuy#;rJF_Jr7P-y?H8*l28HoeE*?9-MfPDfTI#$9a`SbUp&KBgZP6AqU9^x)B&AIh93NYk_m#P^~^lI<}OkYy2%sGe0(17pV-~I zf)O{Zx-G>N-c-=!P`+jn3hb)_>fA;lmPtyRuV4>B!FS(>nIYVc=RHcW<}Nc#ys%qH zwOIAsdP_}-j{YCBtits{+N5iuyn@`TSPS(~ig502S~-t-ByJZG!yq7W+Y$Q#!6e7t ziA-A(PH0e{9RRhv@TCip?)H(kucv%R0y8|yi;BAn=NzNa<==w0{>it%2-CqhZ^;8O z1Ey|Vn>OuB0C`1ra@U-~v^f?2`Jla5XJm{K zjKkwduOapaoVb($x{1rM!Gk?0@a%9Q=-8l(LC&|VdZK{AHU3TpIfZZ=Ln#BY4a2d+q1>%XqT9BWNHLjx&O4Ng z+>YRC6xFkT6=Fe`GLqawI>`PYE6V{JID729ygUYBPImSMd?erieTv2g&)?`NDDE|5 zPjSND4O$1fPguI4p~D^tIzRXn7##WNgc>Lt$@MH^GKZrfw<+Wz;qS*vZpMU&vaq;# zZai`mM2gyjp;ixT;-obYFdUB2#S&yMM4v6N`@-!k@nD5;8=MNNE5I${)6yPeeh2|e zSNiS{NaKFGpPT)C+lR=*ta5ptic?FN@;yjXhc%v@GIKU<60x4DGH@@$62a8N$I!LH zDt-Z+8tYQ)vuVh<4uLH#tleU_kpBey-M%gJrTY(bcd4-HyMBFLXHH#RFy^nQ>ca%D z(Y_uUN={3QvwLy&0!aJhOUmPXY-H^cWY#whD`KEd(j^E1oQEID6wtXhW`Z(= zvo)YCn03R!4wLJ)f4o$AS=nNY3!o2Jef!toB4X*CE%xsSYIeDMri(=2qIlTI^SnV3 zF<=K^yJyMl;yu+gkcJOv{CRcQ}9#LAOn5=?=&8X|W_Ndwl=V_eL-v6d6A zQTF=bP8RJjr2|g^|^xAQLsFv&V|c$ectRB^cc~ zZu?&$V;6DNk2sF9w6rnUWXMgwv}b>|3sXD1P(jnrxYA}a+6gmX7YAuxMEBtLpig-hG%Jk&vTORTsTi=X ze|K*G`|+fR1XCzorNzaC2>Z$2z`ulfsMmTI!Oq;kGg zh7GbctW^&E9;PM5J1tSFMFyEr;wcG2l=iDz*0BH3C_rLChynl17EP)&zbuwwYMTFM zv{z3L)a`P~jE}O~9E9uxkAg|y<+ZUmMIL5t71*=-WAMFH1u9psfKUPGX|_eX1Luhp z>I}plgK7dGGfF`xxr$l%o~bxb6g4jeWGdjtm~rjkG*4BksNeC^yl@^oc{J*qZud0p z=1v|*FNS}&7Q0^3^&?pfCnTZ4_4K5b$OWgrtVfH9cZ=M1#GQ4~YAPCIL7yKkcLVhX z1%lq)gZTppD&(DDXc~nr-xeqfpnpZvX(I!h4fGNqw=hOFzgScT>rPFLSNPsxN${$` ztibduH9g&1mIp&K)LTH9rRluDuh9xlo>Wz>hnp%IaE630! zBLMK7u~h#vx)ZWI@biZ^5MD*JpnDG<280f+hHNwsYamU94P)_nZYc{mpK^kT`b}c& zbW7h`izu#VUx|YL-aXOVN|PYmE&g24lin0&`DRSmxF0PLbpNpVrQLrUI4?vv46X0% z-unDq@|86Tbtha5?HZ|)4LdhHk(cW5ha(AUc+ALlKpt6TYh5ZkOI0G);p<*O2zsH) z#9P~mQUP(zRCIQc5qbhv@Vf(b1f(83%rc1WFm2c$-|893I<;Z5vMMvSM2xQfY^4P3 zx+D8kL5DS_&Q=du1_J8JT3Uh@&h@q-NJE9mx1af*E9Fcum`jCL%rLw8kRH2e&o_&y z4YUbJHo*~$Bgj54Ui2*dF550`;*gdbM*>!ph*22wx_s!+DLk=j{9rGcJus9FW}GUf6%Ni2Z9G{x2aD|P!+F2*z%X)p$1Po7()%b10S~w8e+}w;! zUfQbM758dq{r4Au4Gwl1(p)1z>c~nb^Ik0L16sdZU%$pCUt#6deqaio%JC~ zyjdQovLAat zi*_Cx1d@1sE;T#v{NAXzt}?4>+Kc~$=kJu8H|f8X*o(poR1mr#T;fZs>cA!8`%6|U z&$?*!huExh)^Fl|a6@O=-aN`a849`3?&o&0&^-Yo1!geuTJ$8XyRZ-|8RRWglB#jX zpZJbo8heFq%l!%=Hlz_AmV`ge-t|q3b!K^=0WqMufbBLTIMGOKl+LgC>}}mR+53qn zHF4ze`C8NSM1;uLy{IG&E|;OuQz1}^olT>cE7leHjnW~qRYj3+c0NqZ|IC>X`1lu3 zB_^1<%ux@i{%Gz7MUsx$SvVepTzD?*R^3cuI{qj?LS3DY*cgNkC^>1YzRQeClWZYg-VpaLxy_+dn{O^_apNjZwuw}06j8M|C@s>pUl{e zz%=wgR9iScc!Qm{*O|`!X;2%%{;mu7$ga3C>x<^&F<*=xU|k~NzW!pCo%WNY-dp7H z)M8QBHC8eVMt&$Gq~~K!sal{Tib1$7a8W}|j-`trg$9(&ANzBz^2kj96$Hsom_(y| z4T7B|(ik0;E_=^63`U^ssosef4QAQlhc3;W_3U>VlnvBEp$ud`K4n281nVEjZV59! zmA)R}CdQ|0JW-%%W8&L@7!6Q*SHhLJz)Qu#u}JH%FO@XxCIFXYb#$vA|$lpF6f$x>h}h&u78clGD^lTA>Qu`3!9NzSR7hxzf=RZ zuVFUIDFoEf?gj-L9tG=|iz6cT0Mc@ElXy}D86$?qut&Y*NSn-ie^KK=Jgt#Ti|jm? zGIza%raPU}{z^JnNfLP|>gt3U$rgkdBQn?`m~^Qr7&RbHC-4C5P%SD|D9P&T3tf)z zZQovuh!ubTb@h+p9XC}{+em|1gSd=&9mralDpihC)s3ni{pI$8_4Bdn~fJUlTHCbNa?r7|v@yWd5f{kx&|+KhOBCdmsgfu7!aL3?aLp5I45 z@plv|4HZI?W z#qZmz6kmvGvx4m_b`(%zVmJX1RI-&K^rGpbLT)rO?+f>{7SdW;T1mrSyQx%qP;JV{ zu!x8WQ4mN4`MQoySZ{RQ6Z$KHqutXBnb~L#aVTOUl<^2r zFV_xUd$muyIKq3^auQV^-rw;gX=}=GMCI7~_diE#zglh*YJq=+60z8!5+^DY07=oA&PD&c|M%m}C?TiK{DvC* z(VtzQkcw}G6+bf%;_YipHlWdiZ$@#IQ(etduvhlh#jgS%I7ig)N6Jth zjE%BA@}(AtvRVj;7bOxa*7bXIGQu9FSNWw#o(6gVJVY1)x5zfWO1Yw}!$MQh;hX2v}=b-D#$jG1@Fx``1=$@aKWY%Zm0HaqFuupLIVMMr5U>B{7 zcQ2Ex^wLG}2tyut$2G<>J2A>SR}~vnaTT%&55_!);r`(VsO76hgJwar z)kKz9&V|DC`-N$TeI$e^Ajn{RK#0;YHo(M~pdK)NoAy(?ou#5#;>|%PL%eRur>S}Y z63DtAWThTQIt8Xo0Q4|xoT}tckd`pS7;64xrp>wZeC3Go=Y9vu+!&R z>0?bap zNhXpd1)QwkOM~@%B?ae;Wp`(1%V;NfS&{R=Y;+{ycf_-0%WfEXKAF5yO9V>uiu`Au zThN0S0(qs#DdC{SRL)0ZeChVS_oP}+#m%VL8Joo@ranHhq9z^AKE*-FtswBvq@80) z7_D&rMXeB5r0;ov#qs2650cUxz!6wY#EbMLDP6yP`?9xF8sUci7rZxwfbH<|U;=6} zR(jCd`W)P#?wHUki=o3naZfAma_N)#TOpOm`Mm&s2v}Xx9YhHt<|X*|0$yNj6__kG zI`AJp!QU|lrU3p9LEJ+amJ0W{#^{PHpKXEtEQZn0hTSu#GtdRu+{lQzD-Qh(LjMv_ z>ebeQQUQ#%`{~oOIJ~eFiqEvy%acS9Y64IkDCf z4vM-JupofSp=j8?_aLMYljG8&5B848jU1KVU%jz-AZvPoPT(*|DcI-ml$L2Ioc@mh4$vZmSwJ=>cHPpq zy;9e@ZRzx%a)17#R#q1eHIS2pJ{-%}x_)LO>a~wH{y*evwjY`uG$lIvCX%lksZdGruvAgP+xs4IEn~<`U-XHG1G} zhqQraq3fcr8$v<~%4so$f$FXp&0%_gJ}KMGe!A|I z_M0&>HgYueC$>%?Jv_H%C?3de20)Tx zwEjyTtUzpSZOJQ@HI(GP>6MoHV&P?S-u@c?1-wGnFJOGpnpnBn^e<_}t&`^V9PuJYM(fe%*H^*XO#<>pb7@ z<9&>QCsQXfJbI3vF{JUb+AG9YmU`WZbA|P#XAm)|kJgc-Bq1TC`XgxwfpvRfY3Z$8 zv1>wsC253QQa;0x`kfA1YW^jc25IAII`_o4R<)9y_*c5l<-|!PdCo}@SeKOYm+BD` zqd_fL5w?lmCHN9_Ur{H;OQ(1Ape^(W9iN zre|l_0bwt!h5d5V*VhLOh+{Y>97+oe`1mxgD5cR44h&!tY$AOJM!ez8a1rL<BH&HWmuK^aQ_W-HVlA*IgNg^x2F}=|G zK;+eE1K#~^M}W6reOb9La_4Un~6Q`m4*^QC)At||du+*BkVV`$B zHdfhxo{4u0a}ZEmfwr%lyeWiS71*VQYC*P$*2yhu6CHjT_o~i{psxjU+;!*QA>mZO zj2nlcSd%xd5e_E($zS}#o2v!`n%F~R1`8pZR9~y4hS|sWtcW(MD<{Q6jh-UFmZ5E+ zr>D1TB@Pm2;im854 z;^F$XXKI6EM0b_!(GTq9l6JK#Xg(rhG}Gq2jpW>4?`difc;4qd^#{B$yClnq?@(PL zP3RY*+$XlT4)n0NvT2Ii9*ZY+5`0c5DY)mm++%w4bFp>hN$g_SdERs55!N%;P}qJW zZzgRB;Uk;M)eq}mTnDTvW+F!7<6B)rQ8|1-Dk}P@zWOISpW3L)CYC2`ZH<^HYHzYo zkTO@Re2BmNIh3#10| zuREswTMp}13kh|U?y-?$c#vAeyLL_KagkXg9ew>@iwgWOgk3p5dqTL?uh!|zkd-XX=z~*nkEUfMnPeqySuc! z+z&h8%HH%NZnB(^rhXe}r``VdL5@*qn4v2p@sS<1W1dOQEc@wQ^J-@UmHl%aDFrF+ z9(W+!l#wX1|02oe#!ob~G7&*~1g$pehLengeedq6mK*#*B@WLfVHTRQTs%cABFxsY zqEUnai-wjo`5lt|wqXHf)d(w=UHtq$hdWEm#F`igULsEGHgd~Wv~f&fXRls~7mZ9$ z;^TZ}+ro+EGc-h?VLoW>@7zh9^j=5wg zNr0ms2}`>_ViouDn9>+4ymaX8+r}^32Q>0dG zEgJgd1twogWbb}Z5krs;>3I}lYp2V~`a-QET}Z_wA;|AYLhif1wnO2oe-eDh;|(&? zIXk?hKXEJ6Qh{J6CY@a?kilcrcV^)4r=h_Lo{@Y~kLf>~&D6#L10|2^GtYmu-uiy$ z1zpmvrDN4cDLkZ0|E``RTZ>5CpNkKtv880`Dq!qHbnG`wgRTI~RFwK2yHPp{WyjRpHJD7u$Nf zJ^U0>3@I-uZ3p_DvIVrznbUIUuq$$E*g9`pT38i2w_5S`SZ|fzd}!97D{@P#SanHB z>*-nN9;PfYQ`NM?-F{vQ`~G&hU(9x%$YnZS$@SnHx0J+I!sPODiDePuKo>|n3>I>K zQvPdH0i^(4q&C6*6AHeW8BhYTfh+_1ox0(+pUpWUAg?Efl*I{SnZN6c7hxy$%KjpR zAvj7w{zm`&4T87HYjmEl5Itq*a2ul+?3ilZ$4^)kRYbOJ{TTW&@5cGkOD^NB$XT`; zO-5OU%b&vnkYX~TTVYl&x9RM0&s+SC~{|3m0 zLc=i@hFz#RLPj#5r;AF$xvdkk3<2I&FGctVIb$uMrbmy2?jhT{79@HgwXjnabb9Em zN+SsO{l-Q_{qcL&JQ!HMaijgaU>bo^i>YYwhr0HsF1N}(Tz{9;bbbf@`f7iPKi|Xm z>_*(KE%7%^u=p0@jBdDemY?*_<++ic{@I(#XxxQ_J_GkmJ~-&PZ>CG_Nc(ggjr{Y@ z_HX6Spd@+qN(t*4#NWBu*(^XFxIwt3(7y(+Op+#WR1OoT&KM~D!tt8G!`WfLpT5S_ z*6w%ZPl&k5?i*rkD5GznZ@PWkMV1p8%kKcu!EYB8Dbf_c9xbV^wk+0(lK`7p5>ES60yW8CD~S4x?fgy>g#K#IjOYSDYdc# z{M(ZjO`ViaTO+h9p@z*GJOz|}0r$M6Rs8`JWYXoFoWbx!FgI<_L?kuCdIRVfMFTML zwwoCBeDpCTk;56QCgw1iVn!F}y-7x&9ap8qr9(BFYuKmrM=0kbu$*AOL}Vc#9*Q05 zxNxW)=WjN!oh0obo;A9{BPc82|(CXQRGU!Vf6W-h41}xmmgK$ zoXZ8-E{YGnt8dYWPJyluXUAOZcm3zE{E;RmOFh(cSddbE=&#R2BmKKLy4EsD9$sEp zNWd$--5>;HCgj+HfGyz4?Q(S&r)u&XriI;zNQ75jMH1PICdP)(A~ z4nWcpo-qWC7M`?KD!lISg!xqb#h9%FLIEnhH-(JO&kqDHb%kuT=gF^Vqty6+n(n2n z_Iau;pr*$AfmUf7cfI|qm$4Kqj3ajBBGE}+zQA0ft{M)|*Eeox7&}@GJCH7%di0bE z=7=U#)Gb3uOW{HuvD#oEZRlSY7A z$TJ+;xtMqm8&2RqRW?#Woy0UnXh=W`R-&QKulxDTfWkw>KQ@Oe+e;5Py$e_BjMd#E zY>m5by+)ITFB(B&Zv}xk)Qms@F}i^J6Gq?0IuQj3O^VM4# zpTjK#IRI_Xghp2u1Wc#7u5Or~9)>@mEvAd}*AC;2*U}<2wmx#m;N#M%8B%=g=5C6w z2r)wq0-KcgYWLF;YMv(R3z^Y!JYb>F5|fgOlv9*$4AZtDO&-K0VZTtQxlec-1yC(i z6UHhqwb<)6?jwV>dQb-D0&bPhFcYh;{@^8NyWXH1n%v*wup_4kHgl-%Ekk|^YiNu^ zLBJ4=-UJSyyzn|JE>2)-k)`YKLlJH+Zqde~;W_7pSp;_f2&R}kcs)e6n|i(Ghf8S6 zb{cJqfs($I^t+$IU}^tucaihEDN4~bvTM@2yr5=UnVw1qpA}}ONatd;z=YV?IPtaD z?udgC!MRuYE9%Q7F)1TDCg|OB;;bAVL$wG`{fCUOfB?ykEc~1S zPk2KR*>CBuJld*X>G6E(3Kj=MoBWfpw+A=;WbN4;;YBzgaVJKpoD5rg1;>)CYm}@e zEed-x#NPs*e~45!!yzcu#w?vkdi5z*rbESxA`2<9xZ%ZavmcPxks=y+A_Ii&s9}e8 zRT1JnBKr(;T*swJB{Y`7>jNi}OWWFK=qYmEdmLw?FljmZ&7^3jo=D4f7bLV|c2mL2q=`1ijK%`IjszG7xsoVk2h&)9}0O;^5 zFi%ceI;rlcD~!89MbbR6q_aiB-~{qFRFwae1h@ z1Q_19R6x3ZpJDrR;PZnpjKw5(K0m$LQQy$ev#45w#^$C3$}=S;tECw<k%lC@&Bsw8+)Cqa|tp7MZSl+!! z{#?I+{+`70^vcPk8Qhx76u&rC{O2(g494jqemvidaUwte1Cj4ge<4xjh1p%t3wpOb zf=g=Kw72$Bjd7r{6quoVWo7c~+Svp)+9232;tOeUK1-qx9@Zx&>8^V1)(H8k-+G}L z^B2JzGD>1@Zf@Vz7`YzXn(!DiA&478Q~z<)KA>+`-YvU(Q}nIK8px3X5u}F-Ab)=v zxohD#YN{72EwueqM+kpLC$?uDmt8AgUzuzog=siXI8sqNI&4$a=ORTC8%r2IS35uihK7KRaNyn$`0Mv zoFXQzwzOyuao{nGj;g{~>*%Ry-Q3u?xUfH=bAPaMT~D8e9Qrf1^p($-f6VU#WyCu? z)omx)k4u|4O$9>UJIW#$zyiLD!2KN&x9UoaVb&(3B^N(Y1!<5-X=DoCLNx^*!01A> zTV$wz;#6)HG{@YTB-ANFYx zXA(?}wT0KAH(lW-InVNOU#@{}8%uRj;&EFe;m!)+T+s~tVc!o#Fd?uBP&ZHjm7BrQ zRvWSe9^OuH{9u7pAmS*`?K@VB93^l1lZ5HJW(R~DPCA&Gl?9`W#c5A8iDR(3wtdN6b2QaO81^1rIZ@jK5vb3f#}==W~z+vxl21D!GBrj(9+dG(KK!4!O$qx?lEP#3PgmVXCtoDSC0rlz zcwO(K$|}wl2d-@3<;#8TTM*wu%AM~)Hu>jxTUP3$3R8?EGU`}RAiYxmdeGslhZ4;% z-|e5is0APKa9~J`Luu!)gGHrHPHNo_bypT%z&Lj*;Gju1u6=lGX2kz}bN=pEZRl8& zw;nb&!tn}Hm02{c$w*%QgSVwQ^+c}DK{}}UcP(?BtZ{QR77%onbDc@-O*-#;d|5C8&hTJEu8dEu zG-8Ad)2C|hQTJjE%6zSrqY>q2(X69R9k2C6!aZt`*h6w)NZ=cE6{w%t@iQ^W!Z#$! zscFv!o;)@)Fgutm`k{q@=Ppj}XGf$PpBxcc9Il*0f1dN;GDg(Mio+x)dv!-7F#r~n zzp`#$+e7M7Qr>V8Xp`Fk7m4xU?W<+Y!y}#xw)t!Ph3N2JHa&e<6n!e_0fi`EHi%==V~*8r*ctbO8X zZ4X~P&Zu;SGwfcA%-Z;EyeF`CHal^mL-q&+6hpUocP!)5hnAmRTKX=vgDsu1RpbK9l>RrtfOniPIwsuBKWw6_1SngoMT}~LRDb* z)YBp$K;iS2a6|&cqZkCfVEFYMQ{WI5T6_JX0UR;xnd4KhZTXP3gChs!n+8a*06puj zg82aZHs^rneY+c3W7-=r4#z&JukFDmNC*RoZe^-fU0sb&SroxEO43NSS3FE<#Gr2z z90+z$^_N?;&a#}aL8`AO8bv|X91)1O2!%CHQLWQ%QcWk^88u-(0na25_Ed%`B_lkS(uK=bJ1oSjL3U^9FeiPD#Q(dRljnm~Qe@^j~1b6z4 zK?edprNEvV0RjdD|G>w1YE!Z-8*!e+h~+811@Z}*J$pW3d1JB5cUzjrfmn$#T)!tG zXF~{cDD=J!m48D2nXGm1fToEN3)MlE-$(@hWOGph>00#cW1R4AOhU1mJgi>$T&hF?96^UuZ<|1Za&< z9AGHFz8I0LYt)Uz7tEvh4X|Tk5WBM>9yPzHkkG+1yEJ}wu=X(D=`jKD0|)-K?`s7m zaY1Swr2fCroD#ifycBgV^g`f-h%2ml7Na9FR&DOPX*|_S+W&H zEQ+dLX0;5a2sp?%WtD%+F%Uj0+V2*uxs4-PRd^>{%VC^*H$mxw3U5JGjT9hXfSAI< zkx#rq8Uy4_l*eNU>c4ev;CuTL;1iJ~68v{`LK^1(Py&tA`t6~!DXS0HnML@JfE0W2 zNq`KXmU@8ilq=VP{TcoBB+159?zwv2&U4qleC@se!~ARyxVP8Iya}feII&+2N_@03IKbj*cC-C4a+DDZz4lfaunT_7fl-U27Bn zHQK~r$xh7nkEF}x;dqwp49jSGv9(@`vEd}XSLI$(@aJ2_B`+Mvp$9VfE%?cd41*5;xhZVa_-B@< zpr&|m5n!yt<>jYkWwIQFdjQtKWBg#EAzPHwrC(q9n3&!dUqCx7c3Ydk4C*iIc%Bi5 zY!KlIUzZ8t#%mWin(V$IBqS8FOW}DJ1S{Z{>;gSl?Q10Mh7N28ps*VUmlvzkmhx!0 z0YjZ0^|`7HL^t4+@rXE!1_oOWk(eDG`xtUtH`$fYXw?zyMi_bqetJnC*ba^Y?kH&c zJ5SJ*lS$OU@v@~=Tue*}L$io|moHyNRLP2Q|1P+a5*y#5fik0KxB-aZu+oE;lIUJ> z`{jSvih;pyp@}HZJ!rqJ8n668(87+Vj9#U#^4!1^E1|+8#t{a=LSvxRvP|+PTzpYQ2Te%1l zS&FUFcpe^p;))drm!NEcX@X!rocS2FOk{4)qU&=Px4a~?Vg!h$vjP-GTr=f5|G6NT zX^RRAH$Q-HK(h&{ij-JQgyE=J;I~52(Og2<${MC;Gcv)iySnmrX%|g$q`DGmoJe-S z8YwXBtSHvbfq9VN=Qqxh=|52m<3)dTBKz$8!L-gS&zs(vxI?7@$MAZ0#=8B~O z>L@3LJW-4Vg;!#eX7L+xg9PYyQ0A6e7HOR#t{2qFaLli{*6`GXsPV!#YoZ&5&n9|1 zE$KVak&#-XcUg+>&wrQL;^^>$7!B$R(%1YI`+T8S#2H0YL}GOkyKzs?DjV;BfOUK~ zk*tn)Eg1P^*hQY9ksyk|X5Vt{J)n(Rr$x+;c%a$VJi%HPDub>LjmQL+H;6I*HU|nY zd^a|}3d#XmJf}zC<@=0OiNjD7l!20>V5<+b@Zq!t!5bzdKfnTN&hT`1hnDgMs&Q(1 zZkM9<;X2M|8eprw&47&-I>SD^jc0FCcO+v`y#TKgKzhJ>0PGq4E5X-;IJgUxIasum zP0>w{z;R5(By(Qn^;@e{V^h=UiS&+EdE)y4MuyuxByOWhCyqp?n`S8k(g3kJ8dcK! zJXqhC@m8`EtgNfhh?xDQXG?&J1xLUl(L^MW0V+*MJK-KMp1`&L+SZ@b+OME}Y>SeR zoed2(q_!CW5`=erb8{2Jq7FZlo=}eHo0@7~n^xk?AqZkDfffP-pEY;}%0cLPc28KM zB_kuFl1}b7K8^kbfAlZlFlRe)C$E|?MZo8W=tP@z-vrDdrSF6=GjNUU?df-1L4yOp zFQa#e*o#wBQ2d)>3?={+!4&`2$6CKr?QuInk0)Be5BxKk6Eia4R zBo_HN$>{5-R(+0iUUUO%&&p~DzX$4(qYQV}KluJQ1WrYwptEG6_@dM$w*=G#E{I6k z8-hvzewMFhzN2WuPBV=IT|Ul`DFTQ(beQ2s$$yMvF)?t-eTHwqz~cJT-S{SSC<#PR z9)=VsN$8P#HYF*q*~QbnMka&yllTojiJxk*vV*rbV4j}X6`Haz z^C9j;fveNR$LQ*c#5H7=4tQesg+g8((TmQnC#XCMni{Es24maZ$?n&bI+>RaBK}8H zL17&Y>~r{q`kTPBf4~#=nTo+%|FD|SF)}hBxesjI`*GfQGoA&64mb;E=0w9~j)yB~sEB0GlA3Y+ zrQPbkcm+R$<37niuaS6`79|2vN}?I8F=RN^;7>T5CvGmW^Z6;4lM~k&kqmRq@L_GcV?(et!QI125or%XkXULS=c65mloNm3AAG*IE9H%B z!lB)(-@kvs!tgq+u(!Or6akIT$nsaira?Id-jZdfmq5Bw} z-{tIkunL7SAGFNq6x*g1>Ga@Qc~4}%BGM@h@*r?Yz~;K%cGIHSMb03v7o?m^K|#aM zE-IadA{yC_bN4Pv!?%WOgt)+F`2fw>hoD0Z9wBZa6!uL*~^TytTi7Z)6`DnCqBpAn;;*d>lSda65tji0iIW z<+1ngCt>S3cD}p)<$e!v^qBM^mQ+lPItCLSnl z*0YnP@PV^gZ+^VS@vhXm^5uuWld)=EoJK^mMjM+Qi-bh!!Y5DQm$Zyu0bOa&#vuv{ zDr`wWn^1squ(QX>K#>InUAf;U-557&N8^X%XT!u&LX>{P)8>Kj!G&3DH76`BQ5Oe7 zWfhU!-*2AkYKlSM_SV&+aCAWM3HpQPB))UE99?eTf(HCJuopeEgVXwX%nJd6NZMA@ zjs1c~9hg17Kz4yUPLNTLQo+VPn8QZ7DowLU+Y_b*BwM+Sb+OT7i-K+PwD3ITG;@i$ z#P(e9)@eLV@JI5J>ZbV=il$6Y!$ZOzG*p-(Gi@y-NNIrB03q*C-4`nbk`Y~D_3>lU z{2qE{9>LsYlB#$;iM(vc;zps-IU<6znlh~p*|*qELH5|eLi2keH0G^r!iyK%n5J({ z$IGDPW-26F7ucKYj1NuL6Ta*t%BK;+$*vGn;|q4?WVu@b`_r zbpK8;3Fq!sd^*!5QrY7IS`J$7?TvS;-*C$wJbsM+NXyeOYtHF(_{keqbfM@F;G_En zIFV|Q^|uRmkK`jL=qfyraV*N4dR@4n_}Pg}DG4>uUvwytxnO173lz_cfL%55xYf-i zu1zf0Bj-I>w5x2E(LA?x9N)JW6c^0GiKZV?hu@X+`p#xZJR3e|vzO<^RnDl;hG|r` ztzBKq2zGtd-OaI>hf^2JGb%jij`nUJZK4k}dP!uQ3Q2eSOjw88xQzVBN8_B4F^U!& z^B1i-(&Z{h5ikvHyQx1NJ4Z@=F3Jf$2QkchwpOnPAG~y$X)K336EO1C#CuL$S;74$ zgjrr=wRp&LgLn-^ljBK@k3RfYJNbWpGjmQ;^TovG*Nd25Q9PxdEl4^O8%=|e!<6Rs znkMk3d!RfA{caL~GYgJZCkMU_O)ti<@0mAmR)CFDTdSlx_vjYXk9shh1oH9iV?Fgw zb^@?qK|G(D+$vUWwjGDDDWRdqi!FHj9@F&9%uXJjMSTy`C?^y`VmI^ww3NZ_A~2<_ z?JVyJd;5lfMN7=rbYMCdK68{Q0xdxU>^ISTtp8dQO-($r>T&PqqimBN$xf3DMS6(kEZVmX^O_xF5)-dN_z05D8P78Xg%6$`prnS*8!d*UnAkjOcSIzG z2;G5N(@_@gPB88n+E-Z-rIS`;CkzWSvmNkA%W#wztRjUY~f*q9ils~hsIvSn#;jL5J6 z`CuFDFEVoSW_9-2=zMm&)y7MZpa4W!s>-pg@xc%;Ga~nf&q_e~o|M{bngNa&Va9^< zX+isc(}zYgZ;q`~iVo)JNGyAAhH*uevmTfn8-OwuXA-gypnLlDZQ?#erNqW`_l=n2 zjYpp;gNTmMF}w=6N}tCMVzqPh4GgTY3^3J$mizKuf4+W}OKFHDMYW149m&iv{w{&A za*MC=R)^}~Ikvq~y1fBt1n6oisHozRF^A>Y9pJ*@4oC@0YnSQ;$u3!;@e~U&HejLv zOdP7*nIjnA*EK#D&S}6A`s)`X1A|Y_JM_{G=&V3-2IRL(EwBu{VgD1{;?%0 zwvNu|rW;@Mms`nspy|(`kU`CiGd4^hq>;2nS2Y+lC8{?R)SOYM>%lTcm*X**;Z7-V z*b)#XHcTAN7$u+zWB#3a;api~6PwhXP8hnO+d?r-T-~A@Gre=X0GUkb3{SPt$=rIK zRS@vw+e9$BDpcKhKBM4I;EUm%GFUdCNkbD2O8~f5fhWK=#uq?{uA%#)V%~y0%U7tP zQA=>E=ag_8*pyv304tm zwm9P6g!Y#>I02{ySs~#ME!R;&s_(VqKC+zX>#%xOdW~cdmfG#f?EkNKSsMar+)uo{ zID6qEcwueUILgWKG6`EC6EGrtMxulk^(x}bz3h8|-{HT>$;dPyl}AAe5$OH7AlVI8 z?WUH)mo?0J192|EGm3xb&PZ<6Ugl5(1EvVTrW8~ZS*jP4oH$2a-U=vw8+mG!V-|8T zW3w+zlUDFyZEaxJ7X{gY1BWsA2@ea4$up!(>%OZ}o1d@ys1H>dU;nLCb1M*Aa91X0 z2K~A=myM#$M^t&s+$tqU@$*oWA{0&<2^$9v{6hW1^pM&Jkt-O)!oS`aoY2I?$RjMW zsZHkx-X!v-Vz}`e<(P4nnlf-c7$m`e-A)XZ0+2Mk`gmb9Fz14vYA-I5e0BgT~?b_#j=)as+l8<$F zDz4M)E#t%$COM@6oCY4yQbBLa%G(iu4i#2ku*f8?QNu#dDyO79cRE2+!iYf$Hby}4 z;9s|hA{JBmi0$ucsJee4QU$v*aAg?8pe;y$D{?oM?5XGl}x(=8z z9yz>aFgV->HsqM3kkRG&bOTo8U@v244#ha~RwAY3JQoC>IH^Y#lwdExK1o#GyBK0} zhZ8Hx35KRa_I(=uGSXmKL%pJ@xvSbBh!_>Ngz*;7xb9-JT2=>h+ak{OC+H=>*&-S# zptA$h51ja&I}W3-Loh0U5j21(tsrRdQrb_qMg%nBQ?q%W$; zmg3{q7@+_GVH4_^3>pVX0Y^C~y5K2bcLMi19_O|!J&)Q7u2@%352m^3=CbBc>WY}& z_0qg7ixev=2Hv#vbZZ9(#yB%Cui`s*IF!Dq28I*oLQI(&S5;(WzCmz<%VjOlVPg4* z3;K%(-qC?fEC>%;U*&D30+scVIG`kGtSC~gph`V<&D@+#i4Q6;kk(LfuHt=|93pG* z$GAu$MFNyJ&=%pUAI3Jh=y)mQLrVlwt^uRN#3>MWhUoqvBg5!LI_akfa0jzx80bp< zS7_7U{({{Bn+H~L2Y}|?*JsBGF&Hq(?-SgIGXfdKZLzFG!d$gC&)bOVX%E~}D1q=D z*oTUG+>5VpwVn3yIc1ZyhXZjAdB-9VfdL&nP8MuFs+gtWg#0ZDy*v>2jfPm^~*w2%{*90Ho`gK!({~tK5(c?mkZFWje zMOxO+0~*&KRhfOtmNat|NA2mfUjZ68aq^@F5GMeB7ru_AN6`)=mI4n6aCF;(AdfJ( zSm%nZT+>+Zr~BLl6oA?SyeD#zy3`|v;2q#}5{&>mP%l-pEtO$mOuuUYeh|Es%MdS_ zOV~P7KbR9u&BNgQ_BCv3mwqlS_=mUOKPVwnV!7AE7k>nFWc2NV6usRsA3slW6s^6_ zziT{u$@?k@K{`6*VkT&&k)+k~NjNjYij7nI6<#uawLm0_xbr1OMS_F|uelygEF>5e zRT+;(rjYk{mq(+#yJmld&!0;o_;0bInfK$B)9YezFi;7^t)x zgN$PjC6aYBdCHOES8~kM=;YW^-tF|wis{C`HBB-T4TGTJl|h<>sTRMyhC+^_P1s+SluwN#uOH}3Ow%R zx;hn48_M~4@=~HckccQ;+L?0uHY%%MI035fM3u&L>i3*#)8so1;tMb>^3UHWv}~@p zwK1!d^^V^p&kDTO+xC4}`f8`)O2v7h#OFKXt^EVl&JBE`)Q+)GI5&Dcw3?j5m22_K zOtts22R)lcE0X5K-|D_r0%I4cH5s_Od?pSuLDD z^{CRth`;b;>7BDTQy+5YTNHYg=s%1d{OF!B<;|ka8zfK?R?BlkH#&zr2rI zf4x&%&*rWjIljv#fTqLNXEaOKfnJ@(P`HnBV5jBra_gLdZm)Hk28WRoC9@5wbh;A7 zOr35Pmar+xDKZnVeGMMN@SiY%^}sS38t9unQu@ix@}z;!l_Fyo8yZ-I0_F zq9YTj{#n}h?Eb01{if$1DO@@} zz3eA?SIVk)Q28R2AqL}cN+>_H6mwvI`^7_=Il&c@d&i?Mo;@Bv#1Y9C$lveE_Ae>s zRde$-p0;hzk3JvH9T4&(V<|6fyvOhCdN9ZSMv9ubPnXDknH46d@s?SIb{+eM_B&;D z9KZ8=el7m+C~1_o%8V^I++>*UZx-nNgY)k$Mc1+NQ_k*RGNlfAi3KU;JiU6K`GrF$ zvtt(9=%vis<7VRoLEa8!AJtq|j-7+WPrgPp-+T8ZoBu$7sxbYzmO;yP9JE8Y=A5E^ftzMe25v|!np(=PQ-Pmov zZxm&>BxCD$+w*{6zezD@*0c>1C^cy7OgD&;JWp?t(@11CwR-` z;Az1Q4^KV&=PpY76oyZ>(-_G=J!7?;s+2fDZ?oCCaK1&R{}p9$%ZOIA=5vQK>ZdpU zGDh@;uaJDH3O}km9VgUf6YJO*8A&HeX`1QFlak%GOWS|%y{Gf{{cS=h@0?Oz+2j7? z;UNpc=#8*Aitl@)=Kfe$x}kaVMbeFYPX(v`wyvaMXf25l^YYQ2s=^CTQZh4Pq;gD& zs*&))Mbw}EVZ(fr;pJTIhXcX3ixLNmL`};2yhL5ZwV04@s$AVenKl$S{d9-1Lhl!A zWhuJQD>@Sj6dx7Vdlz(aN0%P{F=hG8v-eJ-uFPP6boH{R;>^a#UEK?b((VKBq z@7((PZZ{PR*{`1Qe%3->wX&%)SUAF<5?z~=J(jNkDqH--c!-il}G@+PB zp(1Cip%UVK)uVJkHLG@eD2Q8DxhitS&{%QB%e&K4e&8|tlff>zgzA-Y+w{ClRi0Kk zmVCe1Dr5CaOb_{-tUt82o`SR+f@^?~nABKOADlh$^5*0{_Twau?(Z4BZ-r~O98Gm4 z8ovSX#sEp5&zRt(Y$<*;ti^OuT_JupsUv!ngpOR%kN2I)`_Po14hKx!my$A97OeX1 zJjGPR57zcdRqb&P^WMWbSakNQ=duS!)@tas$qwo`Oo_g2F#iD9hD?HKG}3m)#$VRpl>n6rL{l}G!(++= zuM_ge%2gg-lD!`$O;jNc`|lrpRgwio{K3o{H};_hg*=W|EpP>R6#5rS;jtz1pbI!k zkrkv0iV(&C$b>-Oqpa{=ydk%5D&@jba8u<9Z&u`GFIt=LW7k}}$MSqS^0;;EO4VP; zt*q|m;|4`+CO>;cwd?Sp&G27dgVhj$7e2Ul!#t4BaQJO+ecQf^Y*zpOhAp!+1IN8AU7se@#svC74}OwHV(vIzwVpc zFWAk%chW;GjX4j;sMh-ax2e3(yFX z*W11CDvHreT)c=3L$D%XMpbCW((#y5eCHc9<_O5N*-EUs5XQr;29--mSAX1GaQ@k6 zF8cr7&RPurTKmOs-xx1{TmGm*v9MxgarKf_#ZmKX zPYbR5ZLZvK%edfU;&Yrl(>vD3zyBhG`3Pz^CJGP5f-22D(9EY89+qUTT**0E>btH| zOZe{PT=XHX%c=TsjJWw + + + + + +

Data & Performance Model

+

All participants use the same ERA5 weather data and the same RISE performance model. You can choose between two approaches to obtain the data.

+
+

Option A — Download ERA5 from CodaBench (Recommended)

+

The ERA5 NetCDF files are available for direct download from the Files tab of this competition. You need 2024 data plus January 2025 (late-December departures extend into January 2025):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileSize (approx.)Contents
era5_wind_atlantic_2024.nc~570 MB10 m wind (u10, v10) — Atlantic corridor
era5_waves_atlantic_2024.nc~570 MBWave height (swh) and direction (mwd) — Atlantic corridor
era5_wind_pacific_2024.nc~865 MB10 m wind (u10, v10) — Pacific corridor
era5_waves_pacific_2024.nc~865 MBWave height (swh) and direction (mwd) — Pacific corridor
era5_wind_atlantic_2025_01.nc~49 MB10 m wind — January 2025, Atlantic
era5_waves_atlantic_2025_01.nc~49 MBWaves — January 2025, Atlantic
era5_wind_pacific_2025_01.nc~74 MB10 m wind — January 2025, Pacific
era5_waves_pacific_2025_01.nc~74 MBWaves — January 2025, Pacific
+

These are the exact same files produced by the routetools downloader. The NetCDF variables are: +- Wind: u10 (eastward), v10 (northward) in m/s +- Waves: swh (significant wave height in m), mwd (mean wave direction in degrees) +- Grid: 0.25° × 0.25°, 6-hourly time steps (00:00, 06:00, 12:00, 18:00 UTC)

+

With these files in hand, implement the RISE performance model from the formulas below.

+
+

Tip: For higher temporal resolution, you can download hourly ERA5 data yourself via the CDS API (Option B) or by using the routetools downloader with --time-step 1.

+
+
+

Option B — Download ERA5 via CDS API

+

If you prefer to download the data yourself, register for a free account at CDS and install the cdsapi package:

+
pip install cdsapi
+
+ +

Download wind and waves for each corridor. You need both 2024 and 2025 (late-December departures extend into January 2025):

+
import cdsapi
+
+client = cdsapi.Client()
+
+MONTHS = [f"{m:02d}" for m in range(1, 13)]
+DAYS = [f"{d:02d}" for d in range(1, 32)]
+TIMES = ["00:00", "06:00", "12:00", "18:00"]
+
+CORRIDORS = {
+    "atlantic": [60, -80, 25, 10],     # [N, W, S, E]
+    "pacific":  [55, 120, 15, 240],    # Uses 0-360° longitude
+}
+
+WIND_VARS = ["10m_u_component_of_wind", "10m_v_component_of_wind"]
+WAVE_VARS = [
+    "significant_height_of_combined_wind_waves_and_swell",
+    "mean_wave_direction",
+]
+
+for year in ["2024", "2025"]:
+    for corridor, area in CORRIDORS.items():
+        for var_type, variables in [("wind", WIND_VARS), ("waves", WAVE_VARS)]:
+            client.retrieve(
+                "reanalysis-era5-single-levels",
+                {
+                    "product_type": "reanalysis",
+                    "variable": variables,
+                    "year": year,
+                    "month": MONTHS,
+                    "day": DAYS,
+                    "time": TIMES,
+                    "area": area,
+                    "grid": [0.25, 0.25],
+                    "data_format": "netcdf",
+                },
+                f"era5_{var_type}_{corridor}_{year}.nc",
+            )
+
+ +

The resulting NetCDF files contain variables named u10, v10 (wind) and swh, mwd (waves), on a 0.25° grid at 6-hourly intervals.

+
+

Note: To download hourly data instead, change TIMES to [f"{h:02d}:00" for h in range(24)].

+
+

RISE Performance Model — Full Specification

+

The RISE model computes instantaneous power in kW for an 88 m cargo ship given:

+
    +
  • $v$ — Ship speed through water (m/s)
  • +
  • TWS — True wind speed (m/s)
  • +
  • TWA — True wind angle (degrees, 0° = headwind)
  • +
  • SWH — Significant wave height (m)
  • +
  • MWA — Mean wave angle relative to heading (degrees)
  • +
+

Constants

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SymbolValueExact Fraction
$K_H$4.2876…969 / 226
$K_A$0.15312549 / 320
$A_W$11.1395
$K_W$0.28935…125 / 432
$K_S$0.8590312527489 / 32000
Dead zone10°
+

Power Components

+

Hull resistance:

+

$$P_{\text{hull}} = K_H \cdot v^3$$

+

Apparent wind:

+

$$u_x = \text{TWS} \cdot \cos(\text{TWA}) + v$$ +$$u_y = \text{TWS} \cdot \sin(\text{TWA})$$ +$$V_R = \sqrt{u_x^2 + u_y^2}$$

+

(TWA in radians for $\cos$/$\sin$ calls.)

+

Aerodynamic drag:

+

$$P_{\text{wind}} = K_A \cdot v \cdot (V_R \cdot u_x - v^2)$$

+

Wave added resistance:

+

$$P_{\text{wave}} = A_W \cdot \text{SWH}^2 \cdot v^{3/2} \cdot \exp!\left(-K_W \cdot |\theta_{\text{MWA}}|^3\right)$$

+

where $\theta_{\text{MWA}}$ is the mean wave angle in radians.

+

Wingsail thrust (WPS only):

+

$$\text{AWA} = \text{atan2}(|u_y|,\, u_x) \quad \text{(in degrees)}$$

+

If $\text{AWA} < 10°$, sail contribution is zero. Otherwise:

+

$$\alpha = (\text{AWA} - 10°) \cdot \pi / 180$$ +$$P_{\text{sail}} = K_S \cdot \sin(\alpha) \cdot \left(1 + \frac{3}{20}\sin^2(\alpha)\right) \cdot V_R^2 \cdot v$$

+

Total Power and Energy

+

Without wingsails:

+

$$P = \max!\left(0,\; P_{\text{hull}} + P_{\text{wind}} + P_{\text{wave}}\right)$$

+

With wingsails (WPS):

+

$$P = \max!\left(0,\; P_{\text{hull}} + P_{\text{wind}} + P_{\text{wave}} - P_{\text{sail}}\right)$$

+

Energy integration — sum over all waypoint segments:

+

$$E_{\text{MWh}} = \frac{1}{1000} \sum_{i=1}^{n} P_i \cdot \Delta t_{h,i}$$

+

where $\Delta t_{h,i}$ is the duration of segment $i$ in hours, and $P_i$ is the power in kW at the midpoint of each segment.

+

Route and Departure Definitions

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CaseSource (lat, lon)Destination (lat, lon)Passage (h)WPS
AO_WPS(43.60, −4.00)(40.53, −73.80)354Yes
AO_noWPS(43.60, −4.00)(40.53, −73.80)354No
AGC_WPS(43.60, −4.00)(40.53, −73.80)354Yes
AGC_noWPS(43.60, −4.00)(40.53, −73.80)354No
PO_WPS(34.80, 140.00)(34.40, −121.00)583Yes
PO_noWPS(34.80, 140.00)(34.40, −121.00)583No
PGC_WPS(34.80, 140.00)(34.40, −121.00)583Yes
PGC_noWPS(34.80, 140.00)(34.40, −121.00)583No
+

Departures: 366 departures — every day of 2024 at 12:00 UTC (from 2024-01-01 to 2024-12-31).

+

Great Circle cases (*GC_*): The route must follow the great circle path between source and destination. Only the speed profile along that path may vary.

+

Optimised cases (*O_*): Both route geometry and speed profile may be freely optimised.

+ + \ No newline at end of file diff --git a/codabench/pages/data.md b/codabench/pages/data.md new file mode 100644 index 00000000..339d0671 --- /dev/null +++ b/codabench/pages/data.md @@ -0,0 +1,175 @@ +# Data & Performance Model + +All participants use the **same ERA5 weather data** and the **same RISE performance model**. You can choose between two approaches to obtain the data. + +--- + +## Option A — Download ERA5 from CodaBench (Recommended) + +The ERA5 NetCDF files are available for direct download from the **Files** tab of this competition. You need 2024 data plus January 2025 (late-December departures extend into January 2025): + +| File | Size (approx.) | Contents | +| -------------------------------- | -------------- | --------------------------------------------------------- | +| `era5_wind_atlantic_2024.nc` | ~570 MB | 10 m wind (u10, v10) — Atlantic corridor | +| `era5_waves_atlantic_2024.nc` | ~570 MB | Wave height (swh) and direction (mwd) — Atlantic corridor | +| `era5_wind_pacific_2024.nc` | ~865 MB | 10 m wind (u10, v10) — Pacific corridor | +| `era5_waves_pacific_2024.nc` | ~865 MB | Wave height (swh) and direction (mwd) — Pacific corridor | +| `era5_wind_atlantic_2025_01.nc` | ~49 MB | 10 m wind — January 2025, Atlantic | +| `era5_waves_atlantic_2025_01.nc` | ~49 MB | Waves — January 2025, Atlantic | +| `era5_wind_pacific_2025_01.nc` | ~74 MB | 10 m wind — January 2025, Pacific | +| `era5_waves_pacific_2025_01.nc` | ~74 MB | Waves — January 2025, Pacific | + +These are the exact same files produced by the `routetools` downloader. The NetCDF variables are: + +- **Wind:** `u10` (eastward), `v10` (northward) in m/s +- **Waves:** `swh` (significant wave height in m), `mwd` (mean wave direction in degrees) +- **Grid:** 0.25° × 0.25°, 6-hourly time steps (00:00, 06:00, 12:00, 18:00 UTC) + +With these files in hand, implement the RISE performance model from the formulas below. + +> **Tip:** For higher temporal resolution, you can download hourly ERA5 data yourself via the CDS API (Option B) or by using the `routetools` downloader with `--time-step 1`. + +--- + +## Option B — Download ERA5 via CDS API + +If you prefer to download the data yourself, register for a free account at [CDS](https://cds.climate.copernicus.eu/) and install the `cdsapi` package: + +```bash +pip install cdsapi +``` + +Download wind and waves for each corridor. You need **both 2024 and 2025** (late-December departures extend into January 2025): + +```python +import cdsapi + +client = cdsapi.Client() + +MONTHS = [f"{m:02d}" for m in range(1, 13)] +DAYS = [f"{d:02d}" for d in range(1, 32)] +TIMES = ["00:00", "06:00", "12:00", "18:00"] + +CORRIDORS = { + "atlantic": [60, -80, 25, 10], # [N, W, S, E] + "pacific": [55, 120, 15, 240], # Uses 0-360° longitude +} + +WIND_VARS = ["10m_u_component_of_wind", "10m_v_component_of_wind"] +WAVE_VARS = [ + "significant_height_of_combined_wind_waves_and_swell", + "mean_wave_direction", +] + +for year in ["2024", "2025"]: + for corridor, area in CORRIDORS.items(): + for var_type, variables in [("wind", WIND_VARS), ("waves", WAVE_VARS)]: + client.retrieve( + "reanalysis-era5-single-levels", + { + "product_type": "reanalysis", + "variable": variables, + "year": year, + "month": MONTHS, + "day": DAYS, + "time": TIMES, + "area": area, + "grid": [0.25, 0.25], + "data_format": "netcdf", + }, + f"era5_{var_type}_{corridor}_{year}.nc", + ) +``` + +The resulting NetCDF files contain variables named `u10`, `v10` (wind) and `swh`, `mwd` (waves), on a 0.25° grid at 6-hourly intervals. + +> **Note:** To download hourly data instead, change `TIMES` to `[f"{h:02d}:00" for h in range(24)]`. + +### RISE Performance Model — Full Specification + +The RISE model computes instantaneous **power in kW** for an 88 m cargo ship given: + +- $v$ — Ship speed through water (m/s) +- TWS — True wind speed (m/s) +- TWA — True wind angle (degrees, 0° = headwind) +- SWH — Significant wave height (m) +- MWA — Mean wave angle relative to heading (degrees) + +#### Constants + +| Symbol | Value | Exact Fraction | +| --------- | ---------- | -------------- | +| $K_H$ | 4.2876… | 969 / 226 | +| $K_A$ | 0.153125 | 49 / 320 | +| $A_W$ | 11.1395 | — | +| $K_W$ | 0.28935… | 125 / 432 | +| $K_S$ | 0.85903125 | 27489 / 32000 | +| Dead zone | 10° | — | + +#### Power Components + +**Hull resistance:** + +$$P_{\text{hull}} = K_H \cdot v^3$$ + +**Apparent wind:** + +$$u_x = \text{TWS} \cdot \cos(\text{TWA}) + v$$ +$$u_y = \text{TWS} \cdot \sin(\text{TWA})$$ +$$V_R = \sqrt{u_x^2 + u_y^2}$$ + +(TWA in radians for $\cos$/$\sin$ calls.) + +**Aerodynamic drag:** + +$$P_{\text{wind}} = K_A \cdot v \cdot (V_R \cdot u_x - v^2)$$ + +**Wave added resistance:** + +$$P_{\text{wave}} = A_W \cdot \text{SWH}^2 \cdot v^{3/2} \cdot \exp\!\left(-K_W \cdot |\theta_{\text{MWA}}|^3\right)$$ + +where $\theta_{\text{MWA}}$ is the mean wave angle in **radians**. + +**Wingsail thrust (WPS only):** + +$$\text{AWA} = \text{atan2}(|u_y|,\, u_x) \quad \text{(in degrees)}$$ + +If $\text{AWA} < 10°$, sail contribution is zero. Otherwise: + +$$\alpha = (\text{AWA} - 10°) \cdot \pi / 180$$ +$$P_{\text{sail}} = K_S \cdot \sin(\alpha) \cdot \left(1 + \frac{3}{20}\sin^2(\alpha)\right) \cdot V_R^2 \cdot v$$ + +#### Total Power and Energy + +**Without wingsails:** + +$$P = \max\!\left(0,\; P_{\text{hull}} + P_{\text{wind}} + P_{\text{wave}}\right)$$ + +**With wingsails (WPS):** + +$$P = \max\!\left(0,\; P_{\text{hull}} + P_{\text{wind}} + P_{\text{wave}} - P_{\text{sail}}\right)$$ + +**Energy integration** — sum over all waypoint segments: + +$$E_{\text{MWh}} = \frac{1}{1000} \sum_{i=1}^{n} P_i \cdot \Delta t_{h,i}$$ + +where $\Delta t_{h,i}$ is the duration of segment $i$ in hours, and $P_i$ is the power in kW at the midpoint of each segment. + +### Route and Departure Definitions + +| Case | Source (lat, lon) | Destination (lat, lon) | Passage (h) | WPS | +| --------- | ----------------- | ---------------------- | ----------- | --- | +| AO_WPS | (43.60, −4.00) | (40.53, −73.80) | 354 | Yes | +| AO_noWPS | (43.60, −4.00) | (40.53, −73.80) | 354 | No | +| AGC_WPS | (43.60, −4.00) | (40.53, −73.80) | 354 | Yes | +| AGC_noWPS | (43.60, −4.00) | (40.53, −73.80) | 354 | No | +| PO_WPS | (34.80, 140.00) | (34.40, −121.00) | 583 | Yes | +| PO_noWPS | (34.80, 140.00) | (34.40, −121.00) | 583 | No | +| PGC_WPS | (34.80, 140.00) | (34.40, −121.00) | 583 | Yes | +| PGC_noWPS | (34.80, 140.00) | (34.40, −121.00) | 583 | No | + +**Departures:** 366 departures — every day of 2024 at **12:00 UTC** (from 2024-01-01 to 2024-12-31). + +**Great Circle cases** (`*GC_*`): The route must follow the great circle path between source and destination. Only the speed profile along that path may vary. + +**Optimised cases** (`*O_*`): Both route geometry and speed profile may be freely optimised. diff --git a/codabench/pages/evaluation.html b/codabench/pages/evaluation.html new file mode 100644 index 00000000..a4901e51 --- /dev/null +++ b/codabench/pages/evaluation.html @@ -0,0 +1,96 @@ + + + + + + +

Evaluation

+

Scoring

+

Submissions are scored on total energy consumption (MWh) re-evaluated by the scoring program using the official ERA5 data and the RISE performance model. The re-evaluated energy (not the self-reported value) determines the leaderboard ranking.

+

If ERA5 reference data is unavailable on the server, the scoring program falls back to self-reported energy values from the participant's CSVs.

+

Metrics on the Leaderboard

+ + + + + + + + + + + + + + + + + + + + + + + + + +
MetricDescriptionRanking
Total Energy (MWh)Sum of energy across all 8 casesPrimary (ascending)
Per-case energyBreakdown for each of the 8 casesSecondary
Validation errorsNumber of format/constraint violationsLower is better
+

Validation Checks

+

The scoring program performs the following checks automatically:

+

File A Checks

+
    +
  1. File presence: All 8 File A CSVs present with the expected naming pattern.
  2. +
  3. Column structure: All required columns (departure_time_utc, arrival_time_utc, energy_cons_mwh, max_wind_mps, max_hs_m, sailed_distance_nm, details_filename) present.
  4. +
  5. Row count: Each File A has exactly 366 rows.
  6. +
  7. Datetime format: All timestamps match YYYY-MM-DD HH:MM:SS.
  8. +
  9. Departure schedule: Each departure matches the official schedule (2024-01-01 to 2024-12-31, noon UTC).
  10. +
  11. Passage time: Arrival − departure equals the expected passage time (354 h for Atlantic, 583 h for Pacific) within ±1 hour tolerance.
  12. +
  13. Numeric values: All energy, wind, wave, and distance values are positive and non-NaN.
  14. +
  15. Operational constraint — wind: max_wind_mps ≤ 20 m/s (optimised cases only).
  16. +
  17. Operational constraint — waves: max_hs_m ≤ 7 m (optimised cases only).
  18. +
+

File B Checks

+
    +
  1. File existence: Every File B referenced in details_filename exists under tracks/.
  2. +
  3. Column structure: Required columns (time_utc, lat_deg, lon_deg) present.
  4. +
  5. Minimum waypoints: At least 2 waypoints per track.
  6. +
  7. Timestamp ordering: Waypoint timestamps are strictly increasing.
  8. +
  9. Coordinate bounds: Latitudes in [−90, 90], longitudes in [−360, 360].
  10. +
  11. Start position: First waypoint within 0.5° of the expected source port.
  12. +
  13. End position: Last waypoint within 0.5° of the expected destination port.
  14. +
  15. Start time: First waypoint timestamp matches the departure time from File A.
  16. +
  17. End time: Last waypoint timestamp matches the arrival time from File A.
  18. +
  19. Land crossing: Sampled waypoints checked against a Natural Earth land shapefile (optimised cases only; when available on the server).
  20. +
+

Cross-Case Checks

+
    +
  1. WPS consistency: With-wingsail cases should have ≤ energy compared to without-wingsail cases (warning, not error).
  2. +
+

Submissions with validation errors still receive a score, but the error count is displayed on the leaderboard.

+
+

Note on Great Circle cases: Checks 8, 9, and 19 (operational constraints and land crossing) are skipped for GC cases (AGC_*, PGC_*). The great circle path is fixed and participants cannot modify it, so these constraints are not enforceable.

+
+

Disqualification

+

Submissions may be disqualified if: +- Optimised routes clearly cross land without avoidance. +- Reported energy values are materially inconsistent with the RISE performance model re-evaluation. +- The fixed passage time constraint is violated by more than the tolerance.

+

Fair Comparison Guarantee

+

All participants use: +- The same ERA5 weather data (0.25° grid, 6-hourly, 2024) +- The same RISE performance model (formulas provided in the Data tab) +- The same route definitions (ports, passage times, departure schedule)

+

The only variable is the optimization algorithm. This ensures that differences in the leaderboard reflect genuine algorithmic improvements.

+ + \ No newline at end of file diff --git a/codabench/pages/evaluation.md b/codabench/pages/evaluation.md new file mode 100644 index 00000000..5b572f16 --- /dev/null +++ b/codabench/pages/evaluation.md @@ -0,0 +1,70 @@ +# Evaluation + +## Scoring + +Submissions are scored on **total energy consumption (MWh)** re-evaluated by the scoring program using the official ERA5 data and the RISE performance model. The re-evaluated energy (not the self-reported value) determines the leaderboard ranking. + +If ERA5 reference data is unavailable on the server, the scoring program falls back to self-reported energy values from the participant's CSVs. + +### Metrics on the Leaderboard + +| Metric | Description | Ranking | +| ---------------------- | -------------------------------------- | ------------------- | +| **Total Energy (MWh)** | Sum of energy across all 8 cases | Primary (ascending) | +| Per-case energy | Breakdown for each of the 8 cases | Secondary | +| Validation errors | Number of format/constraint violations | Lower is better | + +### Validation Checks + +The scoring program performs the following checks automatically: + +#### File A Checks + +1. **File presence:** All 8 File A CSVs present with the expected naming pattern. +2. **Column structure:** All required columns (`departure_time_utc`, `arrival_time_utc`, `energy_cons_mwh`, `max_wind_mps`, `max_hs_m`, `sailed_distance_nm`, `details_filename`) present. +3. **Row count:** Each File A has exactly 366 rows. +4. **Datetime format:** All timestamps match `YYYY-MM-DD HH:MM:SS`. +5. **Departure schedule:** Each departure matches the official schedule (2024-01-01 to 2024-12-31, noon UTC). +6. **Passage time:** Arrival − departure equals the expected passage time (354 h for Atlantic, 583 h for Pacific) within ±1 hour tolerance. +7. **Numeric values:** All energy, wind, wave, and distance values are positive and non-NaN. +8. **Operational constraint — wind:** `max_wind_mps` ≤ 20 m/s (optimised cases only). +9. **Operational constraint — waves:** `max_hs_m` ≤ 7 m (optimised cases only). + +#### File B Checks + +10. **File existence:** Every File B referenced in `details_filename` exists under `tracks/`. +11. **Column structure:** Required columns (`time_utc`, `lat_deg`, `lon_deg`) present. +12. **Minimum waypoints:** At least 2 waypoints per track. +13. **Timestamp ordering:** Waypoint timestamps are strictly increasing. +14. **Coordinate bounds:** Latitudes in [−90, 90], longitudes in [−360, 360]. +15. **Start position:** First waypoint within 0.5° of the expected source port. +16. **End position:** Last waypoint within 0.5° of the expected destination port. +17. **Start time:** First waypoint timestamp matches the departure time from File A. +18. **End time:** Last waypoint timestamp matches the arrival time from File A. +19. **Land crossing:** Sampled waypoints checked against a Natural Earth land shapefile (optimised cases only; when available on the server). + +#### Cross-Case Checks + +20. **WPS consistency:** With-wingsail cases should have ≤ energy compared to without-wingsail cases (warning, not error). + +Submissions with validation errors still receive a score, but the error count is displayed on the leaderboard. + +> **Note on Great Circle cases:** Checks 8, 9, and 19 (operational constraints and land crossing) are skipped for GC cases (`AGC_*`, `PGC_*`). The great circle path is fixed and participants cannot modify it, so these constraints are not enforceable. + +### Disqualification + +Submissions may be disqualified if: + +- Optimised routes clearly cross land without avoidance. +- Reported energy values are materially inconsistent with the RISE performance model re-evaluation. +- The fixed passage time constraint is violated by more than the tolerance. + +## Fair Comparison Guarantee + +All participants use: + +- **The same ERA5 weather data** (0.25° grid, 6-hourly, 2024) +- **The same RISE performance model** (formulas provided in the **Data** tab) +- **The same route definitions** (ports, passage times, departure schedule) + +The only variable is the optimization algorithm. This ensures that differences in the leaderboard reflect genuine algorithmic improvements. diff --git a/codabench/pages/overview.html b/codabench/pages/overview.html new file mode 100644 index 00000000..6405233c --- /dev/null +++ b/codabench/pages/overview.html @@ -0,0 +1,167 @@ + + + + + + +

SWOPP3 Weather Routing Benchmark

+

Overview

+

The SWOPP3 Weather Routing Benchmark evaluates weather routing optimizers on real ERA5 weather data using the RISE performance model for an 88 m cargo ship with optional wingsails.

+

The Challenge

+

Participants must find minimum-energy routes across two ocean corridors:

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
RouteFromToPassage TimeGC Distance
Trans-AtlanticSantander (ESSDR)New York (USNYC)354 hours2,833 nm
Trans-PacificTokyo (JPTYO)Los Angeles (USLAX)583 hours4,663 nm
+

For each route, there are 4 cases combining route optimisation strategy and wingsail configuration:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CaseStrategyWingsails (WPS)
*O_WPSOptimised route and speedYes
*O_noWPSOptimised route and speedNo
*GC_WPSGreat Circle, fixed speedYes
*GC_noWPSGreat Circle, fixed speedNo
+

This gives 8 cases total × 366 daily departures (every day of 2024, noon UTC) = 2,928 route evaluations per submission.

+

Port Coordinates

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Port CodeCityLatitudeLongitude
ESSDRSantander, Spain43.60° N4.00° W
USNYCNew York, USA40.53° N73.80° W
JPTYOTokyo, Japan34.80° N140.00° E
USLAXLos Angeles, USA34.40° N121.00° W
+

Operational Constraints

+

Optimised cases (*O_*) must respect these weather safety limits along the entire path:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ConstraintLimitDescription
Significant wave heightHs < 7 mMaximum wave height along the route
True wind speedTWS < 20 m/sMaximum wind speed along the route
Land avoidanceNo crossingsRoute waypoints must not cross land
+
+

Note on Great Circle cases: GC cases follow a fixed geodesic path that participants cannot modify. Because the great circle may cross land (e.g. the Atlantic route clips Newfoundland) and traverse severe weather, operational and land-crossing checks are not enforced for GC cases.

+
+

What Makes This Different

+

Unlike the original SWOPP3 competition, all participants use the same weather data and the same performance model. This ensures results are directly comparable — the only differentiator is the optimization algorithm.

+

Ranking

+

Submissions are ranked by total energy consumption (MWh) summed across all 8 cases and 366 departures. Lower is better.

+

Getting Started

+

You can choose between two approaches to obtain the ERA5 data:

+

Option A — Download from CodaBench (recommended): +1. Download the pre-built ERA5 .nc files from the Files tab of this competition +2. Implement the RISE performance model (formulas provided in the Data tab) +3. Submit a zip file with your CSV results

+

Option B — Download via CDS API: +1. Register at the Copernicus Climate Data Store and download ERA5 data with the cdsapi package +2. Implement the RISE performance model (formulas provided in the Data tab) +3. Submit a zip file with your CSV results

+

See the Data & Performance Model tab for details on both options.

+ + \ No newline at end of file diff --git a/codabench/pages/overview.md b/codabench/pages/overview.md new file mode 100644 index 00000000..830a7ef6 --- /dev/null +++ b/codabench/pages/overview.md @@ -0,0 +1,72 @@ +# SWOPP3 Weather Routing Benchmark + +## Overview + +The **SWOPP3 Weather Routing Benchmark** evaluates weather routing optimizers on real ERA5 weather data using the RISE performance model for an 88 m cargo ship with optional wingsails. + +### The Challenge + +Participants must find minimum-energy routes across **two ocean corridors**: + +| Route | From | To | Passage Time | GC Distance | +| ------------------ | ----------------- | ------------------- | ------------ | ----------- | +| **Trans-Atlantic** | Santander (ESSDR) | New York (USNYC) | 354 hours | 2,833 nm | +| **Trans-Pacific** | Tokyo (JPTYO) | Los Angeles (USLAX) | 583 hours | 4,663 nm | + +For each route, there are **4 cases** combining route optimisation strategy and wingsail configuration: + +| Case | Strategy | Wingsails (WPS) | +| ----------- | ----------------------------- | --------------- | +| `*O_WPS` | **Optimised** route and speed | Yes | +| `*O_noWPS` | **Optimised** route and speed | No | +| `*GC_WPS` | **Great Circle**, fixed speed | Yes | +| `*GC_noWPS` | **Great Circle**, fixed speed | No | + +This gives **8 cases total** × **366 daily departures** (every day of 2024, noon UTC) = **2,928 route evaluations per submission**. + +### Port Coordinates + +| Port Code | City | Latitude | Longitude | +| --------- | ---------------- | -------- | --------- | +| ESSDR | Santander, Spain | 43.60° N | 4.00° W | +| USNYC | New York, USA | 40.53° N | 73.80° W | +| JPTYO | Tokyo, Japan | 34.80° N | 140.00° E | +| USLAX | Los Angeles, USA | 34.40° N | 121.00° W | + +### Operational Constraints + +**Optimised cases** (`*O_*`) must respect these weather safety limits along the entire path: + +| Constraint | Limit | Description | +| --------------------------- | ---------------- | ----------------------------------- | +| **Significant wave height** | Hs < **7 m** | Maximum wave height along the route | +| **True wind speed** | TWS < **20 m/s** | Maximum wind speed along the route | +| **Land avoidance** | No crossings | Route waypoints must not cross land | + +> **Note on Great Circle cases:** GC cases follow a fixed geodesic path that participants cannot modify. Because the great circle may cross land (e.g. the Atlantic route clips Newfoundland) and traverse severe weather, **operational and land-crossing checks are not enforced** for GC cases. + +### What Makes This Different + +Unlike the original SWOPP3 competition, **all participants use the same weather data and the same performance model**. This ensures results are directly comparable — the only differentiator is the optimization algorithm. + +### Ranking + +Submissions are ranked by **total energy consumption (MWh)** summed across all 8 cases and 366 departures. Lower is better. + +### Getting Started + +You can choose between two approaches to obtain the ERA5 data: + +**Option A — Download from CodaBench** (recommended): + +1. Download the pre-built ERA5 `.nc` files from the **Files** tab of this competition +2. Implement the RISE performance model (formulas provided in the **Data** tab) +3. Submit a zip file with your CSV results + +**Option B — Download via CDS API**: + +1. Register at the Copernicus Climate Data Store and download ERA5 data with the `cdsapi` package +2. Implement the RISE performance model (formulas provided in the **Data** tab) +3. Submit a zip file with your CSV results + +See the **Data & Performance Model** tab for details on both options. diff --git a/codabench/pages/submission.html b/codabench/pages/submission.html new file mode 100644 index 00000000..7fe75809 --- /dev/null +++ b/codabench/pages/submission.html @@ -0,0 +1,173 @@ + + + + + + +

Submission Format

+

What to Submit

+

Upload a single .zip file containing:

+
submission.zip
+├── TeamName-1-AO_WPS.csv         # File A (Atlantic Optimised, with WPS)
+├── TeamName-1-AO_noWPS.csv       # File A (Atlantic Optimised, without WPS)
+├── TeamName-1-AGC_WPS.csv        # File A (Atlantic Great Circle, with WPS)
+├── TeamName-1-AGC_noWPS.csv      # File A (Atlantic Great Circle, without WPS)
+├── TeamName-1-PO_WPS.csv         # File A (Pacific Optimised, with WPS)
+├── TeamName-1-PO_noWPS.csv       # File A (Pacific Optimised, without WPS)
+├── TeamName-1-PGC_WPS.csv        # File A (Pacific Great Circle, with WPS)
+├── TeamName-1-PGC_noWPS.csv      # File A (Pacific Great Circle, without WPS)
+└── tracks/
+    ├── TeamName-1-AO_WPS-20240101.csv    # File B (waypoints)
+    ├── TeamName-1-AO_WPS-20240102.csv
+    ├── ...                               # 366 files per case × 8 cases
+    └── TeamName-1-PGC_noWPS-20241231.csv
+
+ +

Replace TeamName with your team name and 1 with your submission number.

+

File A — Energy Summary (one per case)

+

Filename: TeamName-{submission}-{casename}.csv

+

Each file has 366 rows (one per departure) with these columns:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ColumnTypeDescription
departure_time_utcdatetimeYYYY-MM-DD HH:MM:SS format
arrival_time_utcdatetimeYYYY-MM-DD HH:MM:SS format
energy_cons_mwhfloatTotal energy consumption in MWh
max_wind_mpsfloatMaximum true wind speed encountered (m/s)
max_hs_mfloatMaximum significant wave height encountered (m)
sailed_distance_nmfloatTotal sailed distance in nautical miles
details_filenamestringName of the corresponding File B CSV
+

Example:

+
departure_time_utc,arrival_time_utc,energy_cons_mwh,max_wind_mps,max_hs_m,sailed_distance_nm,details_filename
+2024-01-01 12:00:00,2024-01-16 06:00:00,336.375913,17.3875,7.8464,3022.5591,TeamName-1-AO_WPS-20240101.csv
+2024-01-02 12:00:00,2024-01-17 06:00:00,199.891800,17.3153,7.3659,3022.5591,TeamName-1-AO_WPS-20240102.csv
+
+ +

File B — Track Waypoints (one per departure per case)

+

Filename: TeamName-{submission}-{casename}-{YYYYMMDD}.csv

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ColumnTypeDescription
time_utcdatetimeYYYY-MM-DD HH:MM:SS, strictly increasing
lat_degfloatLatitude in degrees [-90, 90]
lon_degfloatLongitude in degrees [-360, 360]
+

Example:

+
time_utc,lat_deg,lon_deg
+2024-01-01 12:00:00,43.4600,-3.8100
+2024-01-01 15:32:00,43.5100,-5.2300
+...
+2024-01-16 06:00:00,40.5300,-73.8000
+
+ +

Important Rules

+

Passage Time

+
    +
  • Fixed passage: Arrival time must equal departure + passage hours (354 h for Atlantic, 583 h for Pacific), with a tolerance of ±1 hour.
  • +
+

Departure Schedule

+
    +
  • All 366 departures required: One row per day from 2024-01-01 to 2024-12-31, departing at 12:00 UTC.
  • +
  • Departure timestamps must match the official schedule exactly.
  • +
+

Endpoint Positions

+
    +
  • First waypoint in File B must be within 0.5° of the expected source port.
  • +
  • Last waypoint in File B must be within 0.5° of the expected destination port.
  • +
  • First/last waypoint timestamps must match the departure/arrival times from File A.
  • +
+

Operational Constraints (Optimised Cases Only)

+
    +
  • True wind speed: max_wind_mps must be < 20 m/s along the route.
  • +
  • Significant wave height: max_hs_m must be < 7 m along the route.
  • +
  • Submissions exceeding these limits receive a validation error per violation.
  • +
  • Not enforced for GC cases — the great circle path is fixed and may traverse severe weather.
  • +
+

Land Avoidance (Optimised Cases Only)

+
    +
  • Waypoints in File B must not cross land. The scoring program checks a sample of waypoints against a Natural Earth shapefile.
  • +
  • Not enforced for GC cases — the great circle path is fixed and may cross land (e.g. Newfoundland on the Atlantic route).
  • +
+

Numeric Values

+
    +
  • No negative energy values.
  • +
  • No NaN values in any numeric column.
  • +
  • Timestamps must be strictly increasing in File B.
  • +
+

Route Constraints

+
    +
  • Great Circle cases (*GC_*): The route must follow the great circle path. Only the speed profile may vary.
  • +
  • Optimised cases (*O_*): Both route and speed may be optimised freely.
  • +
+

WPS Consistency

+
    +
  • With wingsails (*_WPS) routes should consume less or equal energy compared to the same route without wingsails (*_noWPS). The system flags a warning if this is violated.
  • +
+ + \ No newline at end of file diff --git a/codabench/pages/submission.md b/codabench/pages/submission.md new file mode 100644 index 00000000..9ab7b0a3 --- /dev/null +++ b/codabench/pages/submission.md @@ -0,0 +1,112 @@ +# Submission Format + +## What to Submit + +Upload a **single `.zip` file** containing: + +``` +submission.zip +├── TeamName-1-AO_WPS.csv # File A (Atlantic Optimised, with WPS) +├── TeamName-1-AO_noWPS.csv # File A (Atlantic Optimised, without WPS) +├── TeamName-1-AGC_WPS.csv # File A (Atlantic Great Circle, with WPS) +├── TeamName-1-AGC_noWPS.csv # File A (Atlantic Great Circle, without WPS) +├── TeamName-1-PO_WPS.csv # File A (Pacific Optimised, with WPS) +├── TeamName-1-PO_noWPS.csv # File A (Pacific Optimised, without WPS) +├── TeamName-1-PGC_WPS.csv # File A (Pacific Great Circle, with WPS) +├── TeamName-1-PGC_noWPS.csv # File A (Pacific Great Circle, without WPS) +└── tracks/ + ├── TeamName-1-AO_WPS-20240101.csv # File B (waypoints) + ├── TeamName-1-AO_WPS-20240102.csv + ├── ... # 366 files per case × 8 cases + └── TeamName-1-PGC_noWPS-20241231.csv +``` + +Replace `TeamName` with your team name and `1` with your submission number. + +## File A — Energy Summary (one per case) + +**Filename:** `TeamName-{submission}-{casename}.csv` + +Each file has **366 rows** (one per departure) with these columns: + +| Column | Type | Description | +| -------------------- | -------- | ----------------------------------------------- | +| `departure_time_utc` | datetime | `YYYY-MM-DD HH:MM:SS` format | +| `arrival_time_utc` | datetime | `YYYY-MM-DD HH:MM:SS` format | +| `energy_cons_mwh` | float | Total energy consumption in MWh | +| `max_wind_mps` | float | Maximum true wind speed encountered (m/s) | +| `max_hs_m` | float | Maximum significant wave height encountered (m) | +| `sailed_distance_nm` | float | Total sailed distance in nautical miles | +| `details_filename` | string | Name of the corresponding File B CSV | + +**Example:** + +```csv +departure_time_utc,arrival_time_utc,energy_cons_mwh,max_wind_mps,max_hs_m,sailed_distance_nm,details_filename +2024-01-01 12:00:00,2024-01-16 06:00:00,336.375913,17.3875,7.8464,3022.5591,TeamName-1-AO_WPS-20240101.csv +2024-01-02 12:00:00,2024-01-17 06:00:00,199.891800,17.3153,7.3659,3022.5591,TeamName-1-AO_WPS-20240102.csv +``` + +## File B — Track Waypoints (one per departure per case) + +**Filename:** `TeamName-{submission}-{casename}-{YYYYMMDD}.csv` + +| Column | Type | Description | +| ---------- | -------- | ------------------------------------------ | +| `time_utc` | datetime | `YYYY-MM-DD HH:MM:SS`, strictly increasing | +| `lat_deg` | float | Latitude in degrees [-90, 90] | +| `lon_deg` | float | Longitude in degrees [-360, 360] | + +**Example:** + +```csv +time_utc,lat_deg,lon_deg +2024-01-01 12:00:00,43.4600,-3.8100 +2024-01-01 15:32:00,43.5100,-5.2300 +... +2024-01-16 06:00:00,40.5300,-73.8000 +``` + +## Important Rules + +### Passage Time + +- **Fixed passage:** Arrival time must equal departure + passage hours (354 h for Atlantic, 583 h for Pacific), with a tolerance of ±1 hour. + +### Departure Schedule + +- **All 366 departures required:** One row per day from 2024-01-01 to 2024-12-31, departing at **12:00 UTC**. +- Departure timestamps must match the official schedule exactly. + +### Endpoint Positions + +- First waypoint in File B must be within **0.5°** of the expected source port. +- Last waypoint in File B must be within **0.5°** of the expected destination port. +- First/last waypoint timestamps must match the departure/arrival times from File A. + +### Operational Constraints (Optimised Cases Only) + +- **True wind speed:** `max_wind_mps` must be < **20 m/s** along the route. +- **Significant wave height:** `max_hs_m` must be < **7 m** along the route. +- Submissions exceeding these limits receive a validation error per violation. +- **Not enforced for GC cases** — the great circle path is fixed and may traverse severe weather. + +### Land Avoidance (Optimised Cases Only) + +- Waypoints in File B must not cross land. The scoring program checks a sample of waypoints against a Natural Earth shapefile. +- **Not enforced for GC cases** — the great circle path is fixed and may cross land (e.g. Newfoundland on the Atlantic route). + +### Numeric Values + +- No negative energy values. +- No NaN values in any numeric column. +- **Timestamps must be strictly increasing** in File B. + +### Route Constraints + +- **Great Circle cases** (`*GC_*`): The route must follow the great circle path. Only the speed profile may vary. +- **Optimised cases** (`*O_*`): Both route and speed may be optimised freely. + +### WPS Consistency + +- With wingsails (`*_WPS`) routes should consume **less or equal** energy compared to the same route without wingsails (`*_noWPS`). The system flags a warning if this is violated. diff --git a/codabench/pages/terms.html b/codabench/pages/terms.html new file mode 100644 index 00000000..463a6289 --- /dev/null +++ b/codabench/pages/terms.html @@ -0,0 +1,41 @@ + + + + + + +

Terms & Conditions

+

Eligibility

+

This benchmark is open to academic and industry researchers worldwide.

+

Data Usage

+
    +
  • ERA5 weather data is provided by ECMWF under the Copernicus Climate Data Store licence.
  • +
  • The RISE performance model is provided as part of the routetools package for benchmark evaluation only.
  • +
+

Submissions

+
    +
  • Participants may submit up to 3 times per day and 100 total submissions.
  • +
  • Each submission must be the participant's own work.
  • +
  • Submissions must use the official ERA5 data and RISE performance model provided.
  • +
+

Publication

+
    +
  • The benchmark organisers may publish aggregate results and rankings.
  • +
  • Participants retain rights to their optimization methods.
  • +
  • Participants are encouraged (but not required) to publish their methods.
  • +
+

Contact

+

For questions about the benchmark, open an issue on the routetools GitHub repository or contact the organizers.

+ + \ No newline at end of file diff --git a/codabench/pages/terms.md b/codabench/pages/terms.md new file mode 100644 index 00000000..a883b090 --- /dev/null +++ b/codabench/pages/terms.md @@ -0,0 +1,26 @@ +# Terms & Conditions + +## Eligibility + +This benchmark is open to academic and industry researchers worldwide. + +## Data Usage + +- ERA5 weather data is provided by ECMWF under the [Copernicus Climate Data Store licence](https://cds.climate.copernicus.eu/cdsapp/#!/terms/licence-to-use-copernicus-products). +- The RISE performance model is documented in the evaluation page and implemented inline in the scoring program. No external dependencies (e.g. `routetools`) are required. + +## Submissions + +- Participants may submit up to **3 times per day** and **100 total** submissions. +- Each submission must be the participant's own work. +- Submissions must use the official ERA5 data and RISE performance model provided. + +## Publication + +- The benchmark organisers may publish aggregate results and rankings. +- Participants retain rights to their optimization methods. +- Participants are encouraged (but not required) to publish their methods. + +## Contact + +For questions about the benchmark, open an issue on the [routetools GitHub repository](https://github.com/Weather-Routing-Research/routetools) or contact the organizers. diff --git a/codabench/reference_data/config.json b/codabench/reference_data/config.json new file mode 100644 index 00000000..b8d2a5a6 --- /dev/null +++ b/codabench/reference_data/config.json @@ -0,0 +1,4 @@ +{ + "mode": "re_evaluation", + "note": "ERA5 6-hourly data + Natural Earth shapefile for server-side re-evaluation" +} diff --git a/codabench/scoring_program/Dockerfile b/codabench/scoring_program/Dockerfile new file mode 100644 index 00000000..ef147c70 --- /dev/null +++ b/codabench/scoring_program/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim + +# Pre-install scoring dependencies from pinned requirements +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt + +# Install RISE performance model wheel (optional comparison in scorer) +COPY swopp3_performance_model-0.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl /tmp/ +RUN pip install --no-cache-dir /tmp/swopp3_performance_model-0.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl \ + && rm /tmp/*.whl diff --git a/codabench/scoring_program/metadata.yaml b/codabench/scoring_program/metadata.yaml new file mode 100644 index 00000000..106b9c55 --- /dev/null +++ b/codabench/scoring_program/metadata.yaml @@ -0,0 +1,2 @@ +# Scoring program metadata for CodaBench +command: python scoring.py $input $output diff --git a/codabench/scoring_program/requirements.txt b/codabench/scoring_program/requirements.txt new file mode 100644 index 00000000..06d27663 --- /dev/null +++ b/codabench/scoring_program/requirements.txt @@ -0,0 +1,5 @@ +numpy>=1.24,<3 +netCDF4>=1.6,<2 +pyshp>=2.3,<3 +shapely>=2.0,<3 +matplotlib>=3.7,<4 diff --git a/codabench/scoring_program/scoring.py b/codabench/scoring_program/scoring.py new file mode 100755 index 00000000..588446f8 --- /dev/null +++ b/codabench/scoring_program/scoring.py @@ -0,0 +1,1624 @@ +#!/usr/bin/env python +"""CodaBench scoring program for the SWOPP3 Weather Routing Benchmark. + +CodaBench invokes this script with two arguments: + python scoring.py + +Where: + /ref/ — reference data (ERA5 NetCDF files + land mask) + /res/ — participant submission (unzipped) + / — write scores.json here + +The scoring program: +1. Validates submission structure (File A and File B CSVs). +2. Validates endpoint positions and timestamps against expected ports. +3. Checks that waypoints do not cross land (using Natural Earth shapefile). +4. Checks operational constraints (Hs < 7 m, TWS < 20 m/s). +5. Validates passage time consistency. +6. Re-evaluates energy using the RISE performance model with official ERA5 data + (when ERA5 files are present in reference_data). +7. Writes scores.json with per-case and total energy metrics. +""" + +from __future__ import annotations + +import base64 +import csv +import gc +import html as html_mod +import io +import json +import math +import sys +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import numpy as np + +# ─── CodaBench entry point ─────────────────────────────────────────── + +if len(sys.argv) < 3: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + +input_dir = Path(sys.argv[1]) +output_dir = Path(sys.argv[2]) + +submission_dir = input_dir / "res" +reference_dir = input_dir / "ref" + +# Worker-local data: /codabench/data on the host is mounted as /app/data +# inside the scoring container. Check there first for ERA5 + shapefile. +_WORKER_DATA = Path("/app/data") +data_dir = _WORKER_DATA if _WORKER_DATA.is_dir() else reference_dir + +output_dir.mkdir(parents=True, exist_ok=True) + +# ─── Configuration ─────────────────────────────────────────────────── + +EXPECTED_DEPARTURES = 366 +DTFMT = "%Y-%m-%d %H:%M:%S" + +# Operational limits (SWOPP3) +MAX_WIND_MPS = 20.0 # True wind speed limit (m/s) +MAX_HS_M = 7.0 # Significant wave height limit (m) + +# Endpoint tolerance (degrees) — allow small rounding differences +ENDPOINT_TOLERANCE_DEG = 0.5 + +# Passage time tolerance (hours) +PASSAGE_TIME_TOLERANCE_H = 1.0 + +# Evaluation resampling interval (minutes) +# Decouples waypoint density (Δt₁) from integration accuracy (Δt₂). +EVAL_DT_MINUTES = 15.0 + +CASE_NAMES = [ + "AO_WPS", + "AO_noWPS", + "AGC_WPS", + "AGC_noWPS", + "PO_WPS", + "PO_noWPS", + "PGC_WPS", + "PGC_noWPS", +] + +GC_CASES = {"AGC_WPS", "AGC_noWPS", "PGC_WPS", "PGC_noWPS"} + +# Case definitions: expected ports, passage times, and route corridor +CASE_DEFS = { + "AO_WPS": { + "src": (43.6, -4.0), + "dst": (40.6, -69.0), + "passage_h": 354, + "route": "atlantic", + "wps": True, + }, + "AO_noWPS": { + "src": (43.6, -4.0), + "dst": (40.6, -69.0), + "passage_h": 354, + "route": "atlantic", + "wps": False, + }, + "AGC_WPS": { + "src": (43.6, -4.0), + "dst": (40.6, -69.0), + "passage_h": 354, + "route": "atlantic", + "wps": True, + }, + "AGC_noWPS": { + "src": (43.6, -4.0), + "dst": (40.6, -69.0), + "passage_h": 354, + "route": "atlantic", + "wps": False, + }, + "PO_WPS": { + "src": (34.8, 140.0), + "dst": (34.4, -121.0), + "passage_h": 583, + "route": "pacific", + "wps": True, + }, + "PO_noWPS": { + "src": (34.8, 140.0), + "dst": (34.4, -121.0), + "passage_h": 583, + "route": "pacific", + "wps": False, + }, + "PGC_WPS": { + "src": (34.8, 140.0), + "dst": (34.4, -121.0), + "passage_h": 583, + "route": "pacific", + "wps": True, + }, + "PGC_noWPS": { + "src": (34.8, 140.0), + "dst": (34.4, -121.0), + "passage_h": 583, + "route": "pacific", + "wps": False, + }, +} + +# Expected departure schedule: 366 days of 2024, noon UTC +EXPECTED_DEPARTURES_LIST = [ + datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) + timedelta(days=d) for d in range(366) +] + +FILE_A_COLUMNS = [ + "departure_time_utc", + "arrival_time_utc", + "energy_cons_mwh", + "max_wind_mps", + "max_hs_m", + "sailed_distance_nm", + "details_filename", +] +FILE_B_COLUMNS = ["time_utc", "lat_deg", "lon_deg"] + + +# ─── Helpers ───────────────────────────────────────────────────────── + + +def _coord_distance_deg(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Euclidean distance in degrees with antimeridian-safe longitude delta.""" + dlon = (lon1 - lon2 + 180) % 360 - 180 # wrapped to [-180, 180] + return math.sqrt((lat1 - lat2) ** 2 + dlon**2) + + +def find_team_prefix(sub_dir: Path) -> str | None: + """Detect the team name prefix from submitted CSV files.""" + for f in sub_dir.glob("*.csv"): + for case in CASE_NAMES: + if f.name.endswith(f"-{case}.csv"): + return f.name[: -(len(case) + 5)] # strip "-{case}.csv" + return None + + +# ─── Land checking (shapefile-based, no JAX needed) ────────────────── + + +def _load_land_checker(ref_dir: Path): + """Load a land-checking function from Natural Earth shapefile in ref_dir. + + Returns a callable (lat, lon) -> bool, or None if shapefile not found. + """ + shapefile = ref_dir / "ne_10m_land.shp" + if not shapefile.exists(): + # Try alternative locations + for candidate in ref_dir.glob("**/ne_*_land.shp"): + shapefile = candidate + break + else: + return None + + try: + import shapefile as shp + from shapely.geometry import Point, shape + + sf = shp.Reader(str(shapefile)) + land_shapes = [shape(s) for s in sf.shapes()] + + # Build a union for efficient queries + from shapely.ops import unary_union + + land_union = unary_union(land_shapes) + + def is_on_land(lat: float, lon: float) -> bool: + return land_union.contains(Point(lon, lat)) + + return is_on_land + except ImportError: + return None + + +# ─── Validation: File A ───────────────────────────────────────────── + + +def validate_file_a( + path: Path, + case_id: str, + expected_rows: int = 366, +) -> tuple[list[str], list[dict]]: + """Validate a File A CSV. Returns (errors, parsed_rows).""" + errors: list[str] = [] + fname = path.name + case_def = CASE_DEFS[case_id] + passage_h = case_def["passage_h"] + + if not path.exists(): + errors.append(f"{fname}: File not found") + return errors, [] + + with path.open() as f: + reader = csv.DictReader(f) + if reader.fieldnames is None: + errors.append(f"{fname}: No header row") + return errors, [] + + missing = set(FILE_A_COLUMNS) - set(reader.fieldnames) + if missing: + errors.append(f"{fname}: Missing columns: {missing}") + + rows = list(reader) + + if len(rows) != expected_rows: + errors.append(f"{fname}: Expected {expected_rows} rows, got {len(rows)}") + + for i, row in enumerate(rows, 1): + # ── Datetime parsing ── + dep_dt = arr_dt = None + for col in ("departure_time_utc", "arrival_time_utc"): + try: + dt = datetime.strptime(row.get(col, ""), DTFMT) + if col == "departure_time_utc": + dep_dt = dt + else: + arr_dt = dt + except ValueError: + errors.append(f"{fname} row {i}: Bad datetime in '{col}'") + + # ── Passage time check ── + if dep_dt and arr_dt: + actual_h = (arr_dt - dep_dt).total_seconds() / 3600 + if abs(actual_h - passage_h) > PASSAGE_TIME_TOLERANCE_H: + errors.append( + f"{fname} row {i}: Passage time {actual_h:.1f}h != " + f"expected {passage_h}h" + ) + + # ── Departure time must match official schedule ── + if dep_dt and i <= len(EXPECTED_DEPARTURES_LIST): + expected_dep = EXPECTED_DEPARTURES_LIST[i - 1] + expected_naive = expected_dep.replace(tzinfo=None) + if dep_dt != expected_naive: + errors.append( + f"{fname} row {i}: Departure {dep_dt} != " + f"expected {expected_naive}" + ) + + # ── Numeric fields ── + for col in ( + "energy_cons_mwh", + "max_wind_mps", + "max_hs_m", + "sailed_distance_nm", + ): + try: + v = float(row.get(col, "")) + if v != v: # NaN + errors.append(f"{fname} row {i}: NaN in '{col}'") + if v < 0: + errors.append(f"{fname} row {i}: Negative value in '{col}'") + except ValueError: + errors.append(f"{fname} row {i}: Non-numeric '{col}'") + + # ── Operational constraints (skipped for GC cases) ── + if case_id not in GC_CASES: + try: + wind = float(row.get("max_wind_mps", "0")) + if wind > MAX_WIND_MPS: + errors.append( + f"{fname} row {i}: max_wind_mps={wind:.1f} exceeds " + f"limit {MAX_WIND_MPS} m/s" + ) + except ValueError: + pass + + try: + hs = float(row.get("max_hs_m", "0")) + if hs > MAX_HS_M: + errors.append( + f"{fname} row {i}: max_hs_m={hs:.1f} exceeds " + f"limit {MAX_HS_M} m" + ) + except ValueError: + pass + + return errors, rows + + +# ─── Validation: File B ────────────────────────────────────────────── + + +def validate_file_b( + path: Path, + case_id: str, + departure_dt: datetime | None = None, + arrival_dt: datetime | None = None, + land_checker=None, +) -> tuple[list[str], int]: + """Validate a File B (track waypoints) CSV. + + Returns + ------- + tuple[list[str], int] + ``(errors, land_count)`` where *land_count* is the number of sampled + waypoints found on land (0 when the check is skipped). + """ + errors: list[str] = [] + land_count = 0 + fname = path.name + case_def = CASE_DEFS[case_id] + + if not path.exists(): + errors.append(f"{fname}: File not found") + return errors, land_count + + with path.open() as f: + reader = csv.DictReader(f) + if reader.fieldnames is None: + errors.append(f"{fname}: No header row") + return errors, land_count + + missing = set(FILE_B_COLUMNS) - set(reader.fieldnames) + if missing: + errors.append(f"{fname}: Missing columns: {missing}") + + rows = list(reader) + + if len(rows) < 2: + errors.append(f"{fname}: Less than 2 waypoints") + return errors, land_count + + # Parse all waypoints + waypoints: list[tuple[datetime | None, float | None, float | None]] = [] + prev_time = None + for i, row in enumerate(rows, 1): + t = lat = lon = None + try: + t = datetime.strptime(row.get("time_utc", ""), DTFMT) + if prev_time is not None and t <= prev_time: + errors.append(f"{fname} row {i}: Timestamps not strictly increasing") + prev_time = t + except ValueError: + errors.append(f"{fname} row {i}: Bad time_utc") + + for col, lo, hi in [("lat_deg", -90, 90), ("lon_deg", -360, 360)]: + try: + v = float(row.get(col, "")) + if v < lo or v > hi: + errors.append(f"{fname} row {i}: '{col}'={v} out of [{lo},{hi}]") + if col == "lat_deg": + lat = v + else: + lon = v + except ValueError: + errors.append(f"{fname} row {i}: Non-numeric '{col}'") + + waypoints.append((t, lat, lon)) + + if not waypoints: + return errors, land_count + # ── Endpoint position checks ── + src_lat, src_lon = case_def["src"] + dst_lat, dst_lon = case_def["dst"] + + first_lat, first_lon = waypoints[0][1], waypoints[0][2] + last_lat, last_lon = waypoints[-1][1], waypoints[-1][2] + + if first_lat is not None and first_lon is not None: + dist = _coord_distance_deg(first_lat, first_lon, src_lat, src_lon) + if dist > ENDPOINT_TOLERANCE_DEG: + errors.append( + f"{fname}: Start ({first_lat:.2f}, {first_lon:.2f}) too far " + f"from expected port ({src_lat}, {src_lon}), dist={dist:.2f}°" + ) + + if last_lat is not None and last_lon is not None: + dist = _coord_distance_deg(last_lat, last_lon, dst_lat, dst_lon) + if dist > ENDPOINT_TOLERANCE_DEG: + errors.append( + f"{fname}: End ({last_lat:.2f}, {last_lon:.2f}) too far " + f"from expected port ({dst_lat}, {dst_lon}), dist={dist:.2f}°" + ) + + # ── Endpoint time checks ── + first_time = waypoints[0][0] + last_time = waypoints[-1][0] + + if departure_dt and first_time and first_time != departure_dt: + errors.append( + f"{fname}: First waypoint time {first_time} != " f"departure {departure_dt}" + ) + + if arrival_dt and last_time and last_time != arrival_dt: + errors.append( + f"{fname}: Last waypoint time {last_time} != arrival {arrival_dt}" + ) + + # ── Land crossing checks (skipped for GC cases) ── + if land_checker is not None and case_id not in GC_CASES: + step = max(1, len(waypoints) // 50) # check ~50 points max + for idx in range(0, len(waypoints), step): + _, lat, lon = waypoints[idx] + if lat is not None and lon is not None and land_checker(lat, lon): + land_count += 1 + if land_count > 0: + errors.append( + f"{fname}: {land_count} waypoint(s) on land " + f"(checked {len(range(0, len(waypoints), step))} points)" + ) + + return errors, land_count + + +# ─── ERA5 loading (self-contained, numpy + netCDF4 only) ───────────── + + +def _load_era5_grid(nc_paths: list[str]) -> dict | None: + """Load and concatenate ERA5 NetCDF files into a numpy dict. + + Returns dict with keys ``data`` (dict of variable arrays), + ``lat``, ``lon``, ``times_h`` (1-D coordinate arrays), and + ``t0`` (numpy datetime64 of first timestamp). + """ + try: + import netCDF4 + except ImportError: + return None + + datasets = [] + for p in nc_paths: + ds = netCDF4.Dataset(p, "r") + datasets.append(ds) + + if not datasets: + return None + + ds0 = datasets[0] + + # Detect coordinate names + lat_name = "latitude" if "latitude" in ds0.variables else "lat" + lon_name = "longitude" if "longitude" in ds0.variables else "lon" + time_name = "valid_time" if "valid_time" in ds0.variables else "time" + + lat = np.array(ds0.variables[lat_name][:], dtype=np.float64) + lon = np.array(ds0.variables[lon_name][:], dtype=np.float64) + + # Detect data variable names (everything except coordinates) + coord_names = {lat_name, lon_name, time_name} + var_names = [v for v in ds0.variables if v not in coord_names] + + # Mapping from long ERA5 variable names to short names + LONG_TO_SHORT = { + "10m_u_component_of_wind": "u10", + "10m_v_component_of_wind": "v10", + "significant_height_of_combined_wind_waves_and_swell": "swh", + "mean_wave_direction": "mwd", + } + + # Concatenate along time across files + all_times = [] + all_data = {LONG_TO_SHORT.get(v, v): [] for v in var_names} + for ds in datasets: + t_name = "valid_time" if "valid_time" in ds.variables else "time" + t_var = ds.variables[t_name] + cal = getattr(t_var, "calendar", "standard") + times = netCDF4.num2date(t_var[:], t_var.units, cal) + all_times.extend(times) + for v in var_names: + short = LONG_TO_SHORT.get(v, v) + all_data[short].append(np.array(ds.variables[v][:], dtype=np.float32)) + + for ds in datasets: + ds.close() + + # Build time array in hours since first timestamp + t0 = np.datetime64(all_times[0]) + times_np = np.array([np.datetime64(t) for t in all_times]) + times_h = (times_np - t0) / np.timedelta64(1, "h") + times_h = times_h.astype(np.float64) + + # Concatenate data arrays along time (axis 0) + data = {} + for v in all_data: + arr = np.concatenate(all_data[v], axis=0) + # Replace NaN with 0 (ERA5 wave fields are NaN over land) + np.nan_to_num(arr, copy=False, nan=0.0) + data[v] = arr + + # Ensure ascending latitude + if lat[0] > lat[-1]: + lat = lat[::-1] + for v in data: + data[v] = data[v][:, ::-1, :] + + # Ensure ascending longitude + if lon[0] > lon[-1]: + lon = lon[::-1] + for v in data: + data[v] = data[v][:, :, ::-1] + + # Decompose angular variables into sin/cos for correct interpolation + if "mwd" in data: + mwd_rad = np.radians(data["mwd"]) + data["mwd_sin"] = np.sin(mwd_rad).astype(np.float32) + data["mwd_cos"] = np.cos(mwd_rad).astype(np.float32) + + return { + "data": data, + "lat": lat, + "lon": lon, + "times_h": times_h, + "t0": t0, + } + + +def _interp_era5( + grid: dict, + var_name: str, + query_lat: np.ndarray, + query_lon: np.ndarray, + query_t_h: np.ndarray, +) -> np.ndarray: + """Trilinear interpolation of an ERA5 variable at query points. + + Parameters + ---------- + grid : dict from ``_load_era5_grid`` + var_name : variable name in the grid + query_lat, query_lon : arrays of shape (N,) in degrees + query_t_h : array of shape (N,) — hours since grid t0 + + Returns array of shape (N,). + """ + arr = grid["data"][var_name] # (T, lat, lon) + lat = grid["lat"] + lon = grid["lon"] + times_h = grid["times_h"] + + dt = times_h[1] - times_h[0] if len(times_h) > 1 else 1.0 + dlat = lat[1] - lat[0] if len(lat) > 1 else 1.0 + dlon = lon[1] - lon[0] if len(lon) > 1 else 1.0 + + # Fractional indices + fi_t = (query_t_h - times_h[0]) / dt + fi_lat = (query_lat - lat[0]) / dlat + fi_lon = (query_lon - lon[0]) / dlon + + # Clamp to valid range + fi_t = np.clip(fi_t, 0, len(times_h) - 1) + fi_lat = np.clip(fi_lat, 0, len(lat) - 1) + fi_lon = np.clip(fi_lon, 0, len(lon) - 1) + + # Integer indices for trilinear interpolation + i0_t = np.clip(np.floor(fi_t).astype(int), 0, len(times_h) - 2) + i0_lat = np.clip(np.floor(fi_lat).astype(int), 0, len(lat) - 2) + i0_lon = np.clip(np.floor(fi_lon).astype(int), 0, len(lon) - 2) + + # Fractional parts + wt = (fi_t - i0_t).astype(np.float32) + wlat = (fi_lat - i0_lat).astype(np.float32) + wlon = (fi_lon - i0_lon).astype(np.float32) + + # Trilinear interpolation (8 corners) + result = np.zeros(len(query_lat), dtype=np.float32) + for dt_off in (0, 1): + for dlat_off in (0, 1): + for dlon_off in (0, 1): + w = ( + ((1 - wt) if dt_off == 0 else wt) + * ((1 - wlat) if dlat_off == 0 else wlat) + * ((1 - wlon) if dlon_off == 0 else wlon) + ) + it = np.clip(i0_t + dt_off, 0, arr.shape[0] - 1) + ila = np.clip(i0_lat + dlat_off, 0, arr.shape[1] - 1) + ilo = np.clip(i0_lon + dlon_off, 0, arr.shape[2] - 1) + result += w * arr[it, ila, ilo] + + return result.astype(np.float64) + + +def _interp_era5_angle( + grid: dict, + var_name: str, + query_lat: np.ndarray, + query_lon: np.ndarray, + query_t_h: np.ndarray, +) -> np.ndarray: + """Interpolate an angular ERA5 variable using sin/cos decomposition. + + Avoids the discontinuity at 0/360 degrees that breaks linear + interpolation of angular quantities (e.g. mean wave direction). + """ + sin_vals = _interp_era5(grid, f"{var_name}_sin", query_lat, query_lon, query_t_h) + cos_vals = _interp_era5(grid, f"{var_name}_cos", query_lat, query_lon, query_t_h) + return np.mod(np.degrees(np.arctan2(sin_vals, cos_vals)), 360.0) + + +# ─── RISE performance model (self-contained, numpy only) ───────────── + +# Constants +_KH = 969.0 / 226.0 # Hull resistance +_KA = 49.0 / 320.0 # Aerodynamic drag +_AW = 11.1395 # Wave added resistance amplitude +_KW = 125.0 / 432.0 # Wave added resistance decay +_KS = 27489.0 / 32000.0 # Wingsail thrust +_DEAD_ZONE_DEG = 10.0 # Wingsail dead zone + + +def _rise_power(tws, twa_deg, swh, mwa_deg, v, wps): + """Compute RISE power (kW) for arrays of segment values. + + All inputs are numpy arrays of shape (N,). + Returns power array of shape (N,). + """ + twa_rad = np.radians(twa_deg) + + # Hull resistance + p_hull = _KH * v**3 + + # Apparent wind + ux = tws * np.cos(twa_rad) + v + uy = tws * np.sin(twa_rad) + vr = np.sqrt(ux**2 + uy**2) + + # Aerodynamic drag + p_wind = _KA * v * (vr * ux - v**2) + + # Wave added resistance (center MWA to [-180, 180] before radians) + mwa_rad = np.radians(np.mod(mwa_deg + 180.0, 360.0) - 180.0) + p_wave = _AW * swh**2 * v**1.5 * np.exp(-_KW * np.abs(mwa_rad) ** 3) + + power = p_hull + p_wind + p_wave + + # Wingsail thrust (WPS only) + if wps: + awa_deg = np.degrees(np.arctan2(np.abs(uy), ux)) + sail_active = awa_deg >= _DEAD_ZONE_DEG + alpha = np.where(sail_active, np.radians(awa_deg - _DEAD_ZONE_DEG), 0.0) + sin_a = np.sin(alpha) + p_sail = _KS * sin_a * (1.0 + 0.15 * sin_a**2) * vr**2 * v + power = power - np.where(sail_active, p_sail, 0.0) + + return np.maximum(power, 0.0) + + +def _slerp( + lat1: float, + lon1: float, + lat2: float, + lon2: float, + f: float, +) -> tuple[float, float]: + """Spherical linear interpolation at fraction *f* in [0, 1].""" + phi1, lam1 = math.radians(lat1), math.radians(lon1) + phi2, lam2 = math.radians(lat2), math.radians(lon2) + p1 = np.array( + [ + math.cos(phi1) * math.cos(lam1), + math.cos(phi1) * math.sin(lam1), + math.sin(phi1), + ] + ) + p2 = np.array( + [ + math.cos(phi2) * math.cos(lam2), + math.cos(phi2) * math.sin(lam2), + math.sin(phi2), + ] + ) + dot = float(np.clip(np.dot(p1, p2), -1.0, 1.0)) + sigma = math.acos(dot) + if sigma < 1e-12: + lat = lat1 + f * (lat2 - lat1) + lon = lon1 + f * ((lon2 - lon1 + 180.0) % 360.0 - 180.0) + return lat, lon + a = math.sin((1 - f) * sigma) / math.sin(sigma) + b = math.sin(f * sigma) / math.sin(sigma) + p = a * p1 + b * p2 + lat = math.degrees(math.atan2(p[2], math.sqrt(p[0] ** 2 + p[1] ** 2))) + lon = math.degrees(math.atan2(p[1], p[0])) + return lat, lon + + +def _resample_waypoints( + waypoints: list[tuple[datetime, float, float]], + dt_minutes: float = EVAL_DT_MINUTES, +) -> list[tuple[datetime, float, float]]: + """Resample a track to uniform Δt₂ via great-circle interpolation.""" + if len(waypoints) < 2: + return list(waypoints) + dt_seconds = dt_minutes * 60.0 + result: list[tuple[datetime, float, float]] = [] + for i in range(len(waypoints) - 1): + t0, lat0, lon0 = waypoints[i] + t1, lat1, lon1 = waypoints[i + 1] + seg_seconds = (t1 - t0).total_seconds() + if seg_seconds <= 0: + result.append((t0, lat0, lon0)) + continue + n_sub = max(1, math.ceil(seg_seconds / dt_seconds)) + for j in range(n_sub): + f = j / n_sub + t = t0 + timedelta(seconds=f * seg_seconds) + lat, lon = _slerp(lat0, lon0, lat1, lon1, f) + result.append((t, lat, lon)) + result.append(waypoints[-1]) + return result + + +def _haversine_m(lat1, lon1, lat2, lon2): + """Haversine distance in metres between arrays of points.""" + R = 6_371_000.0 + dlat = np.radians(lat2 - lat1) + dlon = np.radians(lon2 - lon1) + lat1r = np.radians(lat1) + lat2r = np.radians(lat2) + a = np.sin(dlat / 2) ** 2 + np.cos(lat1r) * np.cos(lat2r) * np.sin(dlon / 2) ** 2 + return R * 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a)) + + +def _forward_bearing_deg(lat1, lon1, lat2, lon2): + """Forward bearing in degrees [0, 360) between arrays of points.""" + lat1r, lat2r = np.radians(lat1), np.radians(lat2) + dlon = np.radians(lon2 - lon1) + x = np.sin(dlon) * np.cos(lat2r) + y = np.cos(lat1r) * np.sin(lat2r) - np.sin(lat1r) * np.cos(lat2r) * np.cos(dlon) + return np.mod(np.degrees(np.arctan2(x, y)), 360.0) + + +# ─── ERA5 re-evaluation (self-contained) ───────────────────────────── + + +def try_load_era5_scorer(ref_dir: Path): + """Load ERA5 data from reference directory and return a scoring function. + + Returns a callable (case_id, waypoints, departure_str) -> energy_mwh, + or None if ERA5 files are missing or netCDF4 is not installed. + + Data is loaded lazily per corridor to limit peak memory usage. Only + one corridor's grids are kept in memory at a time. + """ + # Discover file paths without loading data + corridor_files: dict[str, dict[str, list[str]]] = {} + + for corridor in ("atlantic", "pacific"): + wind_2024 = ref_dir / f"era5_wind_{corridor}_2024.nc" + waves_2024 = ref_dir / f"era5_waves_{corridor}_2024.nc" + if not (wind_2024.exists() and waves_2024.exists()): + continue + + wind_files = [str(wind_2024)] + wave_files = [str(waves_2024)] + for suffix in ( + f"era5_wind_{corridor}_2025_01.nc", + f"era5_wind_{corridor}_2025.nc", + ): + p = ref_dir / suffix + if p.exists(): + wind_files.append(str(p)) + break + for suffix in ( + f"era5_waves_{corridor}_2025_01.nc", + f"era5_waves_{corridor}_2025.nc", + ): + p = ref_dir / suffix + if p.exists(): + wave_files.append(str(p)) + break + + corridor_files[corridor] = {"wind": wind_files, "waves": wave_files} + + if not corridor_files: + return None + + # Mutable cache: holds at most one corridor's grids at a time + _cache: dict[str, dict | None] = {"corridor": None, "wind": None, "waves": None} + + def _get_grids(corridor: str) -> tuple[dict, dict] | None: + """Return (wind_grid, wave_grid) for *corridor*, loading lazily.""" + if _cache["corridor"] != corridor: + # Release previous corridor data + _cache["wind"] = None + _cache["waves"] = None + gc.collect() + _cache["corridor"] = corridor + files = corridor_files.get(corridor) + if files is None: + return None + _cache["wind"] = _load_era5_grid(files["wind"]) + _cache["waves"] = _load_era5_grid(files["waves"]) + wind = _cache["wind"] + waves = _cache["waves"] + if wind is None or waves is None: + return None + return wind, waves + + def evaluate_route( + case_id: str, + waypoints: list[tuple[datetime, float, float]], + departure_str: str, + ) -> dict | None: + """Re-evaluate a single route using ERA5 + RISE model. + + Parameters + ---------- + case_id : one of the CASE_NAMES + waypoints : list of (datetime, lat_deg, lon_deg) tuples + departure_str : departure datetime as string + + Returns dict with energy_mwh, max_tws, max_hs, + wind_violation_segs, wave_violation_segs; or None. + """ + case_def = CASE_DEFS[case_id] + corridor = case_def["route"] + if corridor not in corridor_files: + return None + + grids = _get_grids(corridor) + if grids is None: + return None + wind_grid, wave_grid = grids + + n_wp = len(waypoints) + if n_wp < 2: + return None + + # Resample to uniform Δt₂ for integration-accuracy independence + waypoints = _resample_waypoints(waypoints, EVAL_DT_MINUTES) + n_wp = len(waypoints) + + lats = np.array([wp[1] for wp in waypoints]) + lons = np.array([wp[2] for wp in waypoints]) + + # Compute per-segment dt from actual waypoint timestamps + wp_times = np.array( + [np.datetime64(wp[0]) for wp in waypoints], dtype="datetime64[s]" + ) + seg_dt_h = ((wp_times[1:] - wp_times[:-1]) / np.timedelta64(1, "h")).astype( + np.float64 + ) + # Guard against zero-length segments + seg_dt_h = np.maximum(seg_dt_h, 1e-6) + + # Normalize lons to match ERA5 grid convention [0, 360) + grid_lon = wind_grid["lon"] + if grid_lon[0] >= 0 and grid_lon[-1] > 180: + # Grid is in [0, 360) — shift negative lons + lons = np.where(lons < 0, lons + 360, lons) + + # Segment midpoints (position) + mid_lat = (lats[:-1] + lats[1:]) / 2 + mid_lon = (lons[:-1] + lons[1:]) / 2 + + # Segment midpoint times (hours since grid t0) + dep_dt64 = wp_times[0] + dep_offset_h = float((dep_dt64 - wind_grid["t0"]) / np.timedelta64(1, "h")) + cum_h = np.cumsum(seg_dt_h) + seg_mid_h = dep_offset_h + cum_h - seg_dt_h / 2 + + # Interpolate weather at segment midpoints + u10 = _interp_era5(wind_grid, "u10", mid_lat, mid_lon, seg_mid_h) + v10 = _interp_era5(wind_grid, "v10", mid_lat, mid_lon, seg_mid_h) + swh = _interp_era5(wave_grid, "swh", mid_lat, mid_lon, seg_mid_h) + mwd = _interp_era5_angle(wave_grid, "mwd", mid_lat, mid_lon, seg_mid_h) + + # Ship speed (m/s) + seg_dist_m = _haversine_m(lats[:-1], lons[:-1], lats[1:], lons[1:]) + v_mps = seg_dist_m / (seg_dt_h * 3600.0) + + # Ship bearing (degrees) + bearing_deg = _forward_bearing_deg(lats[:-1], lons[:-1], lats[1:], lons[1:]) + + # True wind speed and angle relative to heading + tws = np.sqrt(u10**2 + v10**2) + wind_from_deg = np.mod(180.0 + np.degrees(np.arctan2(u10, v10)), 360.0) + twa_deg = np.mod(wind_from_deg - bearing_deg, 360.0) + + # Mean wave angle relative to heading + mwa_deg = np.mod(mwd - bearing_deg, 360.0) + + # RISE power at each segment + power_kw = _rise_power(tws, twa_deg, swh, mwa_deg, v_mps, case_def["wps"]) + + # Energy integration (per-segment dt) + energy_mwh = float(np.sum(power_kw * seg_dt_h) / 1000.0) + + # RISE official wheel comparison (optional) + wheel_energy_mwh = None + try: + from swopp3_performance_model import predict_no_wps, predict_with_wps + + predict_fn = predict_with_wps if case_def["wps"] else predict_no_wps + wheel_power = np.array( + [ + predict_fn( + float(tws[i]), + float(twa_deg[i]), + float(swh[i]), + float(mwa_deg[i]), + float(v_mps[i]), + ) + for i in range(len(tws)) + ] + ) + wheel_energy_mwh = float(np.sum(wheel_power * seg_dt_h) / 1000.0) + except ImportError: + pass + + # Violation counts and percentages + n_segs = len(tws) + wind_violation_segs = int(np.sum(tws > MAX_WIND_MPS)) + wave_violation_segs = int(np.sum(swh > MAX_HS_M)) + wind_violation_pct = wind_violation_segs / n_segs * 100.0 if n_segs else 0.0 + wave_violation_pct = wave_violation_segs / n_segs * 100.0 if n_segs else 0.0 + + result = { + "energy_mwh": energy_mwh, + "max_tws": float(np.max(tws)) if len(tws) > 0 else 0.0, + "max_hs": float(np.max(swh)) if len(swh) > 0 else 0.0, + "wind_violation_segs": wind_violation_segs, + "wave_violation_segs": wave_violation_segs, + "total_segs": n_segs, + "wind_violation_pct": round(wind_violation_pct, 3), + "wave_violation_pct": round(wave_violation_pct, 3), + } + if wheel_energy_mwh is not None: + result["wheel_energy_mwh"] = wheel_energy_mwh + return result + + return evaluate_route + + +# ─── Plot generation (matplotlib, optional) ────────────────────────── + + +def _try_import_matplotlib(): + """Import matplotlib with Agg backend. Returns (plt, True) or (None, False).""" + try: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + return plt, True + except ImportError: + return None, False + + +def _fig_to_base64(fig) -> str: + """Render a matplotlib figure to a base64-encoded PNG string.""" + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=150, bbox_inches="tight") + buf.seek(0) + encoded = base64.b64encode(buf.read()).decode("ascii") + buf.close() + return encoded + + +def _generate_energy_timeseries(plt, case_energies: dict, corridor: str) -> str | None: + """Generate an energy timeseries plot for one corridor. + + Parameters + ---------- + plt : matplotlib.pyplot module + case_energies : dict mapping case_id -> list of (departure_date, energy_mwh) + corridor : "atlantic" or "pacific" + + Returns base64 PNG string, or None if no data. + """ + prefix = "A" if corridor == "atlantic" else "P" + cases = [ + f"{prefix}O_WPS", + f"{prefix}O_noWPS", + f"{prefix}GC_WPS", + f"{prefix}GC_noWPS", + ] + labels = ["Opt WPS", "Opt noWPS", "GC WPS", "GC noWPS"] + colors = ["#2196F3", "#FF9800", "#4CAF50", "#F44336"] + + has_data = any(len(case_energies.get(c, [])) > 0 for c in cases) + if not has_data: + return None + + fig, ax = plt.subplots(figsize=(14, 5)) + for case_id, label, color in zip(cases, labels, colors, strict=False): + data = case_energies.get(case_id, []) + if not data: + continue + dates = [d for d, _ in data] + energies = [e for _, e in data] + ax.plot(dates, energies, linewidth=0.8, alpha=0.85, label=label, color=color) + + corridor_title = "Trans-Atlantic" if corridor == "atlantic" else "Trans-Pacific" + ax.set_title(f"Energy per Departure — {corridor_title}", fontsize=13) + ax.set_xlabel("Departure Date") + ax.set_ylabel("Energy (MWh)") + ax.legend(loc="upper right", fontsize=9) + ax.grid(True, alpha=0.3) + fig.autofmt_xdate() + result = _fig_to_base64(fig) + plt.close(fig) + return result + + +def _generate_route_spaghetti( + plt, case_routes: dict, corridor: str, wps: bool, land_polygons=None +) -> str | None: + """Generate a route spaghetti plot for one corridor/WPS combination. + + Parameters + ---------- + plt : matplotlib.pyplot module + case_routes : dict mapping case_id -> list of (lats_array, lons_array) + corridor : "atlantic" or "pacific" + wps : True for WPS cases, False for noWPS + land_polygons : list of shapely geometries for land, or None + + Returns base64 PNG string, or None if no data. + """ + prefix = "A" if corridor == "atlantic" else "P" + opt_case = f"{prefix}O_{'WPS' if wps else 'noWPS'}" + gc_case = f"{prefix}GC_{'WPS' if wps else 'noWPS'}" + + opt_routes = case_routes.get(opt_case, []) + gc_routes = case_routes.get(gc_case, []) + if not opt_routes and not gc_routes: + return None + + # Fixed bounding boxes per corridor (lon_min, lon_max, lat_min, lat_max) + # Pacific crosses antimeridian: plot in [0,360] convention + view = (-80, 5, 25, 60) if corridor == "atlantic" else (130, 250, 15, 55) + + lon_min, lon_max, lat_min, lat_max = view + is_pacific = corridor == "pacific" + + fig, ax = plt.subplots(figsize=(14, 6)) + ax.set_xlim(lon_min, lon_max) + ax.set_ylim(lat_min, lat_max) + + def _plot_lons(lons_list): + """For Pacific, keep lons in [0,360]; for Atlantic, keep as-is.""" + if is_pacific: + return [lon + 360 if lon < 0 else lon for lon in lons_list] + return list(lons_list) + + # Draw land polygons clipped to view + if land_polygons is not None: + from matplotlib.collections import PatchCollection + from matplotlib.patches import Polygon as MplPolygon + from shapely import affinity + from shapely.geometry import MultiPolygon, Polygon, box + + clip_box = box(lon_min - 1, lat_min - 1, lon_max + 1, lat_max + 1) + patches = [] + for geom in land_polygons: + geoms_to_try = [geom] + # For Pacific, also try shifting the geometry by +360 + if is_pacific: + geoms_to_try.append(affinity.translate(geom, xoff=360)) + for g in geoms_to_try: + try: + clipped = g.intersection(clip_box) + except Exception: + continue + if clipped.is_empty: + continue + polys = [] + if isinstance(clipped, MultiPolygon): + polys = list(clipped.geoms) + elif isinstance(clipped, Polygon): + polys = [clipped] + for poly in polys: + if poly.is_empty: + continue + xs, ys = poly.exterior.coords.xy + patches.append( + MplPolygon(list(zip(xs, ys, strict=False)), closed=True) + ) + if patches: + pc = PatchCollection( + patches, + facecolor="#E8E0D8", + edgecolor="#B0A090", + linewidth=0.3, + zorder=0, + ) + ax.add_collection(pc) + + # Set ocean background + ax.set_facecolor("#D6EAF8") + + # Plot GC routes first (background, grey) + for lats, lons in gc_routes[:1]: + ax.plot( + _plot_lons(lons), lats, color="#BDBDBD", linewidth=0.5, alpha=0.6, zorder=1 + ) + if gc_routes: + ax.plot([], [], color="#BDBDBD", linewidth=1, label="Great Circle") + + # Plot optimised routes + for _i, (lats, lons) in enumerate(opt_routes): + ax.plot( + _plot_lons(lons), lats, color="#1565C0", linewidth=0.3, alpha=0.15, zorder=2 + ) + if opt_routes: + ax.plot([], [], color="#1565C0", linewidth=1.5, alpha=0.7, label="Optimised") + + # Mark ports + case_def = CASE_DEFS[opt_case] + src_lat, src_lon = case_def["src"] + dst_lat, dst_lon = case_def["dst"] + p_src_lon = _plot_lons([src_lon])[0] + p_dst_lon = _plot_lons([dst_lon])[0] + ax.plot(p_src_lon, src_lat, "go", markersize=8, zorder=5, label="Departure") + ax.plot(p_dst_lon, dst_lat, "rs", markersize=8, zorder=5, label="Arrival") + + wps_label = "with WPS" if wps else "without WPS" + corridor_title = "Trans-Atlantic" if corridor == "atlantic" else "Trans-Pacific" + ax.set_title(f"Routes \u2014 {corridor_title} ({wps_label})", fontsize=13) + ax.set_xlabel("Longitude (\u00b0)") + ax.set_ylabel("Latitude (\u00b0)") + ax.legend(loc="upper right", fontsize=9) + ax.grid(True, alpha=0.3) + + # For Pacific, relabel x-axis ticks to show real geographic longitudes + if is_pacific: + import numpy as np + + ticks = np.arange(int(lon_min / 10) * 10, lon_max + 1, 10) + ax.set_xticks(ticks) + ax.set_xticklabels([f"{t - 360}°" if t > 180 else f"{t}°" for t in ticks]) + + ax.set_aspect("equal") + fig.tight_layout() + result = _fig_to_base64(fig) + plt.close(fig) + return result + + +def _write_detailed_results( + output_dir: Path, + scores: dict, + all_errors: list, + all_warnings: list, + case_energies: dict, + case_routes: dict, + case_violations: dict | None = None, + data_dir: Path | None = None, +): + """Write detailed_results.html with scores, diagnostics, and plots.""" + plt, has_mpl = _try_import_matplotlib() + + # Load land polygons for route plots + land_polygons = None + if has_mpl and data_dir is not None: + shapefile_path = data_dir / "ne_10m_land.shp" + if shapefile_path.exists(): + try: + import shapefile as shp + from shapely.geometry import shape + + sf = shp.Reader(str(shapefile_path)) + land_polygons = [shape(s) for s in sf.shapes()] + except Exception: + pass + + # Generate plot images + plots: list[tuple[str, str]] = [] + if has_mpl: + for corridor in ("atlantic", "pacific"): + img = _generate_energy_timeseries(plt, case_energies, corridor) + if img: + name = "Trans-Atlantic" if corridor == "atlantic" else "Trans-Pacific" + plots.append((f"Energy Timeseries — {name}", img)) + + for corridor in ("atlantic", "pacific"): + for wps in (True, False): + img = _generate_route_spaghetti( + plt, case_routes, corridor, wps, land_polygons=land_polygons + ) + if img: + name = ( + "Trans-Atlantic" if corridor == "atlantic" else "Trans-Pacific" + ) + wps_label = "WPS" if wps else "noWPS" + plots.append((f"Routes — {name} ({wps_label})", img)) + + # Build HTML + html_parts = [ + '', + "", + "

SWOPP3 — Detailed Results

", + ] + + # Scores table + html_parts.append("

Scores

") + for k, v in scores.items(): + ek = html_mod.escape(str(k)) + if isinstance(v, float) and v < 1e11: + html_parts.append(f"") + else: + html_parts.append( + f"" + ) + html_parts.append("
MetricValue
{ek}{v:,.4f}
{ek}{html_mod.escape(str(v))}
") + + # Warnings + if all_warnings: + html_parts.append(f'

Warnings ({len(all_warnings)})

    ') + for w in all_warnings: + html_parts.append(f"
  • {html_mod.escape(w)}
  • ") + html_parts.append("
") + + # Errors + if all_errors: + html_parts.append( + f'

Validation Errors ({len(all_errors)})

    ' + ) + for e in all_errors[:100]: # cap at 100 to keep HTML manageable + html_parts.append(f"
  • {html_mod.escape(e)}
  • ") + if len(all_errors) > 100: + html_parts.append(f"
  • ... and {len(all_errors) - 100} more
  • ") + html_parts.append("
") + else: + html_parts.append( + '

All validation checks passed ✓

' + ) + + # Violations summary + if case_violations: + has_any = any(len(v) > 0 for v in case_violations.values()) + if has_any: + # Check if any departure has wheel energy data + has_wheel = any( + "wheel_energy_mwh" in d + for deps in case_violations.values() + for d in deps + ) + html_parts.append("

Weather & Land Violations

") + header = ( + "" + "" + "" + "" + "" + "" + ) + if has_wheel: + header += "" + header += "" + html_parts.append(header) + for case_key, deps in sorted(case_violations.items()): + if not deps: + continue + n_dep = len(deps) + wind_d = sum(1 for d in deps if d.get("wind_violation_segs", 0) > 0) + wave_d = sum(1 for d in deps if d.get("wave_violation_segs", 0) > 0) + land_d = sum(1 for d in deps if d.get("land_count", 0) > 0) + avg_wind_pct = ( + sum(d.get("wind_violation_pct", 0.0) for d in deps) / n_dep + ) + avg_wave_pct = ( + sum(d.get("wave_violation_pct", 0.0) for d in deps) / n_dep + ) + ek = html_mod.escape(case_key) + wind_cls = ' class="err"' if wind_d else ' class="ok"' + wave_cls = ' class="err"' if wave_d else ' class="ok"' + land_cls = ' class="err"' if land_d else ' class="ok"' + row = ( + f"" + f"{wind_d}" + f"" + f"{wave_d}" + f"" + f"{land_d}" + ) + if has_wheel: + wheel_deps = [ + d["wheel_energy_mwh"] for d in deps if "wheel_energy_mwh" in d + ] + if wheel_deps: + total_wheel = sum(wheel_deps) + row += f"" + else: + row += "" + row += "" + html_parts.append(row) + html_parts.append("
CaseDeparturesWind (>20 m/s)Avg Wind %Wave (>7 m)Avg Wave %LandWheel Energy (MWh)
{ek}{n_dep}{avg_wind_pct:.1f}%{avg_wave_pct:.1f}%{total_wheel:.1f}N/A
") + + # Plots + if plots: + html_parts.append("

Figures

") + for title, img_b64 in plots: + safe_title = html_mod.escape(title) + html_parts.append(f"

{safe_title}

") + html_parts.append( + f'{safe_title}' + ) + + html_parts.append("") + + html_path = output_dir / "detailed_results.html" + html_path.write_text("\n".join(html_parts)) + + +# ─── Main scoring logic ───────────────────────────────────────────── + + +def score_submission() -> dict: + """Validate and score a SWOPP3 submission.""" + all_errors: list[str] = [] + all_warnings: list[str] = [] + scores: dict[str, float] = {} + + # Data collectors for detailed results + case_energies: dict[str, list] = {c: [] for c in CASE_NAMES} + case_routes: dict[str, list] = {c: [] for c in CASE_NAMES} + case_violations: dict[str, list] = {c: [] for c in CASE_NAMES} + + # Detect team prefix + team_prefix = find_team_prefix(submission_dir) + if team_prefix is None: + all_errors.append("Cannot detect team prefix from CSV filenames") + scores["total_energy_mwh"] = 1e12 + scores["validation_errors"] = 1 + for case in CASE_NAMES: + scores[f"{case}_energy_mwh"] = 1e12 + return scores + + # Try to load land checker + land_checker = _load_land_checker(data_dir) + if land_checker is None: + all_warnings.append("Land shapefile not found — skipping land crossing checks") + + # Try to load ERA5 scorer + era5_scorer = try_load_era5_scorer(data_dir) + if era5_scorer is None: + all_warnings.append( + "ERA5 data not found in reference — using self-reported energy" + ) + + total_energy = 0.0 + + for case in CASE_NAMES: + file_a_path = submission_dir / f"{team_prefix}-{case}.csv" + + # Validate File A + fa_errors, fa_rows = validate_file_a(file_a_path, case, EXPECTED_DEPARTURES) + all_errors.extend(fa_errors) + + if not file_a_path.exists(): + scores[f"{case}_energy_mwh"] = 1e12 + total_energy += 1e12 + continue + + # Load reported energies + try: + energies = [float(r["energy_cons_mwh"]) for r in fa_rows] + case_energy = sum(energies) + except (KeyError, ValueError) as exc: + all_errors.append(f"{case}: Cannot load energies: {exc}") + scores[f"{case}_energy_mwh"] = 1e12 + total_energy += 1e12 + continue + + # Validate File B (track files) and collect waypoints for re-eval + tracks_dir = submission_dir / "tracks" + re_eval_energy = 0.0 + re_eval_ok = era5_scorer is not None + missing_tracks = 0 + total_tracks = len(fa_rows) + for row in fa_rows: + fb_name = row.get("details_filename", "") + if not fb_name: + all_errors.append(f"{case}: Missing details_filename in File A row") + re_eval_ok = False + continue + + fb_path = ( + tracks_dir / fb_name + if tracks_dir.is_dir() + else submission_dir / fb_name + ) + + # Parse departure/arrival for endpoint time checks + dep_dt = arr_dt = None + try: + dep_dt = datetime.strptime(row["departure_time_utc"], DTFMT) + arr_dt = datetime.strptime(row["arrival_time_utc"], DTFMT) + except (ValueError, KeyError): + pass + + fb_errors, fb_land_count = validate_file_b( + fb_path, + case, + departure_dt=dep_dt, + arrival_dt=arr_dt, + land_checker=land_checker, + ) + all_errors.extend(fb_errors) + + # Attempt ERA5 re-evaluation for this departure + if re_eval_ok and fb_path.exists() and dep_dt is not None: + try: + with fb_path.open() as fbf: + wps_reader = csv.DictReader(fbf) + waypoints = [] + for wp_row in wps_reader: + t = datetime.strptime(wp_row["time_utc"], DTFMT) + lat = float(wp_row["lat_deg"]) + lon = float(wp_row["lon_deg"]) + waypoints.append((t, lat, lon)) + dep_str = dep_dt.strftime(DTFMT) + result = era5_scorer(case, waypoints, dep_str) + if result is not None: + e = result["energy_mwh"] + re_eval_energy += e + case_energies[case].append((dep_dt, e)) + violation_entry = { + "departure": dep_dt, + "energy_mwh": e, + "max_tws": result["max_tws"], + "max_hs": result["max_hs"], + "wind_violation_segs": result["wind_violation_segs"], + "wave_violation_segs": result["wave_violation_segs"], + "total_segs": result.get("total_segs", 0), + "wind_violation_pct": result.get("wind_violation_pct", 0.0), + "wave_violation_pct": result.get("wave_violation_pct", 0.0), + "land_count": fb_land_count, + } + if "wheel_energy_mwh" in result: + violation_entry["wheel_energy_mwh"] = result[ + "wheel_energy_mwh" + ] + case_violations[case].append(violation_entry) + # Collect route coords (subsample for memory) + lats = [wp[1] for wp in waypoints] + lons = [wp[2] for wp in waypoints] + step = max(1, len(lats) // 100) + case_routes[case].append( + (lats[::step] + [lats[-1]], lons[::step] + [lons[-1]]) + ) + else: + re_eval_ok = False + except Exception: + re_eval_ok = False + elif re_eval_ok: + # Track file missing or departure unknown — cannot re-evaluate + if not fb_path.exists(): + missing_tracks += 1 + re_eval_ok = False + + # Report missing track files for this case + if missing_tracks > 0: + all_errors.append( + f"{case}: {missing_tracks} of {total_tracks} track files " + f"missing from tracks/ directory. Ensure all File B CSVs " + f"referenced in the details_filename column of File A are " + f"included in the tracks/ folder of your submission zip." + ) + + # Use re-evaluated energy when successful, else fall back + if re_eval_ok and era5_scorer is not None: + case_energy_final = re_eval_energy + elif era5_scorer is None: + # ERA5 unavailable — use self-reported energy + case_energy_final = case_energy + else: + # ERA5 available but re-evaluation failed — penalty + case_energy_final = 1e12 + if missing_tracks > 0: + all_errors.append( + f"{case}: PENALTY — energy set to 1e12 because " + f"{missing_tracks} track file(s) are missing. " + f"All {total_tracks} departures must have " + f"corresponding track files for ERA5 re-evaluation." + ) + else: + all_errors.append( + f"{case}: PENALTY — energy set to 1e12 because " + f"ERA5 re-evaluation could not be completed. " + f"Check that track files have valid waypoints " + f"(time_utc, lat_deg, lon_deg columns)." + ) + + scores[f"{case}_energy_mwh"] = round(case_energy_final, 4) + scores[f"{case}_reported_mwh"] = round(case_energy, 4) + total_energy += case_energy_final + + # Per-case violation summary from ERA5 re-evaluation + cv = case_violations[case] + if cv: + wind_deps = sum(1 for v in cv if v["wind_violation_segs"] > 0) + wave_deps = sum(1 for v in cv if v["wave_violation_segs"] > 0) + land_deps = sum(1 for v in cv if v["land_count"] > 0) + scores[f"{case}_wind_violation_departures"] = wind_deps + scores[f"{case}_wave_violation_departures"] = wave_deps + scores[f"{case}_land_violation_departures"] = land_deps + # Avg violation percentage across departures + n_cv = len(cv) + avg_wind_pct = sum(v.get("wind_violation_pct", 0.0) for v in cv) / n_cv + avg_wave_pct = sum(v.get("wave_violation_pct", 0.0) for v in cv) / n_cv + scores[f"{case}_avg_wind_violation_pct"] = round(avg_wind_pct, 2) + scores[f"{case}_avg_wave_violation_pct"] = round(avg_wave_pct, 2) + # Wheel energy total (if available) + wheel_vals = [v["wheel_energy_mwh"] for v in cv if "wheel_energy_mwh" in v] + if wheel_vals: + scores[f"{case}_wheel_energy_mwh"] = round(sum(wheel_vals), 4) + + # ── Cross-case consistency checks ── + wps_pairs = [ + ("AO_WPS", "AO_noWPS"), + ("AGC_WPS", "AGC_noWPS"), + ("PO_WPS", "PO_noWPS"), + ("PGC_WPS", "PGC_noWPS"), + ] + for wps_case, nowps_case in wps_pairs: + wps_e = scores.get(f"{wps_case}_energy_mwh", 0) + nowps_e = scores.get(f"{nowps_case}_energy_mwh", 0) + if wps_e > nowps_e + 1e-3 and nowps_e < 1e12: + all_warnings.append( + f"WPS energy ({wps_case}={wps_e:.1f}) > noWPS energy " + f"({nowps_case}={nowps_e:.1f})" + ) + + scores["total_energy_mwh"] = round(total_energy, 4) + scores["validation_errors"] = len(all_errors) + + # Write detailed log + log_path = output_dir / "scoring_log.txt" + with log_path.open("w") as f: + if all_warnings: + f.write(f"WARNINGS: {len(all_warnings)}\n") + for w in all_warnings: + f.write(f" ⚠ {w}\n") + f.write("\n") + + if all_errors: + f.write(f"VALIDATION: {len(all_errors)} issue(s) found\n") + for err in all_errors: + f.write(f" ✗ {err}\n") + else: + f.write("VALIDATION: All checks passed ✓\n") + + f.write("\nSCORES:\n") + for k, v in scores.items(): + f.write(f" {k}: {v}\n") + + # Write detailed results with plots + _write_detailed_results( + output_dir, + scores, + all_errors, + all_warnings, + case_energies, + case_routes, + case_violations, + data_dir=data_dir, + ) + + return scores + + +# ─── Entry point ───────────────────────────────────────────────────── + +if __name__ == "__main__": + scores = score_submission() + + scores_path = output_dir / "scores.json" + with scores_path.open("w") as f: + json.dump(scores, f, indent=2) + + print(f"Scores written to {scores_path}") + for k, v in scores.items(): + print(f" {k}: {v}") diff --git a/codabench/starting_kit/starting_kit.py b/codabench/starting_kit/starting_kit.py new file mode 100755 index 00000000..ca175785 --- /dev/null +++ b/codabench/starting_kit/starting_kit.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python +"""SWOPP3 Starting Kit — Great Circle Baseline. + +This script generates a valid submission using the simplest possible +strategy: great-circle routes at constant speed. No external libraries +required beyond the Python standard library. + +Replace the great-circle route with your optimizer's output to improve +energy efficiency. Energy values here are placeholders (0.0) — the +CodaBench scorer will re-evaluate all routes using the official RISE +model and ERA5 data. + +Usage +----- +:: + + python starting_kit.py --output-dir my_submission + +Then zip the output directory and upload to CodaBench. +""" + +from __future__ import annotations + +import argparse +import csv +import math +from datetime import UTC, datetime, timedelta +from pathlib import Path + +# ─── Configuration ─────────────────────────────────────────────────── + +TEAM = "MyTeam" +SUBMISSION = 1 +N_WAYPOINTS = 100 # Number of waypoints in the route + +# Case definitions: (src_lat, src_lon), (dst_lat, dst_lon), passage_hours +CASES = { + "AO_WPS": { + "src": (43.6, -4.0), + "dst": (40.53, -73.80), + "passage_h": 354, + "wps": True, + }, + "AO_noWPS": { + "src": (43.6, -4.0), + "dst": (40.53, -73.80), + "passage_h": 354, + "wps": False, + }, + "AGC_WPS": { + "src": (43.6, -4.0), + "dst": (40.53, -73.80), + "passage_h": 354, + "wps": True, + }, + "AGC_noWPS": { + "src": (43.6, -4.0), + "dst": (40.53, -73.80), + "passage_h": 354, + "wps": False, + }, + "PO_WPS": { + "src": (34.8, 140.0), + "dst": (34.4, -121.0), + "passage_h": 583, + "wps": True, + }, + "PO_noWPS": { + "src": (34.8, 140.0), + "dst": (34.4, -121.0), + "passage_h": 583, + "wps": False, + }, + "PGC_WPS": { + "src": (34.8, 140.0), + "dst": (34.4, -121.0), + "passage_h": 583, + "wps": True, + }, + "PGC_noWPS": { + "src": (34.8, 140.0), + "dst": (34.4, -121.0), + "passage_h": 583, + "wps": False, + }, +} + + +def great_circle_waypoints( + src_lat: float, + src_lon: float, + dst_lat: float, + dst_lon: float, + n_points: int, +) -> list[tuple[float, float]]: + """Generate waypoints along a great-circle path. + + Returns list of (lat_deg, lon_deg) tuples. + """ + lat1 = math.radians(src_lat) + lon1 = math.radians(src_lon) + lat2 = math.radians(dst_lat) + lon2 = math.radians(dst_lon) + + d = math.acos( + math.sin(lat1) * math.sin(lat2) + + math.cos(lat1) * math.cos(lat2) * math.cos(lon2 - lon1) + ) + + waypoints = [] + for i in range(n_points): + f = i / (n_points - 1) if n_points > 1 else 0.0 + if d < 1e-10: + waypoints.append((src_lat, src_lon)) + continue + a = math.sin((1 - f) * d) / math.sin(d) + b = math.sin(f * d) / math.sin(d) + x = a * math.cos(lat1) * math.cos(lon1) + b * math.cos(lat2) * math.cos(lon2) + y = a * math.cos(lat1) * math.sin(lon1) + b * math.cos(lat2) * math.sin(lon2) + z = a * math.sin(lat1) + b * math.sin(lat2) + lat = math.degrees(math.atan2(z, math.sqrt(x**2 + y**2))) + lon = math.degrees(math.atan2(y, x)) + waypoints.append((lat, lon)) + + return waypoints + + +def haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Haversine distance in nautical miles.""" + R_NM = 3440.065 # Earth radius in nm + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = ( + math.sin(dlat / 2) ** 2 + + math.cos(math.radians(lat1)) + * math.cos(math.radians(lat2)) + * math.sin(dlon / 2) ** 2 + ) + return R_NM * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + +def main() -> None: + """Generate a great-circle baseline submission for all SWOPP3 cases.""" + parser = argparse.ArgumentParser(description="SWOPP3 baseline: great circle.") + parser.add_argument("--output-dir", default="submission", help="Output directory.") + parser.add_argument("--team", default=TEAM, help="Team name.") + parser.add_argument( + "--submission", type=int, default=SUBMISSION, help="Submission #." + ) + args = parser.parse_args() + + output_dir = Path(args.output_dir) + tracks_dir = output_dir / "tracks" + tracks_dir.mkdir(parents=True, exist_ok=True) + + # 366 daily departures at noon UTC throughout 2024 + departures = [ + datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) + timedelta(days=d) + for d in range(366) + ] + dtfmt = "%Y-%m-%d %H:%M:%S" + + for case_id, case in CASES.items(): + print(f"\nCase: {case_id}") + + src_lat, src_lon = case["src"] + dst_lat, dst_lon = case["dst"] + passage_h = case["passage_h"] + + gc = great_circle_waypoints(src_lat, src_lon, dst_lat, dst_lon, N_WAYPOINTS) + + # Total sailed distance (nm) + dist_nm = sum( + haversine_nm(gc[i][0], gc[i][1], gc[i + 1][0], gc[i + 1][1]) + for i in range(len(gc) - 1) + ) + + file_a_rows = [] + for dep_idx, departure in enumerate(departures): + dep_str = departure.strftime("%Y%m%d") + arrival = departure + timedelta(hours=passage_h) + fb_name = f"{args.team}-{args.submission}-{case_id}-{dep_str}.csv" + + file_a_rows.append( + { + "departure_time_utc": departure.strftime(dtfmt), + "arrival_time_utc": arrival.strftime(dtfmt), + "energy_cons_mwh": "0.000000", # placeholder — scorer re-evaluates + "max_wind_mps": "0.0000", + "max_hs_m": "0.0000", + "sailed_distance_nm": f"{dist_nm:.4f}", + "details_filename": fb_name, + } + ) + + # Write File B (track waypoints) + fb_path = tracks_dir / fb_name + total_seconds = passage_h * 3600 + with fb_path.open("w", newline="") as f: + writer = csv.DictWriter( + f, fieldnames=["time_utc", "lat_deg", "lon_deg"] + ) + writer.writeheader() + for wp_idx, (wlat, wlon) in enumerate(gc): + t = departure + timedelta( + seconds=total_seconds * wp_idx / (N_WAYPOINTS - 1) + ) + writer.writerow( + { + "time_utc": t.strftime(dtfmt), + "lat_deg": f"{wlat:.6f}", + "lon_deg": f"{wlon:.6f}", + } + ) + + if (dep_idx + 1) % 100 == 0: + print(f" {dep_idx + 1}/366 departures done") + + # Write File A + fa_name = f"{args.team}-{args.submission}-{case_id}.csv" + fa_path = output_dir / fa_name + columns = [ + "departure_time_utc", + "arrival_time_utc", + "energy_cons_mwh", + "max_wind_mps", + "max_hs_m", + "sailed_distance_nm", + "details_filename", + ] + with fa_path.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=columns) + writer.writeheader() + writer.writerows(file_a_rows) + + print(f" → {fa_name} written ({dist_nm:.0f} nm)") + + print(f"\nDone! Submission in {output_dir}/") + print("Zip this directory and upload to CodaBench.") + print("Energy values are placeholders — the scorer will re-evaluate all routes.") + + +if __name__ == "__main__": + main() diff --git a/config.toml b/config.toml index a01f8de7..35453f96 100644 --- a/config.toml +++ b/config.toml @@ -6,7 +6,7 @@ ylim = [-1, 6] travel_stw = 1 [vectorfield.circular] -src = [0.8660254037844386, 0.5] # [cos(pi/6), sin(pi/6)] +src = [0.8660254037844386, 0.5] # [cos(pi/6), sin(pi/6)] dst = [0, 1] xlim = [-0.5, 1.0] ylim = [0.0, 1.5] @@ -35,7 +35,7 @@ ylim = [-1, 6] travel_time = 30 [vectorfield.techy] -src = [0.8660254037844386, 0.5] # [cos(pi/6), sin(pi/6)] +src = [0.8660254037844386, 0.5] # [cos(pi/6), sin(pi/6)] dst = [0, 1] xlim = [-1, 1] ylim = [-0.5, 1.5] @@ -45,7 +45,7 @@ travel_stw = 1 water_level = [1.0, 0.9, 0.8, 0.7] resolution = [3, 4, 5] random_seed = [0, 1, 2, 3, 4, 5] -penalty = [100] +penalty = [10000000000] penalty_init = [0] penalty_increment = [1] maxfevals = [5] @@ -65,3 +65,467 @@ maxfevals = [50000] patience = [50] damping = [0.5] maxfevals = [50000] + +# Shell recipes capture reference parameters for helper shell scripts. They are +# kept here so the script settings live next to the Python config, but +# scripts/swopp3_run.py does not read this section. +[shell_recipes.activate_gpu] +source_script = "scripts/activate_gpu.sh" +description = "Shell environment defaults for GPU-first JAX sessions." +jax_platform_name = "cuda" +cuda_visible_devices = "0" +xla_python_client_preallocate = "false" + +[shell_recipes.parameter_search] +source_script = "scripts/parameter_search.sh" +description = "Chunked parameter search launcher for scripts/parameter_search.py." +batch_size = 10 +total_combinations = 81000 +config_path = "config.toml" +results_dir = "output" + +[shell_recipes.run_results] +source_script = "scripts/run_results.sh" +description = "Restart loop for the realworld results pipeline with faulthandler logging." +logfile = "/home/SHARED/routetools/run_results.log" +restart_delay_seconds = 10 +commands = [ + "uv run python -X faulthandler scripts/realworld/results.py", + "uv run python -X faulthandler scripts/realworld/figures.py", + "uv run python -X faulthandler scripts/realworld/tables.py", +] + +[swopp3.experiments.swopp3_0125_rust] +description = "Full 8-case SWOPP3 run on 0.125 degree ERA5 data in CPU mode." +source_script = "scripts/swopp3_slurm.sh" +output_dir = "output/swopp3_0125_rust" + +[swopp3.experiments.swopp3_0125_rust.defaults] +wind_path_atlantic = "data/era5_0125/era5_wind_atlantic_2024.nc" +wave_path_atlantic = "data/era5_0125/era5_waves_atlantic_2024.nc" +wind_path_pacific = "data/era5_0125/era5_wind_pacific_2024.nc" +wave_path_pacific = "data/era5_0125/era5_waves_pacific_2024.nc" + +[[swopp3.experiments.swopp3_0125_rust.runs]] +name = "full_run" + +[swopp3.experiments.swopp3_0125_gpu] +description = "Full 8-case SWOPP3 run on 0.125 degree ERA5 data in GPU mode." +source_script = "scripts/swopp3_slurm_gpu.sh" +output_dir = "output/swopp3_0125_gpu" + +[swopp3.experiments.swopp3_0125_gpu.defaults] +wind_path_atlantic = "data/era5_0125/era5_wind_atlantic_2024.nc" +wave_path_atlantic = "data/era5_0125/era5_waves_atlantic_2024.nc" +wind_path_pacific = "data/era5_0125/era5_wind_pacific_2024.nc" +wave_path_pacific = "data/era5_0125/era5_waves_pacific_2024.nc" + +[[swopp3.experiments.swopp3_0125_gpu.runs]] +name = "full_run" + +[swopp3.experiments.hard_penalty] +description = "Full SWOPP3 run with hard weather penalties on separate Atlantic and Pacific meshes." +source_script = "scripts/swopp3_slurm_hard_penalty.sh" +output_dir = "output/swopp3_hard_penalty" + +[swopp3.experiments.hard_penalty.defaults] +weather_penalty_weight = 10.0 +distance_penalty_weight = 10.0 +dt_eval_minutes = 30.0 + +[[swopp3.experiments.hard_penalty.runs]] +name = "atlantic" +cases = ["AO_WPS", "AO_noWPS", "AGC_WPS", "AGC_noWPS"] +n_points = 178 + +[[swopp3.experiments.hard_penalty.runs]] +name = "pacific" +cases = ["PO_WPS", "PO_noWPS", "PGC_WPS", "PGC_noWPS"] +n_points = 293 + +[swopp3.experiments.k15_p400_w1000] +description = "Kitchen-sink optimised-only run with K=15, popsize=400, and wind/wave penalties of 1000." +source_script = "scripts/swopp3_slurm_k15_popsize400.sh" +output_dir = "output/swopp3_k15_p400_w1000" + +[swopp3.experiments.k15_p400_w1000.defaults] +strategy = "optimised" +cmaes_k = 15 +popsize = 400 +maxfevals = 50000 +dt_eval_minutes = 30.0 +wind_penalty_weight = 1000.0 +wave_penalty_weight = 1000.0 +distance_penalty_weight = 10.0 +cmaes_verbose = true + +[[swopp3.experiments.k15_p400_w1000.runs]] +name = "ao_wps" +cases = ["AO_WPS"] +n_points = 178 + +[[swopp3.experiments.k15_p400_w1000.runs]] +name = "ao_no_wps" +cases = ["AO_noWPS"] +n_points = 178 + +[[swopp3.experiments.k15_p400_w1000.runs]] +name = "po_wps" +cases = ["PO_WPS"] +n_points = 293 + +[[swopp3.experiments.k15_p400_w1000.runs]] +name = "po_no_wps" +cases = ["PO_noWPS"] +n_points = 293 + +[swopp3.experiments.k15_w200] +description = "K=15 smooth-penalty experiment with wind and wave weights set to 200." +source_script = "scripts/swopp3_slurm_k15_sweep.sh" +output_dir = "output/swopp3_k15_w200" + +[swopp3.experiments.k15_w200.defaults] +cmaes_k = 15 +dt_eval_minutes = 30.0 +wind_penalty_weight = 200.0 +wave_penalty_weight = 200.0 +distance_penalty_weight = 10.0 + +[[swopp3.experiments.k15_w200.runs]] +name = "atlantic" +cases = ["AO_WPS", "AO_noWPS", "AGC_WPS", "AGC_noWPS"] +n_points = 178 + +[[swopp3.experiments.k15_w200.runs]] +name = "pacific" +cases = ["PO_WPS", "PO_noWPS", "PGC_WPS", "PGC_noWPS"] +n_points = 293 + +[swopp3.experiments.k15_w500] +description = "K=15 smooth-penalty experiment with wind and wave weights set to 500." +source_script = "scripts/swopp3_slurm_k15_sweep.sh" +output_dir = "output/swopp3_k15_w500" + +[swopp3.experiments.k15_w500.defaults] +cmaes_k = 15 +dt_eval_minutes = 30.0 +wind_penalty_weight = 500.0 +wave_penalty_weight = 500.0 +distance_penalty_weight = 10.0 + +[[swopp3.experiments.k15_w500.runs]] +name = "atlantic" +cases = ["AO_WPS", "AO_noWPS", "AGC_WPS", "AGC_noWPS"] +n_points = 178 + +[[swopp3.experiments.k15_w500.runs]] +name = "pacific" +cases = ["PO_WPS", "PO_noWPS", "PGC_WPS", "PGC_noWPS"] +n_points = 293 + +[swopp3.experiments.max_sweep_w5] +description = "Max-based penalty sweep experiment with wind and wave weights set to 5." +source_script = "scripts/swopp3_slurm_max_penalty_sweep.sh" +output_dir = "output/swopp3_max_sweep_w5" + +[swopp3.experiments.max_sweep_w5.defaults] +strategy = "optimised" +dt_eval_minutes = 30.0 +wind_penalty_weight = 5.0 +wave_penalty_weight = 5.0 +distance_penalty_weight = 10.0 + +[[swopp3.experiments.max_sweep_w5.runs]] +name = "ao_wps" +cases = ["AO_WPS"] +n_points = 178 + +[[swopp3.experiments.max_sweep_w5.runs]] +name = "ao_no_wps" +cases = ["AO_noWPS"] +n_points = 178 + +[[swopp3.experiments.max_sweep_w5.runs]] +name = "po_wps" +cases = ["PO_WPS"] +n_points = 293 + +[[swopp3.experiments.max_sweep_w5.runs]] +name = "po_no_wps" +cases = ["PO_noWPS"] +n_points = 293 + +[swopp3.experiments.max_sweep_w10] +description = "Max-based penalty sweep experiment with wind and wave weights set to 10." +source_script = "scripts/swopp3_slurm_max_penalty_sweep.sh" +output_dir = "output/swopp3_max_sweep_w10" + +[swopp3.experiments.max_sweep_w10.defaults] +strategy = "optimised" +dt_eval_minutes = 30.0 +wind_penalty_weight = 10.0 +wave_penalty_weight = 10.0 +distance_penalty_weight = 10.0 + +[[swopp3.experiments.max_sweep_w10.runs]] +name = "ao_wps" +cases = ["AO_WPS"] +n_points = 178 + +[[swopp3.experiments.max_sweep_w10.runs]] +name = "ao_no_wps" +cases = ["AO_noWPS"] +n_points = 178 + +[[swopp3.experiments.max_sweep_w10.runs]] +name = "po_wps" +cases = ["PO_WPS"] +n_points = 293 + +[[swopp3.experiments.max_sweep_w10.runs]] +name = "po_no_wps" +cases = ["PO_noWPS"] +n_points = 293 + +[swopp3.experiments.max_sweep_w25] +description = "Max-based penalty sweep experiment with wind and wave weights set to 25." +source_script = "scripts/swopp3_slurm_max_penalty_sweep.sh" +output_dir = "output/swopp3_max_sweep_w25" + +[swopp3.experiments.max_sweep_w25.defaults] +strategy = "optimised" +dt_eval_minutes = 30.0 +wind_penalty_weight = 25.0 +wave_penalty_weight = 25.0 +distance_penalty_weight = 10.0 + +[[swopp3.experiments.max_sweep_w25.runs]] +name = "ao_wps" +cases = ["AO_WPS"] +n_points = 178 + +[[swopp3.experiments.max_sweep_w25.runs]] +name = "ao_no_wps" +cases = ["AO_noWPS"] +n_points = 178 + +[[swopp3.experiments.max_sweep_w25.runs]] +name = "po_wps" +cases = ["PO_WPS"] +n_points = 293 + +[[swopp3.experiments.max_sweep_w25.runs]] +name = "po_no_wps" +cases = ["PO_noWPS"] +n_points = 293 + +[swopp3.experiments.max_sweep_w50] +description = "Max-based penalty sweep experiment with wind and wave weights set to 50." +source_script = "scripts/swopp3_slurm_max_penalty_sweep.sh" +output_dir = "output/swopp3_max_sweep_w50" + +[swopp3.experiments.max_sweep_w50.defaults] +strategy = "optimised" +dt_eval_minutes = 30.0 +wind_penalty_weight = 50.0 +wave_penalty_weight = 50.0 +distance_penalty_weight = 10.0 + +[[swopp3.experiments.max_sweep_w50.runs]] +name = "ao_wps" +cases = ["AO_WPS"] +n_points = 178 + +[[swopp3.experiments.max_sweep_w50.runs]] +name = "ao_no_wps" +cases = ["AO_noWPS"] +n_points = 178 + +[[swopp3.experiments.max_sweep_w50.runs]] +name = "po_wps" +cases = ["PO_WPS"] +n_points = 293 + +[[swopp3.experiments.max_sweep_w50.runs]] +name = "po_no_wps" +cases = ["PO_noWPS"] +n_points = 293 + +[swopp3.experiments.max_sweep_w100] +description = "Max-based penalty sweep experiment with wind and wave weights set to 100." +source_script = "scripts/swopp3_slurm_max_penalty_sweep.sh" +output_dir = "output/swopp3_max_sweep_w100" + +[swopp3.experiments.max_sweep_w100.defaults] +strategy = "optimised" +dt_eval_minutes = 30.0 +wind_penalty_weight = 100.0 +wave_penalty_weight = 100.0 +distance_penalty_weight = 10.0 + +[[swopp3.experiments.max_sweep_w100.runs]] +name = "ao_wps" +cases = ["AO_WPS"] +n_points = 178 + +[[swopp3.experiments.max_sweep_w100.runs]] +name = "ao_no_wps" +cases = ["AO_noWPS"] +n_points = 178 + +[[swopp3.experiments.max_sweep_w100.runs]] +name = "po_wps" +cases = ["PO_WPS"] +n_points = 293 + +[[swopp3.experiments.max_sweep_w100.runs]] +name = "po_no_wps" +cases = ["PO_noWPS"] +n_points = 293 + +[swopp3.experiments.no_penalty] +description = "Full SWOPP3 run with zero weather penalties and a distance-to-land penalty of 10." +source_script = "scripts/swopp3_slurm_no_penalty.sh" +output_dir = "output/swopp3_no_penalty" + +[swopp3.experiments.no_penalty.defaults] +dt_eval_minutes = 30.0 +distance_penalty_weight = 10.0 + +[[swopp3.experiments.no_penalty.runs]] +name = "atlantic" +cases = ["AO_WPS", "AO_noWPS", "AGC_WPS", "AGC_noWPS"] +n_points = 178 + +[[swopp3.experiments.no_penalty.runs]] +name = "pacific" +cases = ["PO_WPS", "PO_noWPS", "PGC_WPS", "PGC_noWPS"] +n_points = 293 + +[swopp3.experiments.optimal_params] +description = "Full SWOPP3 run using the sweep-recommended CMA-ES parameters K=15 and sigma0=0.3." +source_script = "scripts/swopp3_slurm_optimal_params.sh" +output_dir = "output/swopp3_optimal_params" + +[swopp3.experiments.optimal_params.defaults] +cmaes_k = 15 +sigma0 = 0.3 +dt_eval_minutes = 30.0 +wind_penalty_weight = 10.0 +wave_penalty_weight = 10.0 +distance_penalty_weight = 10.0 + +[[swopp3.experiments.optimal_params.runs]] +name = "atlantic" +cases = ["AO_WPS", "AO_noWPS", "AGC_WPS", "AGC_noWPS"] +n_points = 178 + +[[swopp3.experiments.optimal_params.runs]] +name = "pacific" +cases = ["PO_WPS", "PO_noWPS", "PGC_WPS", "PGC_noWPS"] +n_points = 293 + +[swopp3.experiments.sweep_w50] +description = "Normalized-mean penalty sweep experiment with wind and wave weights set to 50." +source_script = "scripts/swopp3_slurm_penalty_sweep.sh" +output_dir = "output/swopp3_sweep_w50" + +[swopp3.experiments.sweep_w50.defaults] +dt_eval_minutes = 30.0 +wind_penalty_weight = 50.0 +wave_penalty_weight = 50.0 +distance_penalty_weight = 10.0 + +[[swopp3.experiments.sweep_w50.runs]] +name = "atlantic" +cases = ["AO_WPS", "AO_noWPS", "AGC_WPS", "AGC_noWPS"] +n_points = 178 + +[[swopp3.experiments.sweep_w50.runs]] +name = "pacific" +cases = ["PO_WPS", "PO_noWPS", "PGC_WPS", "PGC_noWPS"] +n_points = 293 + +[swopp3.experiments.sweep_w100] +description = "Normalized-mean penalty sweep experiment with wind and wave weights set to 100." +source_script = "scripts/swopp3_slurm_penalty_sweep.sh" +output_dir = "output/swopp3_sweep_w100" + +[swopp3.experiments.sweep_w100.defaults] +dt_eval_minutes = 30.0 +wind_penalty_weight = 100.0 +wave_penalty_weight = 100.0 +distance_penalty_weight = 10.0 + +[[swopp3.experiments.sweep_w100.runs]] +name = "atlantic" +cases = ["AO_WPS", "AO_noWPS", "AGC_WPS", "AGC_noWPS"] +n_points = 178 + +[[swopp3.experiments.sweep_w100.runs]] +name = "pacific" +cases = ["PO_WPS", "PO_noWPS", "PGC_WPS", "PGC_noWPS"] +n_points = 293 + +[swopp3.experiments.sweep_w200] +description = "Normalized-mean penalty sweep experiment with wind and wave weights set to 200." +source_script = "scripts/swopp3_slurm_penalty_sweep.sh" +output_dir = "output/swopp3_sweep_w200" + +[swopp3.experiments.sweep_w200.defaults] +dt_eval_minutes = 30.0 +wind_penalty_weight = 200.0 +wave_penalty_weight = 200.0 +distance_penalty_weight = 10.0 + +[[swopp3.experiments.sweep_w200.runs]] +name = "atlantic" +cases = ["AO_WPS", "AO_noWPS", "AGC_WPS", "AGC_noWPS"] +n_points = 178 + +[[swopp3.experiments.sweep_w200.runs]] +name = "pacific" +cases = ["PO_WPS", "PO_noWPS", "PGC_WPS", "PGC_noWPS"] +n_points = 293 + +[swopp3.experiments.sweep_w500] +description = "Normalized-mean penalty sweep experiment with wind and wave weights set to 500." +source_script = "scripts/swopp3_slurm_penalty_sweep.sh" +output_dir = "output/swopp3_sweep_w500" + +[swopp3.experiments.sweep_w500.defaults] +dt_eval_minutes = 30.0 +wind_penalty_weight = 500.0 +wave_penalty_weight = 500.0 +distance_penalty_weight = 10.0 + +[[swopp3.experiments.sweep_w500.runs]] +name = "atlantic" +cases = ["AO_WPS", "AO_noWPS", "AGC_WPS", "AGC_noWPS"] +n_points = 178 + +[[swopp3.experiments.sweep_w500.runs]] +name = "pacific" +cases = ["PO_WPS", "PO_noWPS", "PGC_WPS", "PGC_noWPS"] +n_points = 293 + +[swopp3.experiments.split_penalty] +description = "Full SWOPP3 run with split wind and wave penalties of 100." +source_script = "scripts/swopp3_slurm_split_penalty.sh" +output_dir = "output/swopp3_split_penalty" + +[swopp3.experiments.split_penalty.defaults] +dt_eval_minutes = 30.0 +wind_penalty_weight = 100.0 +wave_penalty_weight = 100.0 +distance_penalty_weight = 10.0 + +[[swopp3.experiments.split_penalty.runs]] +name = "atlantic" +cases = ["AO_WPS", "AO_noWPS", "AGC_WPS", "AGC_noWPS"] +n_points = 178 + +[[swopp3.experiments.split_penalty.runs]] +name = "pacific" +cases = ["PO_WPS", "PO_noWPS", "PGC_WPS", "PGC_noWPS"] +n_points = 293 diff --git a/config_land.toml b/config_land.toml index 189f828f..b3ffd2a2 100644 --- a/config_land.toml +++ b/config_land.toml @@ -13,7 +13,7 @@ random_seed = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49] -penalty = [100] +penalty = [10000000000] penalty_init = [0] penalty_increment = [1] maxfevals = [5] diff --git a/config_noland.toml b/config_noland.toml index 9f7ed93b..173a0ed5 100644 --- a/config_noland.toml +++ b/config_noland.toml @@ -38,7 +38,7 @@ travel_stw = 1 water_level = [1.0] resolution = [3] random_seed = [0] -penalty = [100] +penalty = [10000000000] penalty_init = [0] penalty_increment = [1] maxfevals = [5] diff --git a/docs/cmaes_diagnostic.md b/docs/cmaes_diagnostic.md new file mode 100644 index 00000000..2b905dd7 --- /dev/null +++ b/docs/cmaes_diagnostic.md @@ -0,0 +1,280 @@ +# CMA-ES Diagnostic: Issues Found During SWOPP3 Integration + +## Background + +The CMA-ES optimizer in `routetools` was designed for **ocean-current** routing — the vectorfield represents water velocity, and the cost functions model a vessel moving through a medium. SWOPP3 requires **wind-assisted** routing with a fixed passage time and ERA5 weather data (wind + waves). Several issues emerged when adapting CMA-ES for this scenario. + +--- + +## Issue 1: Time Offset — All Departures Sampled Weather at t=0 + +**Location:** `routetools/swopp3_runner.py` → `run_case()`, `routetools/cost.py` → `cost_function()` + +**Problem:** `departure_offset_h` was hardcoded to `0.0` in `run_case()`. All 366 departures optimised against January 1 weather regardless of their actual departure date. + +**Root cause:** `cost_function()` had no `time_offset` parameter. The time-variant cost functions computed segment timestamps starting from `t=0`. + +**Fix (committed `73e8b05`):** + +- Added `time_offset: float = 0.0` to `cost_function`, `cost_function_constant_cost_time_variant`, `_cma_evolution_strategy`, `optimize`, and `optimize_with_increasing_penalization` +- Made `time_offset` a **non-static** JAX argument (not in `static_argnames`) so changing it per-departure does not trigger JIT recompilation +- `run_case()` now computes offset from the dataset epoch: + +```python +# routetools/swopp3_runner.py — run_case() +departure_offset_h = (dep_naive - epoch_naive).total_seconds() / 3600.0 +``` + +**Status:** ✅ Fixed and verified — second departure takes 0.1s (JIT cache hit), different energy values confirm per-departure weather. + +--- + +## Issue 2: Wind Treated as Ocean Current (Fundamental Model Mismatch) + +**Location:** `routetools/cost.py` — all `cost_function_constant_cost_*` variants + +**Problem:** The "constant cost" (fixed travel time) cost functions compute: + +$$\text{cost} = \sum_i \frac{1}{2} \|\mathbf{v}_{\text{SOG},i} - \mathbf{w}_i\|^2 \cdot \Delta t$$ + +where $\mathbf{w}$ is the vectorfield (wind in SWOPP3). This formula interprets $\mathbf{w}$ as **medium velocity** (ocean current) — the ship's effort is its speed _through the water_ relative to the current. + +For ocean currents this is physically correct: a ship traveling with the current needs less thrust, against it needs more. But **wind does not transport the ship**. Wind acts on sails (WPS) and creates aerodynamic drag, but it does not change the ship's speed-over-ground like a current does. + +**Consequence:** CMA-ES finds routes that "follow the wind" (minimising ‖SOG − wind‖²), producing detours 1.5–2× longer than GC. These routes have _lower CMA-ES cost_ but _higher actual energy_ in the RISE performance model because: + +- Longer distance at fixed time → higher SOG → enormously more hydrodynamic drag (P ∝ v³) +- The "benefit" of aligning with wind is illusory — wind velocity (10 m/s) was being subtracted from ship displacement rate, but that's not how ship propulsion works + +**Evidence (smoke test with correct units, 2 departures):** + +| Case | GC Distance | GC Energy | Opt Distance | Opt Energy | +| ------ | ----------- | ---------- | ------------ | ---------- | +| AO_WPS | 3023 nm | 185.65 MWh | 4557 nm | 434 MWh | +| PO_WPS | 4653 nm | 124.49 MWh | 5702 nm | 268 MWh | + +**Root cause:** The cost functions in `routetools/cost.py` were designed for the ocean-current benchmark problems (Zermelo-type navigation). The `constant_speed` variants use the correct current physics: + +```python +# routetools/cost.py — cost_function_constant_speed_time_invariant() +# Time for a vessel with STW=v to traverse displacement d in current w: +# +# dt = sqrt(d² / (v² − w²) + (d·w)² / (v² − w²)²) − (d·w) / (v² − w²) +# +v2 = travel_stw_mod**2 +w2 = uinterp**2 + vinterp**2 +dw = dx * uinterp + dy * vinterp +dt = jnp.sqrt(d2 / (v2 - w2) + dw**2 / (v2 - w2) ** 2) - dw / (v2 - w2) +``` + +This is correct for currents but inapplicable to wind. + +The `constant_cost` (fixed travel time) variants compute: + +```python +# routetools/cost.py — cost_function_constant_cost_time_variant() +# STW cost = ‖SOG − current‖² / 2 · dt +dxdt = dx / dt_s +dydt = dy / dt_s +cost = ((dxdt - uinterp) ** 2 + (dydt - vinterp) ** 2) / 2 +``` + +This is kinetic energy of the ship's velocity _relative to the medium_ (water + current). Only makes physical sense when `w` is an ocean current (medium velocity), not wind. + +**What would be needed:** A JAX-compatible cost function that models the effect of wind and waves on ship energy — essentially a differentiable or at least JAX-traced version of the RISE performance model (`predict_power_batch`). The current RISE model uses NumPy lookup tables and is not JIT-compatible. + +**Status:** ❌ Unfixed. **This is the core blocker for meaningful optimisation.** + +--- + +## Issue 3: Unit Mismatch — SOG in m/h vs Wind in m/s + +**Location:** `routetools/cost.py` → `cost_function_constant_cost_time_variant()` + +**Problem:** When `spherical_correction=True`, haversine gives distances in **meters**. With `travel_time` in **hours**, `dt` is in hours: + +```python +dt = travel_time / n_seg # hours +dxdt = dx / dt # meters / hours ≈ 11,000 m/h per segment +``` + +But wind from ERA5 is in **m/s** ≈ 10 m/s. The cost ‖SOG − wind‖² was dominated by SOG (~11,000 m/h vs ~10 m/s), making wind completely negligible. + +**Fix (committed `6406e20`):** + +```python +# routetools/cost.py — cost_function_constant_cost_time_variant() +# Convert dt from hours to seconds so SOG is in m/s +dt_s = dt * 3600.0 +dxdt = dx / dt_s +dydt = dy / dt_s +cost = ((dxdt - uinterp) ** 2 + (dydt - vinterp) ** 2) / 2 +return cost * dt_s +``` + +**Note:** The time-invariant variant (`cost_function_constant_cost_time_invariant`) has the same pattern — `dxdt = dx / dt` without unit conversion. But it doesn't use `spherical_correction`, so the units depend on the coordinate system. This should be reviewed. + +**Status:** ✅ Fixed for the time-variant variant. Time-invariant variant needs review. + +--- + +## Issue 4: Pacific Longitude Wrapping (Antimeridian) + +**Location:** `routetools/swopp3.py` → `case_endpoints()`, `great_circle_route()`, `routetools/cmaes.py` → endpoint validation + +**Problem:** Pacific route goes Tokyo (140°E) → Los Angeles (−121° = 239°E), crossing 180°. `great_circle_route()` correctly uses SLERP + longitude unwrapping, producing continuous coordinates (140° → 239°). But `case_endpoints()` returns `dst = [-121, 34.4]`. + +CMA-ES validates endpoints: + +```python +# routetools/cmaes.py — optimize() +if not jnp.allclose(curve0[-1, :], dst): + raise ValueError( + "The ending point of curve0 does not match dst. " + f"curve0[-1,:]={curve0[-1, :]}, dst={dst}" + ) +# → ValueError: curve0[-1,:]=[238.99998 34.4], dst=[-121. 34.4] +``` + +**Fix (committed `6406e20`):** Extract the unwrapped endpoints from the GC curve and pass those to CMA-ES: + +```python +# routetools/swopp3_runner.py — run_optimised_departure() +gc_init = great_circle_route(src, dst, n_points=n_points) +# great_circle_route may unwrap longitude through the antimeridian +# (e.g. -121° becomes 239°). Use the unwrapped endpoints so the +# CMA-ES endpoint check passes and the Bézier curve stays in a +# consistent longitude range. +src_opt = jnp.array([gc_init[0, 0], gc_init[0, 1]]) +dst_opt = jnp.array([gc_init[-1, 0], gc_init[-1, 1]]) +``` + +**Status:** ✅ Fixed. + +--- + +## Issue 5: CMA-ES Sigma Scaling for Large Coordinate Spans + +**Location:** `routetools/cmaes.py` → `optimize()` + +**Problem:** `sigma0` is scaled by the Euclidean distance between endpoints: + +```python +# routetools/cmaes.py — optimize() +# Initial standard deviation to sample new solutions +# One sigma is half the distance between src and dst +sigma0 = float(jnp.linalg.norm(dst - src) * sigma0 / 2) +``` + +For Pacific routes (140° to 239°), `norm ≈ 102`, giving effective `sigma0 ≈ 51°` with default `sigma0=1`. This causes extreme search space exploration — the optimizer samples control points 50° away from the initial route. + +**Workaround (committed `6406e20`):** Pass `sigma0=0.1` from `run_optimised_departure()`: + +```python +# routetools/swopp3_runner.py — run_optimised_departure() +defaults = dict( + L=n_points, + curve0=gc_init, + travel_time=travel_time, + spherical_correction=True, + time_offset=departure_offset_h, + sigma0=0.1, + verbose=False, +) +``` + +This gives effective sigma ≈ 5° for Pacific, ≈ 3° for Atlantic. + +**Deeper issue:** The sigma scaling assumes coordinates are in a "reasonable" range. For global routes in degrees, the norm can be 100+, making the default too large. Consider scaling by route length in the cost-function's own units, or using normalised coordinates. + +**Status:** ⚠️ Workaround in place. May need revisiting. + +--- + +## Issue 6: Waves Not Incorporated in Fixed-Time Cost Functions + +**Location:** `routetools/cost.py` + +**Status of wave integration across cost functions:** + +| Cost Function | Waves? | Note | +| ------------------------------- | ------ | ----------------------------------------- | +| `constant_speed_time_invariant` | ✅ Yes | `wave_adjusted_speed()` reduces STW | +| `constant_speed_time_variant` | ✅ Yes | Same wave model, with `lax.scan` for time | +| `constant_cost_time_invariant` | ❌ No | Has `# TODO: Implement wavefield effects` | +| `constant_cost_time_variant` | ❌ No | New function, no wave integration | + +The wave model in the constant-speed variants uses Townsin-Kwon involuntary speed loss (via `wave_adjusted_speed` in `routetools/_cost/waves.py`). This reduces the ship's effective STW based on Beaufort scale and wave incidence angle: + +```python +# routetools/_cost/waves.py — wave_adjusted_speed() +wia = jnp.mod(jnp.abs(angle - wave_angle), 360) +wave_incidence_angle = jnp.minimum(wia, 360 - wia) +beaufort = beaufort_scale(wave_height=wave_height, asfloat=True, ...) +speed_loss = speed_loss_involuntary( + beaufort=beaufort, + wave_incidence_angle=wave_incidence_angle, + vel_ship=vel_ship, + ... +) +return jnp.asarray(vel_ship) * (100 - speed_loss) / 100 +``` + +For the constant-cost (fixed-time) variants, wave effects would need to be incorporated differently — perhaps as an additive resistance term rather than a speed reduction. + +**Status:** ❌ Known limitation. Marked with TODO in code. + +--- + +## Issue 7: Weather Penalty Uses t=0 for Time-Variant Fields + +**Location:** `routetools/weather.py` → `weather_penalty()` + +**Problem:** The `weather_penalty()` function always queries fields at `t=0`, ignoring the departure time: + +```python +# routetools/weather.py — weather_penalty() +mid_lon = (curve[:, :-1, 0] + curve[:, 1:, 0]) / 2 +mid_lat = (curve[:, :-1, 1] + curve[:, 1:, 1]) / 2 +t_zeros = jnp.zeros_like(mid_lon) # ← always t=0 + +if windfield is not None: + u10, v10 = windfield(mid_lon, mid_lat, t_zeros) + tws = jnp.sqrt(u10**2 + v10**2) + violations = violations + jnp.sum(tws > tws_limit, axis=1) + +if wavefield is not None: + hs, _ = wavefield(mid_lon, mid_lat, t_zeros) + violations = violations + jnp.sum(hs > hs_limit, axis=1) +``` + +When `weather_penalty_weight > 0`, the penalty would be based on January 1 weather regardless of actual departure date. + +**Status:** ⚠️ Not critical since `weather_penalty_weight` defaults to `0.0`, but would need fixing if weather penalties are enabled. + +--- + +## Summary + +### What Works + +| Component | Status | +| --------------------------- | -------------------------------------------------------- | +| GC cases (all 8) | ✅ Great-circle + RISE energy evaluation. Valid outputs. | +| Per-departure time offset | ✅ Each departure samples correct weather. | +| ERA5 data loading | ✅ Per-corridor caching, correct longitude handling. | +| Unit conversion (m/h → m/s) | ✅ `dt_s = dt * 3600` in time-variant cost. | +| Pacific longitude wrapping | ✅ Unwrapped endpoints passed to CMA-ES. | + +### What Doesn't + +| Component | Status | +| ------------------------------- | ----------------------------------------------------------- | +| CMA-ES + wind field | ❌ Wind treated as current → routes diverge → worse energy. | +| CMA-ES + wave field in cost | ❌ Not implemented for fixed-time variants. | +| Weather penalty time awareness | ⚠️ Uses t=0 (OK since weight=0 by default). | +| Sigma scaling for global routes | ⚠️ Workaround (`sigma0=0.1`), needs proper fix. | + +### Core Blocker + +The fundamental issue is that **the CMA-ES cost function models a vessel navigating through an ocean current** (Zermelo-type problem), but SWOPP3 requires optimising a **wind-assisted ship** where wind affects propulsion aerodynamics, not the medium itself. A JAX-compatible energy proxy (differentiable approximation of the RISE performance model) is needed to make CMA-ES optimisation meaningful. diff --git a/docs/parametric_model.md b/docs/parametric_model.md new file mode 100644 index 00000000..33673a7e --- /dev/null +++ b/docs/parametric_model.md @@ -0,0 +1,395 @@ +# Reverse-Engineered Parametric Performance Model + +This document describes the closed-form parametric model reverse-engineered +from the SWOPP3 performance model binary (`swopp3_performance_model`). + +## Reference Vessel + +The SWOPP3 performance model is calibrated for a specific vessel: + +| Property | Value | +| ------------------------------ | ------------------------------- | +| Type | Single-skeg general cargo ship | +| Length overall ($L$) | 88 m | +| Estimated beam ($B$) | ~15 m | +| Estimated wetted surface ($S$) | ~2200 m² | +| Propulsion | CPP, electric | +| Wingsails | 4 × 138 m² rigid (552 m² total) | + +All coefficients below are derived from these specifications. + +## Overview + +The SWOPP3 model exposes two scalar functions: + +| Function | Description | +| ----------------------------------------- | ----------------------------------------- | +| `predict_no_wps(tws, twa, swh, mwa, v)` | Power without Wind Propulsion System | +| `predict_with_wps(tws, twa, swh, mwa, v)` | Power with Wind Propulsion System (sails) | + +Both return propulsion power in **kW** and accept: + +| Parameter | Symbol | Unit | Range | +| ----------------------- | ------------ | ---- | ---------------------- | +| True wind speed | $\text{tws}$ | m/s | $[0, 30]$ | +| True wind angle | $\text{twa}$ | deg | $[0, 180]$ (symmetric) | +| Significant wave height | $\text{swh}$ | m | $[0, 10]$ | +| Mean wave angle | $\text{mwa}$ | deg | $[0, 180]$ (symmetric) | +| Ship speed | $v$ | m/s | $[0, 14.5]$ | + +## `predict_no_wps` — Closed-Form Model + +The total power is the sum of three **perfectly additive** components, +clamped at zero: + +$$ +P_{\text{no\_wps}} = \max\!\Big(0,\; P_{\text{hull}} + P_{\text{wind}} + P_{\text{wave}}\Big) +$$ + +### Hull Resistance + +Pure cubic dependence on ship speed, independent of environment: + +$$ +P_{\text{hull}} = K_h \cdot v^3 +$$ + +$$ +K_h = \frac{969}{226} \approx 4.28761 +$$ + +**Physical interpretation:** Hydrodynamic drag in calm water. The cubic law +follows from drag force $\propto v^2$ multiplied by speed to get power. + +$$ +K_h = \frac{1}{2} \cdot \rho_{\text{water}} \cdot S \cdot C_T \cdot \frac{1}{1000} +$$ + +With $\rho_{\text{water}} = 1025 \;\text{kg/m}^3$ and $S \approx 2200 \;\text{m}^2$: + +$$ +C_T = \frac{K_h}{\frac{1}{2} \cdot 1025 \cdot 2200 \cdot 10^{-3}} \approx 0.0038 +$$ + +This total resistance coefficient is within the typical range $0.002\text{--}0.005$ +for cargo ships at service speed. + +**Literature validation:** The cubic dependence is consistent with the following formula found in naval papers: + +$$ +P_{\text{base}} = \frac{\Delta^{2/3} \cdot v^3}{3.7 \left( \sqrt{L} + 75 / v \right) } +$$ + +where $\Delta$ is displacement and $L$ is the length of the vessel. + +**References:** + +- Molland, A.F., Turnock, S.R., Hudson, D.A. (2017). _Ship Resistance and Propulsion_, 2nd ed. Cambridge University Press. Chapter 3. [doi:10.1017/9781316494196](https://doi.org/10.1017/9781316494196) +- Holtrop, J., Mennen, G.G.J. (1982). "An approximate power prediction method." _International Shipbuilding Progress_, 29(335), 166–170. [doi:10.3233/ISP-1982-2933501](https://doi.org/10.3233/ISP-1982-2933501) + +### Aerodynamic (Wind) Resistance + +Depends on the **apparent wind** seen by the ship: + +$$ +P_{\text{wind}} = K_a \cdot v \cdot \big(V_R \cdot u_x - v^2\big) +$$ + +where the apparent wind components are: + +$$ +u_x = \text{tws} \cdot \cos\!\left(\text{twa} \cdot \frac{\pi}{180}\right) + v +\qquad +u_y = \text{tws} \cdot \sin\!\left(\text{twa} \cdot \frac{\pi}{180}\right) +$$ + +$$ +V_R = \sqrt{u_x^2 + u_y^2} +$$ + +$$ +K_a = \frac{49}{320} = 0.153125 +$$ + +**Physical interpretation:** + +$$ +K_a = \frac{1}{2} \cdot \rho_{\text{air}} \cdot C_D \cdot A_T \cdot \frac{1}{1000} +$$ + +where $\rho_{\text{air}} = 1.225 \;\text{kg/m}^3$. The factor $1/1000$ converts W to kW. +Solving for the drag-area product: + +$$ +C_D \cdot A_T = \frac{K_a}{\frac{1}{2} \cdot 1.225 \cdot 10^{-3}} = 250 \;\text{m}^2 +$$ + +With $C_D \approx 0.7$ (typical for cargo ship superstructure), the implied +frontal area is $A_T \approx 357 \;\text{m}^2$. For an 88 m vessel with +$\sim 15 \;\text{m}$ beam and $\sim 20 \;\text{m}$ air draught, this is +physically consistent ($15 \times 20 = 300 \;\text{m}^2$ plus rigging and +wingsail structure). + +**Note:** At large TWA with strong tailwinds, $P_{\text{wind}}$ becomes +negative (wind assists the ship), which can drive total power to zero (clamped). + +**Literature validation:** The formulation is consistent with the standard aerodynamic wind load model used in naval architecture: + +$$ +P_{\text{wind}} = +\frac{1}{2} \cdot C_X \cdot \rho_{\text{air}} \cdot A_x \cdot +\cos(\phi) \cdot v_{\text{wind}}^2 \cdot v +$$ + +which follows from the classical drag expression + +$$ +F_{\text{wind}} = +\frac{1}{2} \, \rho_{\text{air}} \, C_X \, A_x \, v_{\text{wind}}^2 +$$ + +with power obtained as $P = F \cdot v$. +Here $C_X$ is the longitudinal aerodynamic force coefficient, $A_x$ is the projected frontal area, and $\phi$ accounts for the wind attack angle. + +**References:** + +- Blendermann, W. (1994). "Parameter identification of wind loads on ships." _Journal of Wind Engineering and Industrial Aerodynamics_, 51(3), 339–351. [doi:10.1016/0167-6105(94)90067-1]() +- Fujiwara, T., Ueno, M., Nimura, T. (1998). "Estimation of wind forces and moments acting on ships." _Journal of the Society of Naval Architects of Japan_, 183, 77–90. [doi:10.2534/jjasnaoe1968.1998.77](https://doi.org/10.2534/jjasnaoe1968.1998.77) +- ITTC (2014). "Recommended Procedures and Guidelines: Speed and Power Trials." 7.5-04-01-01.1. [ittc.info](https://www.ittc.info/media/8370/75-04-01-011.pdf) + +### Wave-Added Resistance + +Factorizes cleanly into three independent terms: + +$$ +P_{\text{wave}} = A_w \cdot \text{swh}^2 \cdot v^{3/2} \cdot \exp\!\Big(-K_w \cdot |\theta_{\text{mwa}}|^3\Big) +$$ + +where $\theta_{\text{mwa}} = \text{mwa} \cdot \pi / 180$ is the wave angle in radians. + +$$ +A_w \approx 11.1395 \qquad K_w = \frac{125}{432} = \frac{5^3}{2^4 \cdot 3^3} \approx 0.28935 +$$ + +**Key properties:** + +- Quadratic in SWH ($\propto \text{swh}^2$) — exact +- Speed exponent is exactly $3/2$ ($\propto v^{1.5}$) +- Directional factor $\exp(-K_w |\theta|^3)$ decays from 1.0 at head seas + ($\text{mwa}=0°$) to $\approx 0.00013$ at following seas ($\text{mwa}=180°$) + +**Literature validation:** The three standard semi-empirical frameworks for +added resistance in waves all share the same dimensional structure: + +- **Gerritsma & Beukelman (1972)** derive added resistance from strip theory + as an integral of relative wave-induced motions along the hull. The result + scales as $R_{\text{aw}} \propto \rho g B^2 H^2 / L$, where $B$ is beam + and $L$ is length — i.e. quadratic in wave height with a geometric + hull-shape prefactor. + +- **Faltinsen et al. (1980)** extend this to short waves using an asymptotic + diffraction formulation. Their result also gives $R_{\text{aw}} \propto H^2$ + and adds an explicit heading dependence through the encounter angle $\beta$. + +- **ITTC (2014)** recommends the simplified Stawave-2 formula: + $$ + R_{\text{aw}} = \frac{1}{16} \, \rho \, g \, H^2 \, B \, \sqrt{B/L} + $$ + which is speed-independent (resistance does not depend on $v$), so power + grows as $P = R_{\text{aw}} \cdot v \propto H^2 \cdot v^1$. + +All three frameworks agree on the $H^2$ dependence (a direct consequence of +linear wave theory, where wave energy $\propto H^2$). They differ on the speed +dependence _of the resistance_: + +| Framework | $R_{\text{aw}}$ speed dependence | $P_{\text{wave}}$ speed dependence | +| ----------------------------------- | ------------------------------------------------------------- | --------------------------------------- | +| ITTC Stawave-2 | $\propto v^0$ | $\propto H^2 \cdot v^1$ | +| Gerritsma-Beukelman (motions-based) | $\propto v^{0.5\text{--}1}$ (varies with encounter frequency) | $\propto H^2 \cdot v^{1.5\text{--}2}$ | +| Experimental regressions | $\propto v^{0.5\text{--}1.5}$ | $\propto H^2 \cdot v^{1.5\text{--}2.5}$ | + +The SWOPP3 model uses $P_{\text{wave}} \propto v^{3/2}$, which implies a +resistance that grows as $R_{\text{aw}} \propto v^{1/2}$. This sits between +the speed-independent ITTC approximation and the motions-based strip theory +results — a physically natural compromise. The $3/2$ exponent is best +understood as a regression fit that captures the average speed sensitivity +across the operating envelope, rather than a first-principles derivation. + +Strong directional decay from head to following seas is also consistent with +all three frameworks: added resistance is maximum in head seas and negligible +in following seas, where the ship rides with the wave crests. + +**References:** + +- Gerritsma, J., Beukelman, W. (1972). "Analysis of the resistance increase in waves of a fast cargo ship." _International Shipbuilding Progress_, 19(217), 285–293. [doi:10.3233/ISP-1972-1921701](https://journals.sagepub.com/doi/abs/10.3233/ISP-1972-1921701) +- Salvesen, N. (1978). "Added resistance of ships in waves." _Journal of Hydronautics_, 12(1), 24–34. [doi:10.2514/3.63110](https://doi.org/10.2514/3.63110) +- Faltinsen, O.M., Minsaas, K.J., Liapis, N., Skjørdal, S.O. (1980). "Prediction of resistance and propulsion of a ship in a seaway." _Proc. 13th Symposium on Naval Hydrodynamics_, Tokyo, 505–529. ([Conference proceedings, no DOI available](https://scispace.com/pdf/prediction-of-resistance-and-propulsion-of-a-ship-in-a-lzk79rkb4j.pdf)) + +### Accuracy + +Tested against the reference binary on 10,000 random inputs: + +| Metric | Value | +| ------------------- | -------- | +| Mean absolute error | 0.008 kW | +| p99 absolute error | 0.064 kW | +| Max absolute error | 0.114 kW | +| Max relative error | 0.031% | + +--- + +## `predict_with_wps` — Sail-Assisted Model (Closed-Form) + +$$ +P_{\text{with\_wps}} = \max\!\Big(0,\; P_{\text{hull}} + P_{\text{wind}} + P_{\text{wave}} - P_{\text{sail}}\Big) +$$ + +where hull, wind, and wave terms are identical to `predict_no_wps`. + +### Sail Power — Closed-Form + +The sail power saving factorizes exactly as: + +$$ +P_{\text{sail}} = C(\text{AWA}) \cdot V_R^2 \cdot v +$$ + +where $V_R^2 = u_x^2 + u_y^2$ is the squared apparent wind speed +and $\text{AWA}$ is the apparent wind angle: + +$$ +\text{AWA} = \arctan2\!\big(|u_y|,\; u_x\big) \cdot \frac{180}{\pi} +$$ + +so $\text{AWA}$ is in **degrees**. + +**Sail polar coefficient** $C(\text{AWA})$: + +$$ +C(\text{AWA}) = \begin{cases} +0 & \text{if } \text{AWA} < 10° \\[4pt] +K_s \cdot \sin\alpha \cdot \Big(1 + \dfrac{3}{20}\sin^2\alpha\Big) & \text{if } \text{AWA} \geq 10° +\end{cases} +$$ + +where $\alpha = (\text{AWA} - 10°) \cdot \pi / 180$ (converted to radians) and: + +$$ +K_s = 0.85903125 +$$ + +**Derivation of $K_s$:** The value 0.85903125 was identified by isolating the +sail contribution from the binary. Setting $\text{AWA}$ to a known angle where +$\sin\alpha(1 + \frac{3}{20}\sin^2\alpha)$ evaluates to a clean value and +solving for $K_s = P_{\text{sail}} / (V_R^2 \cdot v \cdot C(\alpha))$ yields +the decimal 0.85903125 exactly. This is a terminating decimal: + +$$ +0.85903125 = \frac{85903125}{10^8} = \frac{27489}{32000} = \frac{3 \cdot 7^2 \cdot 11 \cdot 17}{2^8 \cdot 5^3} +$$ + +In other words, $K_s = 27489/32000$ — a ratio with a power-of-ten +denominator, confirming it was likely chosen analytically rather than +fitted numerically. + +### Key Properties + +1. **Wave-independent:** $P_{\text{sail}}$ depends only on + $(\text{tws}, \text{twa}, v)$ — no dependence on SWH or MWA. + +2. **Operates in apparent wind coordinates:** The model naturally uses + $\text{AWA}$ and $V_R$, not the true wind quantities directly. + +3. **10° dead zone:** The sail produces no power when $\text{AWA} < 10°$ + (too close to head wind). This cutoff is exact in apparent wind angle. + +4. **Peak at AWA ≈ 100°:** $C(100°) = K_s \cdot 23/20 \approx 0.9879$ — + the peak of the sail thrust polar occurs at beam-reach conditions. + +5. **Always non-negative:** $P_{\text{sail}} \geq 0$ (sails only help). + +6. **Symmetric:** $P_{\text{sail}}(\text{twa}) = P_{\text{sail}}(-\text{twa})$. + +7. **Physical interpretation:** $K_s \cdot \sin\alpha$ is the primary + lift-based thrust; the $\frac{3}{20}\sin^2\alpha$ term is a quadratic + drag/lift correction that enhances thrust at large angles of attack. + + $$ + K_s = \frac{1}{2} \cdot \rho_{\text{air}} \cdot C_L \cdot A_{\text{sail}} \cdot \frac{1}{1000} + $$ + + With total sail area $A_{\text{sail}} = 4 \times 138 = 552 \;\text{m}^2$: + + $$ + C_L = \frac{K_s}{\frac{1}{2} \cdot 1.225 \cdot 552 \cdot 10^{-3}} \approx 2.54 + $$ + + This effective lift coefficient is within the typical range $2\text{--}4$ + for rigid wingsails. + +### Accuracy + +The closed-form sail model is **exact to machine precision** ($< 10^{-13}$) +against the reference. The only residual error in the full model comes from +the $A_w$ constant in the wave term. + +Tested against the reference binary on 50,000 random inputs: + +| Metric | Value | +| ------------------- | ---------- | +| Mean absolute error | 0.004 kW | +| p99 absolute error | 0.030 kW | +| Max absolute error | 0.050 kW | +| Errors > 0.1 kW | 0 / 50,000 | + +--- + +## Summary of Constants + +| Constant | Symbol | Value | Exact | +| ------------------------- | ------ | ----------- | ------------------------------- | +| Hull coefficient | $K_h$ | 4.28761… | $969/226$ | +| Air drag coefficient | $K_a$ | 0.153125 | $49/320$ | +| Wave amplitude | $A_w$ | 11.1395 | fitted | +| Wave directional decay | $K_w$ | 0.28935… | $125/432 = 5^3/(2^4 \cdot 3^3)$ | +| Sail thrust coefficient | $K_s$ | 0.85903125 | $27489/32000$ | +| Sail dead zone angle | — | 10° | exact | +| Sail quadratic correction | — | 3/20 = 0.15 | exact | + +## Physical Coefficients (88 m Cargo Vessel) + +| Coefficient | Symbol | Derived Value | Typical Range | Source | +| ---------------- | --------- | ------------- | ------------- | ------------------------------------------------------------------- | +| Total resistance | $C_T$ | 0.0038 | 0.002–0.005 | $K_h / (\frac{1}{2} \rho_w S / 1000)$, $S \approx 2200\;\text{m}^2$ | +| Wind drag × area | $C_D A_T$ | 250 m² | — | $K_a / (\frac{1}{2} \rho_a / 1000)$ | +| Wind drag | $C_D$ | ~0.7 | 0.6–0.9 | Assuming $A_T \approx 357\;\text{m}^2$ | +| Wingsail lift | $C_L$ | 2.54 | 2–4 | $K_s / (\frac{1}{2} \rho_a \cdot 552 / 1000)$ | + +Where $\rho_w = 1025 \;\text{kg/m}^3$, $\rho_a = 1.225 \;\text{kg/m}^3$. + +### Wave Dominance + +At typical service speed ($v = 8\;\text{kn}$), wave-added resistance exceeds +hull resistance at surprisingly low wave heights: + +| SWH (m) | $P_{\text{wave}}$ / $P_{\text{hull}}$ | $P_{\text{wave}}$ share | +| ------- | ------------------------------------- | ----------------------- | +| 1.0 | 0.31 | 24% | +| 1.8 | 1.00 | 50% (crossover) | +| 3.0 | 2.78 | 74% | +| 4.0 | 4.98 | 83% | + +(Head seas, $\text{mwa} = 0°$.) +This is consistent with the SWOPP3 binary itself — wave dominance +is a genuine physical property of the model, not a modelling artifact. + +## Clamping Rule + +Both functions clamp the result at zero: + +$$ +P = \max(0, P_{\text{raw}}) +$$ + +This occurs when strong tailwinds ($P_{\text{wind}} < 0$) or large sail +savings ($P_{\text{sail}}$) exceed hull + wave resistance. diff --git a/docs/parametric_model.pdf b/docs/parametric_model.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7bf5f12f5379c6df8615dfb03fadf43690255814 GIT binary patch literal 97067 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μ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.13.2", "typer>=0.19.2", "h3==4.0.0b2", - "wrr-bench", - "wrr-utils", "borb @ https://files.pythonhosted.org/packages/7a/4e/b193d894ffb0fde0f773b0476d8b041528302fa86bccf2cc8006c79404e4/borb-3.0.1.tar.gz", "jupyter-server>=2.17.0", "statsmodels>=0.14.6", + "scipy>=1.12.0", + "netCDF4>=1.6.0", + "opencv-python>=4.13.0.92", + "dask>=2024.1.0", + "cartopy>=0.25.0", + "gcsfs>=2026.2.0", + "zarr>=3.1.5", ] +[project.optional-dependencies] +cuda = ["jax[cuda]==0.8.1"] +era5-cds = ["cdsapi>=0.7.0"] +era5-gcs = ["gcsfs>=2024.1.0", "zarr>=2.17.0"] +swopp3 = ["swopp3-performance-model==0.1.0"] + [project.urls] homepage = "https://github.com/Weather-Routing-Research/cmaes_bezier_demo" @@ -40,7 +51,12 @@ homepage = "https://github.com/Weather-Routing-Research/cmaes_bezier_demo" main = "routetools.main:main" [tool.setuptools] -packages = ["routetools"] +packages = [ + "routetools", + "routetools.era5", + "routetools._cost", + "routetools.wrr_bench", +] [tool.setuptools_scm] # Configure setuptools_scm to write a version file and provide a fallback when @@ -53,19 +69,19 @@ fallback_version = "0+unknown" line-length = 88 [tool.ruff.lint] select = [ - "E", # pycodestyle - "F", # Pyflakes - "UP", # pyupgrade - "B", # flake8-bugbear - "SIM", # flake8-simplify - "I", # isort - "D", # pydocstyle - "C401", # flake8-comprehensions: unnecessary-generator-set - "C402", # flake8-comprehensions: unnecessary-generator-dict - "C403", # flake8-comprehensions: unnecessary-list-comprehension-set - "C404", # flake8-comprehensions: unnecessary-list-comprehension-dict - "C405", # flake8-comprehensions: unnecessary-literal-set - "W605", # pycodestyle: invalid-escape-sequence + "E", # pycodestyle + "F", # Pyflakes + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "I", # isort + "D", # pydocstyle + "C401", # flake8-comprehensions: unnecessary-generator-set + "C402", # flake8-comprehensions: unnecessary-generator-dict + "C403", # flake8-comprehensions: unnecessary-list-comprehension-set + "C404", # flake8-comprehensions: unnecessary-list-comprehension-dict + "C405", # flake8-comprehensions: unnecessary-literal-set + "W605", # pycodestyle: invalid-escape-sequence ] # Ignore D100 Missing docstring in public module @@ -95,20 +111,5 @@ xfail_strict = true addopts = ["-ra", "--strict-config", "--strict-markers"] filterwarnings = ["error", "ignore::DeprecationWarning"] -[tool.uv] -dev-dependencies = [ - "pytest", - "ruff", - "mypy", - "pre-commit", - "ipykernel", -] - -[tool.uv.sources] -wrr-bench = { git = "ssh://git@github.com/Weather-Routing-Research/weather-routing-benchmarks.git", branch = "main" } -wrr-utils = { git = "ssh://git@github.com/Weather-Routing-Research/weather-routing-research.git", branch = "main" } - -[project.optional-dependencies] -cuda = ["jax[cuda]==0.8.1"] - - +[dependency-groups] +dev = ["pytest", "ruff", "mypy", "pre-commit", "ipykernel"] diff --git a/routetools/_cost/haversine.py b/routetools/_cost/haversine.py index bdc0a83d..fb5aa92b 100644 --- a/routetools/_cost/haversine.py +++ b/routetools/_cost/haversine.py @@ -1,10 +1,12 @@ from __future__ import annotations import math +from datetime import datetime, timedelta import jax.numpy as jnp EARTH_RADIUS = 6371000 # in meters, globe mean radius +NAUTICAL_MILE_METERS = 1852.0 def haversine_meters_module( @@ -53,3 +55,119 @@ def haversine_distance_from_curve(curve: jnp.ndarray) -> jnp.ndarray: lon, lat = curve[:, 0], curve[:, 1] dx, dy = haversine_meters_components(lat[:-1], lon[:-1], lat[1:], lon[1:]) return jnp.sqrt(dx**2 + dy**2) + + +def curve_distance_nm(curve: jnp.ndarray) -> float: + """Return total route length in nautical miles. + + Parameters + ---------- + curve : jnp.ndarray + Route waypoints with shape ``(L, 2)`` in ``(lon, lat)`` degrees. + + Returns + ------- + float + Total distance in nautical miles. + """ + return float(jnp.sum(haversine_distance_from_curve(curve))) / NAUTICAL_MILE_METERS + + +def waypoint_times_uniform( + curve: jnp.ndarray, + departure: datetime, + passage_hours: float, +) -> list[datetime]: + """Compute UTC timestamps at each waypoint with uniform time spacing. + + Parameters + ---------- + curve : jnp.ndarray + Waypoints with shape ``(L, 2)``. + departure : datetime + Departure timestamp. + passage_hours : float + Total passage duration in hours. + + Returns + ------- + list[datetime] + One timestamp per waypoint. + + Raises + ------ + ValueError + If *curve* has no waypoints. + """ + n_points = curve.shape[0] + if n_points == 0: + raise ValueError("curve must contain at least one waypoint") + if n_points == 1: + return [departure] + + total_seconds = passage_hours * 3600.0 + return [ + departure + timedelta(seconds=total_seconds * i / (n_points - 1)) + for i in range(n_points) + ] + + +def great_circle_route( + src: jnp.ndarray, + dst: jnp.ndarray, + n_points: int = 100, +) -> jnp.ndarray: + """Compute a great-circle route between two points. + + Uses spherical interpolation (SLERP) in Cartesian 3-D, then projects + back to ``(lon, lat)`` in degrees while unwrapping longitude across the + antimeridian. + + Parameters + ---------- + src : jnp.ndarray + Source ``(lon, lat)`` in degrees, shape ``(2,)``. + dst : jnp.ndarray + Destination ``(lon, lat)`` in degrees, shape ``(2,)``. + n_points : int + Number of waypoints including endpoints. + + Returns + ------- + jnp.ndarray + Route shape ``(n_points, 2)`` in ``(lon, lat)``. + """ + lon1, lat1 = jnp.deg2rad(src[0]), jnp.deg2rad(src[1]) + lon2, lat2 = jnp.deg2rad(dst[0]), jnp.deg2rad(dst[1]) + + x1 = jnp.cos(lat1) * jnp.cos(lon1) + y1 = jnp.cos(lat1) * jnp.sin(lon1) + z1 = jnp.sin(lat1) + + x2 = jnp.cos(lat2) * jnp.cos(lon2) + y2 = jnp.cos(lat2) * jnp.sin(lon2) + z2 = jnp.sin(lat2) + + dot = x1 * x2 + y1 * y2 + z1 * z2 + omega = jnp.arccos(jnp.clip(dot, -1.0, 1.0)) + + t = jnp.linspace(0.0, 1.0, n_points) + sin_omega = jnp.sin(omega) + is_coincident = sin_omega < 1e-10 + + safe_sin = jnp.where(is_coincident, 1.0, sin_omega) + a = jnp.where(is_coincident, 1.0 - t, jnp.sin((1.0 - t) * omega) / safe_sin) + b = jnp.where(is_coincident, t, jnp.sin(t * omega) / safe_sin) + + x = a * x1 + b * x2 + y = a * y1 + b * y2 + z = a * z1 + b * z2 + + lat = jnp.rad2deg(jnp.arcsin(jnp.clip(z, -1.0, 1.0))) + lon = jnp.rad2deg(jnp.arctan2(y, x)) + + dlon = jnp.diff(lon) + dlon_wrapped = (dlon + 180.0) % 360.0 - 180.0 + lon = jnp.concatenate([lon[:1], lon[0] + jnp.cumsum(dlon_wrapped)]) + + return jnp.stack([lon, lat], axis=1) diff --git a/routetools/_ports.py b/routetools/_ports.py new file mode 100644 index 00000000..80b3e4f6 --- /dev/null +++ b/routetools/_ports.py @@ -0,0 +1,115 @@ +DICT_PORTS = { + "CAVAN": {"lat": 48.50, "lon": -124.82, "city": "Vancouver", "country": "Canada"}, + "CNSHA": {"lat": 31.28, "lon": 121.98, "city": "Shanghai", "country": "China"}, + "DEHAM": {"lat": 53.99, "lon": 8.63, "city": "Hamburg", "country": "Germany"}, + "EGHRG": { + "lat": 27.66, + "lon": 33.88, + "city": "Suez", + "country": "Egypt", + "canal": "Suez", + "ocean": "Indian", + }, + "EGPSD": { + "lat": 31.53, + "lon": 32.32, + "city": "Suez", + "country": "Egypt", + "canal": "Suez", + "ocean": "Mediterranean", + }, + "ESALG": {"lat": 36.07, "lon": -5.38, "city": "Algeciras", "country": "Spain"}, + "FRLEH": {"lat": 49.49, "lon": 0.06, "city": "Le Havre", "country": "France"}, + "JPTYO": { + "lat": 34.8, + "lon": 140.0, + "city": "Tokyo/Yokohama", + "country": "Japan", + }, + "LKCMB": {"lat": 6.93, "lon": 79.79, "city": "Colombo", "country": "Sri Lanka"}, + "PABLB": { + "lat": 8.89, + "lon": -79.53, + "city": "Balboa", + "country": "Panama", + "canal": "Panama", + "ocean": "Pacific", + }, + "PAONX": { + "lat": 9.43, + "lon": -79.92, + "city": "Colon", + "country": "Panama", + "canal": "Panama", + "ocean": "Atlantic", + }, + "PECLL": { + "lat": -12.19, + "lon": -77.23, + "city": "Callao", + "country": "Peru", + }, + "ESSDR": { + "lat": 43.6, + "lon": -4.0, + "city": "Santander", + "country": "Spain", + }, + "USLAX": { + "lat": 34.4, + "lon": -121.0, + "city": "Los Angeles", + "country": "United States", + }, + "USHOU": { + "lat": 29.30, + "lon": -94.63, + "city": "Houston", + "country": "United States", + }, + "USLBH": { + "lat": 33.73, + "lon": -118.17, + "city": "Long Beach", + "country": "United States", + }, + "USNYC": { + "lat": 40.53, + "lon": -73.80, + "city": "New York", + "country": "United States", + }, + "USNYS": { # New York SWOPP3 port, different from USNYC + "lat": 40.6, + "lon": -69.0, # As defined by SWOPP3 + "city": "New York", + "country": "United States", + }, + "USSAV": { + "lat": 31.99, + "lon": -80.76, + "city": "Savannah", + "country": "United States", + }, + "MYKUL": { + "lat": 2.968, + "lon": 100.899, + "city": "Kuala Lumpur", + "country": "Malaysia", + }, +} +DICT_INSTANCES = { + "DEHAM-USNYC": {"date_start": "2023-01-01T00:00:00"}, + "USNYC-DEHAM": {"date_start": "2023-01-01T00:00:00"}, + "EGHRG-MYKUL": {"date_start": "2023-01-01T00:00:00"}, + "MYKUL-EGHRG": {"date_start": "2023-01-01T00:00:00"}, + "EGPSD-ESALG": {"date_start": "2023-01-01T00:00:00"}, + "ESALG-EGPSD": {"date_start": "2023-01-01T00:00:00"}, + "PABLB-PECLL": {"date_start": "2023-01-01T00:00:00"}, + "PECLL-PABLB": {"date_start": "2023-01-01T00:00:00"}, + "PAONX-USNYC": {"date_start": "2023-01-01T00:00:00"}, + "USNYC-PAONX": {"date_start": "2023-01-01T00:00:00"}, + # SWOPP3 + "ESSDR-USNYS": {"date_start": "2024-01-01T00:00:00"}, + "USLAX-JPTYO": {"date_start": "2024-01-01T00:00:00"}, +} diff --git a/routetools/analysis_config.py b/routetools/analysis_config.py new file mode 100644 index 00000000..4f398417 --- /dev/null +++ b/routetools/analysis_config.py @@ -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] diff --git a/routetools/benchmark.py b/routetools/benchmark.py index 41a282fb..f6e51265 100644 --- a/routetools/benchmark.py +++ b/routetools/benchmark.py @@ -4,16 +4,13 @@ import jax import jax.numpy as jnp -import numpy as np -from wrr_bench.benchmark import load -from wrr_bench.ocean import Ocean -from wrr_utils.optimization import Circumnavigate -from wrr_utils.route import Route +from routetools.circumnavigate import circumnavigate from routetools.cmaes import optimize from routetools.fms import optimize_fms from routetools.land import Land from routetools.vectorfield import time_variant, vectorfield_zero +from routetools.wrr_bench import Ocean, load_real_instance def get_currents_to_vectorfield( @@ -191,6 +188,9 @@ def load_benchmark_instance( instance_name: str, date_start: str = "2023-01-08", vel_ship: int = 6, + use_currents: bool = True, + use_waves: bool = True, + route_days: int = 10, bounding_border: int = 10, data_path: str = "./data", ) -> dict[str, Any]: @@ -205,6 +205,13 @@ def load_benchmark_instance( Start date for the benchmark instance, by default "2023-01-08". vel_ship : int, optional Velocity of the ship in knots, by default 6. + use_currents : bool, optional + Whether to use ocean currents data, by default True. + use_waves : bool, optional + Whether to use ocean waves data, by default True. + route_days : int, optional + Number of days of ocean data to load. If the route goes for + longer, it will repeat the last day. By default 10. bounding_border: int, optional Border size for bounding box, by default 10. data_path : str, optional @@ -221,12 +228,15 @@ def load_benchmark_instance( - vectorfield: Callable function to get the current vectors - land: Land instance for land penalization """ - dict_instance = load( + dict_instance = load_real_instance( instance_name, date_start=date_start, vel_ship=vel_ship, data_path=data_path, bounding_border=bounding_border, + use_currents=use_currents, + use_waves=use_waves, + route_days=route_days, ) # Load ocean and land data @@ -260,16 +270,13 @@ def load_benchmark_instance( } -def circumnavigate( +def circumnavigate_and_smooth( lat_start: float, lon_start: float, lat_end: float, lon_end: float, ocean: Ocean, land: LandBenchmark, - date_start: np.datetime64, - date_end: np.datetime64 | None = None, - vel_ship: float = 10.0, grid_resolution: int = 4, neighbour_disk_size: int = 3, land_dilation: int = 0, @@ -296,12 +303,6 @@ def circumnavigate( land : Land | None, optional Land instance to derive navigable cells from. If None, assumes no land constraints, by default None. - date_start : np.datetime64 - Start date for the route. - date_end : np.datetime64 | None, optional - End date for the route, by default None. - vel_ship : float, optional - Speed through water of the ship in knots, by default 10.0. grid_resolution : int, optional Grid resolution in kilometers, by default 4. neighbour_disk_size : int, optional @@ -325,27 +326,18 @@ def circumnavigate( - The initial A* route as an array of (lon, lat) points. """ # Circumnavigate optimizer - opt = Circumnavigate( - grid_resolution=grid_resolution, - neighbour_disk_size=neighbour_disk_size, - land_dilation=land_dilation, - num_iter=0, # No FMS refinement here - ) - route: Route = opt.optimize( + lats, lons = circumnavigate( lat_start=lat_start, lon_start=lon_start, lat_end=lat_end, lon_end=lon_end, data=ocean, - bounding_box=ocean.bounding_box, # Required explicitly - date_start=date_start, - date_end=date_end, # Required explicitly, but not used - vel_ship=vel_ship, # Required explicitly, but not used + grid_resolution=grid_resolution, + neighbour_disk_size=neighbour_disk_size, + land_dilation=land_dilation, ) - # Retrieve the curve from the Route instance - lats = jnp.asarray(route.lats) - lons = jnp.asarray(route.lons) + # Retrieve the curve from the optimizer and ensure it has the correct shape (L, 2) curve = jnp.stack([lons, lats], axis=1) assert ( curve.ndim == 2 and curve.shape[1] == 2 @@ -384,7 +376,7 @@ def circumnavigate( def optimize_benchmark_instance( dict_instance: dict[str, Any], - penalty: float = 1e8, + penalty: float = 1e10, K: int = 6, L: int = 64, num_pieces: int = 1, @@ -420,7 +412,7 @@ def optimize_benchmark_instance( The problem instance contains the following information: lat_start, lon_start, lat_end, lon_end, date_start, vel_ship, bounding_box, data penalty : float, optional - Penalty for land points, by default 1e8 + Penalty for land points, by default 1e10 K : int, optional Number of free Bézier control points. By default 6 L : int, optional @@ -463,16 +455,13 @@ def optimize_benchmark_instance( if verbose: print("[INFO] Initializing with circumnavigation route...") # Initialize the circumnavigation route - curve0 = circumnavigate( + curve0 = circumnavigate_and_smooth( lat_start=dict_instance["lat_start"], lon_start=dict_instance["lon_start"], lat_end=dict_instance["lat_end"], lon_end=dict_instance["lon_end"], ocean=dict_instance["data"], land=dict_instance["land"], - date_start=dict_instance["date_start"], - date_end=dict_instance.get("date_end"), - vel_ship=dict_instance.get("vel_ship"), grid_resolution=4, neighbour_disk_size=3, land_dilation=0, diff --git a/routetools/circumnavigate.py b/routetools/circumnavigate.py new file mode 100644 index 00000000..54705c98 --- /dev/null +++ b/routetools/circumnavigate.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import heapq + +import h3.api.basic_int as h3 +import numpy as np + +from routetools.wrr_bench import Ocean +from routetools.wrr_bench.polygons import invert_polygon, multipolygon_to_h3_cells + + +class Node: + """A node class for A* Pathfinding.""" + + def __init__( + self, hex_id: int, parent: Node | None = None, vel_ship: float | None = None + ): + self.parent = parent + self.hex_id = hex_id + self.vel_ship = vel_ship + + self.g = 0 + self.h = 0 + self.f = 0 + self.dt = 0 + + def __eq__(self, other: Node): + """Compare nodes for equality.""" + # Skip vel comparison if one of the nodes has "None" + if self.vel_ship is None or other.vel_ship is None: + eq_vel = True + else: + eq_vel = self.vel_ship == other.vel_ship + # Compare hex_id and velocity + return (self.hex_id == other.hex_id) and eq_vel + + def __lt__(self, other: Node): + """Compare nodes by f-value for priority queue ordering.""" + return self.f < other.f + + def __repr__(self): + """Return a short string representation of the node.""" + return str(self.hex_id) + "-" + str(self.vel_ship) + + +def _get_route(last_node: Node) -> list[tuple]: + route: list[tuple] = [] + node_now = last_node + while node_now is not None: + route.append(h3.cell_to_latlng(node_now.hex_id)) + node_now = node_now.parent + return route[::-1] + + +def _get_neighbours( + node: Node, ocean_cells: set[int], neighbour_disk_size: int = 3 +) -> list[Node]: + neighbours: list[Node] = [] + for neighbour in h3.grid_disk(node.hex_id, neighbour_disk_size): + if neighbour != node.hex_id and neighbour in ocean_cells: + neighbours.append(Node(neighbour)) + return neighbours + + +def circumnavigate( + lat_start: float, + lon_start: float, + lat_end: float, + lon_end: float, + data: Ocean, + grid_resolution: int = 4, + neighbour_disk_size: int = 3, + land_dilation: int = 0, + check_land_edges: bool = True, +) -> tuple[np.ndarray, np.ndarray]: + """ + Optimize the route using the A* algorithm. + + The ocean data is replaced by one with all zero currents. + + Parameters + ---------- + lat_start : float + The latitude of the starting point. + lon_start : float + The longitude of the starting point. + lat_end : float + The latitude of the ending point. + lon_end : float + The longitude of the ending point. + data : Ocean + The ocean data to use for the optimization. + grid_resolution : int, optional + The resolution of the h3 grid used for the A* algorithm, by default 4 + neighbour_disk_size : int, optional + The size of the disk used to get the neighbors of a node, by default 3 + land_dilation : int, optional + The number of cells to dilate the land cells, by default 0 + check_land_edges : bool, optional + Whether to check if the edge between two nodes is crossing land, + by default True + + Returns + ------- + tuple[np.ndarray, np.ndarray] + The optimized route with a fine reparametrization, + as arrays of latitudes and longitudes. + """ + # Replace the ocean data with one with all zero currents to compute the route + ocean_zero = Ocean( + bounding_box=data.bounding_box, + land_file=data.land_file, + interp_method=data.interp_method, + radius=data.radius, + ) + + hex_start = h3.latlng_to_cell(lat_start, lon_start, grid_resolution) + hex_end = h3.latlng_to_cell(lat_end, lon_end, grid_resolution) + + node_start = Node(hex_start) + node_end = Node(hex_end) + + polygon = invert_polygon(ocean_zero.shapely_ocean, ocean_zero.bounding_box) + ocean_cells = multipolygon_to_h3_cells( + polygon, + res=grid_resolution, + land_dilation=land_dilation, + ) + + ocean_cells.add(node_start.hex_id) + ocean_cells.add(node_end.hex_id) + + # Create priority queue and add start node + open_list: list[Node] = [] + heapq.heapify(open_list) # PriorityQueue + heapq.heappush(open_list, node_start) + + closed_list: list[Node] = [] + + nodes_route = None + while len(open_list) > 0: + node_now = heapq.heappop(open_list) + + if node_now == node_end: + nodes_route = _get_route(node_now) + break + + for neighbour in _get_neighbours(node_now, ocean_cells, neighbour_disk_size): + # Check if the node is in the closed list + if neighbour in closed_list: + continue + + # Calculate the cost of the neighbour + neighbour.parent = node_now + if node_now == node_start: + node_now_lat = lat_start + node_now_lon = lon_start + else: + node_now_lat, node_now_lon = h3.cell_to_latlng(node_now.hex_id) + + if neighbour == node_end: + neighbour_lat = lat_end + neighbour_lon = lon_end + else: + neighbour_lat, neighbour_lon = h3.cell_to_latlng(neighbour.hex_id) + + if ( + check_land_edges + and data.get_land_edge( + np.array([node_now_lat, neighbour_lat]), + np.array([node_now_lon, neighbour_lon]), + ).all() + ): + continue + + # Push or update the neighbour in the priority queue + if neighbour not in open_list: + heapq.heappush(open_list, neighbour) + else: + node_old = open_list[open_list.index(neighbour)] + if neighbour.g < node_old.g: + open_list.remove(node_old) + heapq.heappush(open_list, neighbour) + + closed_list.append(node_now) + + if nodes_route is None: + raise ValueError("The route is not possible with the given parameters.") + + nodes_route[0] = (lat_start, lon_start) + nodes_route[-1] = (lat_end, lon_end) + + latitudes, longitudes = list(zip(*nodes_route, strict=False)) + + latitudes = np.array(latitudes) + longitudes = np.array(longitudes) + + # Return latitude and longitude arrays for the circumnavigated route. + return latitudes, longitudes diff --git a/routetools/cmaes.py b/routetools/cmaes.py index 75cbd0ce..3ecd7b82 100644 --- a/routetools/cmaes.py +++ b/routetools/cmaes.py @@ -16,6 +16,9 @@ from routetools.cost import cost_function from routetools.land import Land from routetools.vectorfield import vectorfield_fourvortices +from routetools.weather import wave_penalty_smooth as _wave_penalty_smooth +from routetools.weather import weather_penalty as _weather_penalty +from routetools.weather import wind_penalty_smooth as _wind_penalty_smooth @jit # type: ignore[misc] @@ -282,7 +285,17 @@ def _cma_evolution_strategy( [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] ] | None = None, - penalty: float = 10, + windfield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ] + | None = None, + penalty: float = 1e10, + weather_penalty_weight: float = 0.0, + wind_penalty_weight: float = 0.0, + wave_penalty_weight: float = 0.0, + distance_penalty_weight: float = 0.0, + tws_limit: float = 20.0, + hs_limit: float = 7.0, travel_stw: float | None = None, travel_time: float | None = None, L: int = 64, @@ -298,10 +311,30 @@ def _cma_evolution_strategy( weight_l2: float = 0.0, keep_top: float = 0.0, spherical_correction: bool = False, + time_offset: float = 0.0, + cost_fn: Callable[[jnp.ndarray], jnp.ndarray] | None = None, + land_margin: int = 0, + dt_eval_minutes: float = 0.0, verbose: bool = True, **kwargs: dict[str, Any], ) -> cma.CMAEvolutionStrategy: curve: jnp.ndarray + + # Compute L_eval: the number of Bézier evaluation points for accurate + # quadrature (Δt₂). When dt_eval_minutes > 0 and travel_time is known, + # L_eval is derived so that each evaluation segment spans ~dt_eval_minutes. + # Otherwise fall back to the output resolution L (Δt₁ == Δt₂). + if dt_eval_minutes > 0 and travel_time is not None: + L_eval = int(travel_time * 60 / dt_eval_minutes) + 1 + # Ensure L_eval - 1 is divisible by num_pieces + if num_pieces > 1: + remainder = (L_eval - 1) % num_pieces + if remainder != 0: + L_eval += num_pieces - remainder + L_eval = max(L_eval, 3) # need at least 3 points + else: + L_eval = L + # Initialize the optimizer es = cma.CMAEvolutionStrategy( x0, @@ -321,38 +354,100 @@ def _cma_evolution_strategy( # Turn the percentage into a number num_top = int(keep_top * popsize) - # Initialize storage for the top solutions - top_curves: jnp.ndarray = jnp.zeros((num_top, L, 2)) + # Initialize storage for the top solutions (at L_eval resolution) + top_curves: jnp.ndarray = jnp.zeros((num_top, L_eval, 2)) top_costs: jnp.ndarray = jnp.full((num_top,), jnp.inf) # Optimization loop while not es.stop(): X = es.ask() # sample len(X) candidate solutions - # Transform controls into curves and compute costs + # Transform controls into curves at L_eval resolution for + # accurate energy quadrature (Δt₂). curve = control_to_curve( jnp.array(X), src, dst, - L=L, + L=L_eval, num_pieces=num_pieces, force_L_multiple_of_num_pieces=force_L_multiple_of_num_pieces, ) - cost: jnp.ndarray = cost_function( - vectorfield=vectorfield, - curve=curve, - wavefield=wavefield, - travel_stw=travel_stw, - travel_time=travel_time, - weight_l1=weight_l1, - weight_l2=weight_l2, - spherical_correction=spherical_correction, - ) + if cost_fn is not None: + cost = cost_fn(curve) + else: + cost = cost_function( + vectorfield=vectorfield, + curve=curve, + wavefield=wavefield, + travel_stw=travel_stw, + travel_time=travel_time, + weight_l1=weight_l1, + weight_l2=weight_l2, + spherical_correction=spherical_correction, + time_offset=time_offset, + ) # Land penalization if land is not None and penalty > 0: - cost += land.penalization(curve, penalty=penalty) + # Skip the first/last `land_margin` waypoints (ports on coast) + if land_margin > 0 and curve.shape[1] > 2 * land_margin: + curve_check = curve[:, land_margin:-land_margin, :] + else: + curve_check = curve + land_count = land.penalization(curve_check, penalty=1) + has_land = land_count > 0 + # Death penalty: land-crossing candidates get cost=penalty, + # plus land_count as gradient signal so CMA-ES moves + # toward fewer land points. + cost = jnp.where(has_land, penalty + land_count, cost) + + # Weather constraint penalization + if weather_penalty_weight > 0 and ( + windfield is not None or wavefield is not None + ): + cost += _weather_penalty( + curve, + windfield=windfield, + wavefield=wavefield, + tws_limit=tws_limit, + hs_limit=hs_limit, + penalty=weather_penalty_weight, + travel_stw=travel_stw, + travel_time=travel_time, + spherical_correction=spherical_correction, + time_offset=time_offset, + ) + + # Split smooth wind penalty + if wind_penalty_weight > 0 and windfield is not None: + cost += _wind_penalty_smooth( + curve, + windfield=windfield, + tws_limit=tws_limit, + weight=wind_penalty_weight, + travel_stw=travel_stw, + travel_time=travel_time, + spherical_correction=spherical_correction, + time_offset=time_offset, + ) + + # Split smooth wave penalty + if wave_penalty_weight > 0 and wavefield is not None: + cost += _wave_penalty_smooth( + curve, + wavefield=wavefield, + hs_limit=hs_limit, + weight=wave_penalty_weight, + travel_stw=travel_stw, + travel_time=travel_time, + spherical_correction=spherical_correction, + time_offset=time_offset, + ) + + # EDT distance-to-land penalty + if distance_penalty_weight > 0 and land is not None: + cost += land.distance_penalty(curve, weight=distance_penalty_weight) # Replace the worst solutions with the best found so far if keep_top > 0 and es.countiter > 1: @@ -392,7 +487,17 @@ def optimize( [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] ] | None = None, - penalty: float = 10, + windfield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ] + | None = None, + penalty: float = 1e10, + weather_penalty_weight: float = 0.0, + wind_penalty_weight: float = 0.0, + wave_penalty_weight: float = 0.0, + distance_penalty_weight: float = 0.0, + tws_limit: float = 20.0, + hs_limit: float = 7.0, travel_stw: float | None = None, travel_time: float | None = None, K: int = 6, @@ -409,6 +514,10 @@ def optimize( keep_top: float = 0.0, spherical_correction: bool = False, seed: float = jnp.nan, + time_offset: float = 0.0, + cost_fn: Callable[[jnp.ndarray], jnp.ndarray] | None = None, + land_margin: int = 0, + dt_eval_minutes: float = 0.0, verbose: bool = True, ) -> tuple[jnp.ndarray, dict[str, Any]]: """ @@ -438,7 +547,24 @@ def optimize( A function that returns the height and direction of the wave field, by default None penalty : float, optional - Penalty for land points, by default 10 + Large penalty applied to routes that intersect land (death-penalty + scheme), by default 1e10 + weather_penalty_weight : float, optional + Penalty weight for weather constraint violations (TWS, Hs). + Set to 0 (default) to disable weather penalties. + wind_penalty_weight : float, optional + Smooth wind penalty weight (TWS squared ramp). Set to 0 (default) + to disable. + wave_penalty_weight : float, optional + Smooth wave penalty weight (Hs squared ramp). Set to 0 (default) + to disable. + distance_penalty_weight : float, optional + EDT-based distance-to-land penalty weight. Set to 0 (default) + to disable. + tws_limit : float, optional + Maximum allowed true wind speed in m/s, by default 20.0 + hs_limit : float, optional + Maximum allowed significant wave height in m, by default 7.0 travel_stw : float, optional The boat will have this fixed speed through water (STW). If set, then `travel_time` must be None. By default None @@ -448,7 +574,13 @@ def optimize( K : int, optional Number of free Bézier control points. By default 6 L : int, optional - Number of points evaluated in each Bézier curve. By default 64 + Number of output waypoints in the final curve (Δt₁). By default 64 + dt_eval_minutes : float, optional + Evaluation grid spacing in minutes (Δt₂). When positive and + ``travel_time`` is set, the optimizer evaluates Bézier curves at + a finer resolution than the output ``L`` for more accurate energy + quadrature. Set to 0 (default) to use ``L`` for both evaluation + and output (Δt₁ == Δt₂). force_L_multiple_of_num_pieces : bool, optional If True, ensures that L-1 is divisible by num_pieces. Raises ValueError if not. By default False @@ -504,24 +636,25 @@ def optimize( # Initial solution from provided curve x0 = curve_to_control(curve0, K=K, num_pieces=num_pieces) # Validate that, after conversion, it still does not cross land - curve_check = control_to_curve( - x0, - src, - dst, - L=L, - num_pieces=num_pieces, - force_L_multiple_of_num_pieces=force_L_multiple_of_num_pieces, - ) - is_land = land(curve_check) - if land is not None and is_land.any(): - ls_idx = jnp.where(is_land)[0].tolist() - warnings.warn( - "[WARNING] The provided initial curve0 crosses land " - "after conversion to control points. " - f"Indices on land (out of {is_land.size}): {ls_idx}", - category=UserWarning, - stacklevel=2, + if land is not None: + curve_check = control_to_curve( + x0, + src, + dst, + L=L, + num_pieces=num_pieces, + force_L_multiple_of_num_pieces=force_L_multiple_of_num_pieces, ) + is_land = land(curve_check) + if is_land.any(): + ls_idx = jnp.where(is_land)[0].tolist() + warnings.warn( + "[WARNING] The provided initial curve0 crosses land " + "after conversion to control points. " + f"Indices on land (out of {is_land.size}): {ls_idx}", + category=UserWarning, + stacklevel=2, + ) # Initial standard deviation to sample new solutions # One sigma is half the distance between src and dst @@ -535,7 +668,14 @@ def optimize( x0=x0, land=land, wavefield=wavefield, + windfield=windfield, penalty=penalty, + weather_penalty_weight=weather_penalty_weight, + wind_penalty_weight=wind_penalty_weight, + wave_penalty_weight=wave_penalty_weight, + distance_penalty_weight=distance_penalty_weight, + tws_limit=tws_limit, + hs_limit=hs_limit, travel_stw=travel_stw, travel_time=travel_time, L=L, @@ -551,6 +691,10 @@ def optimize( seed=seed, keep_top=keep_top, spherical_correction=spherical_correction, + time_offset=time_offset, + cost_fn=cost_fn, + land_margin=land_margin, + dt_eval_minutes=dt_eval_minutes, verbose=verbose, ) time_end = time.time() @@ -562,27 +706,99 @@ def optimize( jnp.asarray(es.best.x), src, dst, - L=L, + L=L, # output resolution (Δt₁) num_pieces=num_pieces, force_L_multiple_of_num_pieces=force_L_multiple_of_num_pieces, ) cost_best: float = es.best.f + # Compute L_eval for fair comparison with curve0 + if dt_eval_minutes > 0 and travel_time is not None: + L_eval = int(travel_time * 60 / dt_eval_minutes) + 1 + if num_pieces > 1: + remainder = (L_eval - 1) % num_pieces + if remainder != 0: + L_eval += num_pieces - remainder + L_eval = max(L_eval, 3) + else: + L_eval = L + # Compare the best curve with the initial one if provided if curve0 is not None: - cost_initial: float = cost_function( - vectorfield=vectorfield, - curve=curve0[jnp.newaxis, :, :], - wavefield=wavefield, - travel_stw=travel_stw, - travel_time=travel_time, - weight_l1=weight_l1, - weight_l2=weight_l2, - spherical_correction=spherical_correction, - ).item() + # Evaluate initial curve at L_eval resolution so the comparison + # uses the same quadrature accuracy as the optimizer. + curve0_eval = control_to_curve( + x0, + src, + dst, + L=L_eval, + num_pieces=num_pieces, + force_L_multiple_of_num_pieces=force_L_multiple_of_num_pieces, + ) + if cost_fn is not None: + cost_initial: float = cost_fn(curve0_eval[jnp.newaxis, :, :]).item() + else: + cost_initial: float = cost_function( + vectorfield=vectorfield, + curve=curve0_eval[jnp.newaxis, :, :], + wavefield=wavefield, + travel_stw=travel_stw, + travel_time=travel_time, + weight_l1=weight_l1, + weight_l2=weight_l2, + spherical_correction=spherical_correction, + time_offset=time_offset, + ).item() if land is not None and penalty > 0: - cost_initial += land.penalization( - curve0[jnp.newaxis, :, :], penalty=penalty + c0_batch = curve0_eval[jnp.newaxis, :, :] + if land_margin > 0 and c0_batch.shape[1] > 2 * land_margin: + c0_check = c0_batch[:, land_margin:-land_margin, :] + else: + c0_check = c0_batch + land_count = land.penalization(c0_check, penalty=1).item() + if land_count > 0: + cost_initial = penalty + land_count + if weather_penalty_weight > 0 and ( + windfield is not None or wavefield is not None + ): + cost_initial += _weather_penalty( + curve0_eval[jnp.newaxis, :, :], + windfield=windfield, + wavefield=wavefield, + tws_limit=tws_limit, + hs_limit=hs_limit, + penalty=weather_penalty_weight, + travel_stw=travel_stw, + travel_time=travel_time, + spherical_correction=spherical_correction, + time_offset=time_offset, + ).item() + if wind_penalty_weight > 0 and windfield is not None: + cost_initial += _wind_penalty_smooth( + curve0_eval[jnp.newaxis, :, :], + windfield=windfield, + tws_limit=tws_limit, + weight=wind_penalty_weight, + travel_stw=travel_stw, + travel_time=travel_time, + spherical_correction=spherical_correction, + time_offset=time_offset, + ).item() + if wave_penalty_weight > 0 and wavefield is not None: + cost_initial += _wave_penalty_smooth( + curve0_eval[jnp.newaxis, :, :], + wavefield=wavefield, + hs_limit=hs_limit, + weight=wave_penalty_weight, + travel_stw=travel_stw, + travel_time=travel_time, + spherical_correction=spherical_correction, + time_offset=time_offset, + ).item() + if distance_penalty_weight > 0 and land is not None: + cost_initial += land.distance_penalty( + curve0_eval[jnp.newaxis, :, :], + weight=distance_penalty_weight, ).item() if cost_initial < cost_best: warnings.warn( @@ -593,7 +809,15 @@ def optimize( stacklevel=2, ) # Then, take the initial curve as the best - curve_best = curve0 + # Output at L resolution (Δt₁), not L_eval + curve_best = control_to_curve( + x0, + src, + dst, + L=L, + num_pieces=num_pieces, + force_L_multiple_of_num_pieces=force_L_multiple_of_num_pieces, + ) cost_best = cost_initial dict_cmaes = { @@ -615,9 +839,19 @@ def optimize_with_increasing_penalization( [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] ] | None = None, + windfield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ] + | None = None, penalty_init: float = 0, penalty_increment: float = 10, maxiter: int = 10, + weather_penalty_weight: float = 0.0, + wind_penalty_weight: float = 0.0, + wave_penalty_weight: float = 0.0, + distance_penalty_weight: float = 0.0, + tws_limit: float = 20.0, + hs_limit: float = 7.0, travel_stw: float | None = None, travel_time: float | None = None, K: int = 6, @@ -633,6 +867,10 @@ def optimize_with_increasing_penalization( weight_l2: float = 0.0, spherical_correction: bool = False, seed: float = jnp.nan, + time_offset: float = 0.0, + cost_fn: Callable[[jnp.ndarray], jnp.ndarray] | None = None, + land_margin: int = 0, + dt_eval_minutes: float = 0.0, verbose: bool = True, ) -> tuple[list[jnp.ndarray], list[float]]: """ @@ -664,6 +902,19 @@ def optimize_with_increasing_penalization( Increment in the penalty for land points. By default 10 maxiter : int, optional Maximum number of iterations. By default 10 + weather_penalty_weight : float, optional + Penalty weight for weather constraint violations (TWS, Hs). + Set to 0 (default) to disable weather penalties. + wind_penalty_weight : float, optional + Smooth wind penalty weight (default 0). + wave_penalty_weight : float, optional + Smooth wave penalty weight (default 0). + distance_penalty_weight : float, optional + EDT-based distance-to-land penalty weight (default 0). + tws_limit : float, optional + Maximum allowed true wind speed in m/s, by default 20.0 + hs_limit : float, optional + Maximum allowed significant wave height in m, by default 7.0 travel_stw : float, optional The boat will have this fixed speed through water (STW). If set, then `travel_time` must be None. By default None @@ -673,7 +924,12 @@ def optimize_with_increasing_penalization( K : int, optional Number of free Bézier control points. By default 6 L : int, optional - Number of points evaluated in each Bézier curve. By default 64 + Number of output waypoints in the final curve (\u0394t\u2081). By default 64 + dt_eval_minutes : float, optional + Evaluation grid spacing in minutes (\u0394t\u2082). When positive and + ``travel_time`` is set, the optimizer evaluates B\u00e9zier curves at + a finer resolution for more accurate energy quadrature. + Set to 0 (default) to use ``L`` for both evaluation and output. popsize : int, optional Population size for the CMA-ES optimizer. By default 200 sigma0 : float, optional @@ -720,7 +976,14 @@ def optimize_with_increasing_penalization( x0=x0, land=land, wavefield=wavefield, + windfield=windfield, penalty=penalty, + weather_penalty_weight=weather_penalty_weight, + wind_penalty_weight=wind_penalty_weight, + wave_penalty_weight=wave_penalty_weight, + distance_penalty_weight=distance_penalty_weight, + tws_limit=tws_limit, + hs_limit=hs_limit, travel_stw=travel_stw, travel_time=travel_time, L=L, @@ -734,6 +997,10 @@ def optimize_with_increasing_penalization( weight_l2=weight_l2, spherical_correction=spherical_correction, seed=seed, + time_offset=time_offset, + cost_fn=cost_fn, + land_margin=land_margin, + dt_eval_minutes=dt_eval_minutes, verbose=verbose, ) if verbose: @@ -749,7 +1016,11 @@ def optimize_with_increasing_penalization( force_L_multiple_of_num_pieces=force_L_multiple_of_num_pieces, ) # sigma0 = es.sigma0 - if land(curve).any(): + if land_margin > 0 and curve.shape[0] > 2 * land_margin: + curve_check = curve[land_margin:-land_margin, :] + else: + curve_check = curve + if land is not None and land(curve_check).any(): penalty += penalty_increment x0 = es.best.x else: diff --git a/routetools/cost.py b/routetools/cost.py index ac59128a..80eb8238 100644 --- a/routetools/cost.py +++ b/routetools/cost.py @@ -4,14 +4,147 @@ from functools import partial import jax.numpy as jnp +import numpy as np from jax import jit, lax from routetools._cost.haversine import ( haversine_distance_from_curve as haversine_distance_from_curve, ) -from routetools._cost.haversine import haversine_meters_components +from routetools._cost.haversine import ( + haversine_meters_components, +) from routetools._cost.waves import wave_adjusted_speed from routetools.land import Land, move_curve_away_from_land +from routetools.weather import ( + DEFAULT_HS_LIMIT, + DEFAULT_TWS_LIMIT, + wave_penalty_smooth, + wind_penalty_smooth, +) + +try: + from routetools.performance import predict_power_batch, predict_power_jax +except ModuleNotFoundError: + predict_power_batch = None + predict_power_jax = None + + +def segment_bearings_deg(curve: jnp.ndarray) -> np.ndarray: + """Compute true-north bearing (degrees) for each route segment. + + Parameters + ---------- + curve : jnp.ndarray + Shape ``(L, 2)`` with ``(lon, lat)`` in degrees. + + Returns + ------- + np.ndarray + Shape ``(L-1,)`` bearing in degrees on ``[0, 360)``. + """ + lon = np.asarray(curve[:, 0], dtype=np.float64) + lat = np.asarray(curve[:, 1], dtype=np.float64) + + dlon = np.diff(lon) + dlat = np.diff(lat) + lat_mid = np.radians((lat[:-1] + lat[1:]) / 2) + + dx = dlon * np.cos(lat_mid) + dy = dlat + return np.degrees(np.arctan2(dx, dy)) % 360.0 + + +def evaluate_route_energy( + curve: jnp.ndarray, + passage_hours: float, + wps: bool, + windfield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ] + | None = None, + wavefield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ] + | None = None, + departure_offset_h: float = 0.0, +) -> tuple[float, float, float]: + """Evaluate total route energy in MWh with optional wind and wave fields. + + Parameters + ---------- + curve : jnp.ndarray + Shape ``(L, 2)`` with ``(lon, lat)`` in degrees. + passage_hours : float + Total passage time in hours. + wps : bool + Whether wingsails are deployed. + windfield : Callable, optional + ``(lon, lat, t) -> (u10, v10)`` in m/s. + wavefield : Callable, optional + ``(lon, lat, t) -> (hs, mwd)`` where ``hs`` is in metres and ``mwd`` + is degrees from North. + departure_offset_h : float + Hours from the field time origin to departure. + + Returns + ------- + tuple[float, float, float] + ``(energy_mwh, max_tws_mps, max_hs_m)``. + """ + if predict_power_batch is None: + raise ModuleNotFoundError( + "routetools.performance is required for evaluate_route_energy." + ) + + n_points = curve.shape[0] + if n_points < 2: + raise ValueError(f"curve must have at least 2 points, got {n_points}") + n_seg = n_points - 1 + + mid_lon = np.asarray((curve[:-1, 0] + curve[1:, 0]) / 2, dtype=np.float64) + mid_lat = np.asarray((curve[:-1, 1] + curve[1:, 1]) / 2, dtype=np.float64) + + seg_frac = (np.arange(n_seg) + 0.5) / n_seg + t_hours = departure_offset_h + seg_frac * passage_hours + + bearing_deg = segment_bearings_deg(curve) + + # Per-segment distances (metres) and per-segment speed. + # Each segment has equal time dt but different spatial length, + # so speed varies along the route. + seg_dist_m = np.asarray(haversine_distance_from_curve(curve), dtype=np.float64) + dt_s = (passage_hours / n_seg) * 3600.0 + v_mps = seg_dist_m / dt_s # (n_seg,) + + if windfield is not None: + u10, v10 = windfield(jnp.array(mid_lon), jnp.array(mid_lat), jnp.array(t_hours)) + u10 = np.asarray(u10, dtype=np.float64) + v10 = np.asarray(v10, dtype=np.float64) + tws = np.sqrt(u10**2 + v10**2) + wind_from_deg = (180.0 + np.degrees(np.arctan2(u10, v10))) % 360.0 + twa = (wind_from_deg - bearing_deg) % 360.0 + else: + tws = np.zeros(n_seg) + twa = np.zeros(n_seg) + + if wavefield is not None: + hs, mwd = wavefield(jnp.array(mid_lon), jnp.array(mid_lat), jnp.array(t_hours)) + hs = np.asarray(hs, dtype=np.float64) + mwd = np.asarray(mwd, dtype=np.float64) + mwa = (mwd - bearing_deg) % 360.0 + else: + hs = np.zeros(n_seg) + mwa = np.zeros(n_seg) + + power_kw = predict_power_batch(tws, twa, hs, mwa, v_mps, wps=wps) + + dt_hours = passage_hours / n_seg + energy_kwh = float(jnp.sum(jnp.asarray(power_kw)) * dt_hours) + energy_mwh = energy_kwh / 1000.0 + + max_tws_mps = float(np.max(tws)) if windfield is not None else 0.0 + max_hs_m = float(np.max(hs)) if wavefield is not None else 0.0 + return energy_mwh, max_tws_mps, max_hs_m def angle_wrt_true_north(dx: jnp.ndarray, dy: jnp.ndarray) -> jnp.ndarray: @@ -60,6 +193,7 @@ def cost_function( weight_l1: float = 1.0, weight_l2: float = 0.0, spherical_correction: bool = False, + time_offset: float = 0.0, ) -> jnp.ndarray: """ Compute the cost of a batch of paths navigating over a vector field. @@ -75,19 +209,24 @@ def cost_function( vector field. curve : jnp.ndarray A batch of trajectories (an array of shape B x L x 2). - Coordinates are (lon, lat) or (x, y). + Coordinates are ordered as ``(lon, lat)`` for geographic fields, or + ``(x, y)`` for projected planar fields. travel_stw : float, optional The boat will have this fixed speed through water (STW). If applying the spherical correction, this speed is in meters per second. travel_time : float, optional - The boat can regulate its STW but must complete the path in exactly this time. - If applying the spherical correction, this time is in seconds. + The boat can regulate its STW but must complete the path in exactly this + time. Units must match the vector field time axis (for ERA5, hours). weight_l1 : float, optional Weight for the L1 norm in the combined cost. Default is 1.0. weight_l2 : float, optional Weight for the L2 norm in the combined cost. Default is 0.0. spherical_correction : bool, optional - Whether to apply spherical correction to distances. Default is False. + Whether to apply spherical correction to distances. If False, coordinates + are expected to already be in projected metric units. + time_offset : float, optional + Offset added to segment timestamps before querying time-variant fields. + Units must match ``travel_time`` (for ERA5, hours). Returns ------- @@ -113,9 +252,13 @@ def cost_function( spherical_correction=spherical_correction, ) elif (travel_time is not None) and is_time_variant: - # Not supported - raise NotImplementedError( - "Time-variant cost function with fixed travel time is not implemented." + cost = cost_function_constant_cost_time_variant( + vectorfield, + curve, + travel_time, + wavefield=wavefield, + spherical_correction=spherical_correction, + time_offset=time_offset, ) elif (travel_time is not None) and (not is_time_variant): cost = cost_function_constant_cost_time_invariant( @@ -374,6 +517,312 @@ def cost_function_constant_cost_time_invariant( return cost * dt +@partial( + jit, + static_argnames=("vectorfield", "wavefield", "spherical_correction"), +) +def cost_function_constant_cost_time_variant( + vectorfield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ], + curve: jnp.ndarray, + travel_time: float, + wavefield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ] + | None = None, + spherical_correction: bool = False, + time_offset: float = 0.0, +) -> jnp.ndarray: + """Compute energy cost for fixed-time routes with time-variant vector fields. + + Each segment takes ``dt = travel_time / (L-1)``. The vector field is + sampled at the midpoint of each segment at its corresponding timestamp, + shifted by *time_offset*. + + Parameters + ---------- + vectorfield : Callable + ``(lon, lat, t) -> (u, v)`` where ``lon, lat`` are in degrees, + ``u, v`` are in m/s, and ``t`` is in the same units as + *travel_time* (typically hours for ERA5). + curve : jnp.ndarray + Batch of trajectories, shape ``(B, L, 2)``. Coordinates are + ``(lon, lat)`` when ``spherical_correction=True`` and projected + ``(x, y)`` in metres when ``spherical_correction=False``. + travel_time : float + Fixed total passage time (hours for ERA5). + wavefield : Callable, optional + ``(lon, lat, t) -> (height, direction)``. Currently unused in this + function and kept for API symmetry. + spherical_correction : bool + Whether to compute distances on the sphere. If False, ``curve`` + coordinates must already be in metres. + time_offset : float + Offset added to segment timestamps before querying the field (hours). + For ERA5 this is the departure's offset in hours from the + dataset epoch (2024-01-01T00:00). + + Returns + ------- + jnp.ndarray + Cost per segment, shape ``(B, L-1)``. With SI inputs this has + units of m^2/s. + """ + n_seg = curve.shape[1] - 1 + dt = travel_time / n_seg + + # Segment midpoints (position) + curvex = (curve[:, :-1, 0] + curve[:, 1:, 0]) / 2 + curvey = (curve[:, :-1, 1] + curve[:, 1:, 1]) / 2 + + # Segment midpoints (time), shifted by departure offset. + seg_times = time_offset + (jnp.arange(n_seg) + 0.5) * dt # shape (n_seg,) + # Broadcast to batch: shape (B, n_seg) + curvet = jnp.broadcast_to(seg_times[None, :], curvex.shape) + + uinterp, vinterp = vectorfield(curvex, curvey, curvet) + + # TODO: wavefield is accepted for API symmetry with cost_function_rise + # but is not yet used in this function. Wire it into an added-resistance + # term once the STW cost model supports wave effects. + + # Distances between waypoints + if spherical_correction: + dx, dy = haversine_meters_components( + curve[:, :-1, 1], + curve[:, :-1, 0], + curve[:, 1:, 1], + curve[:, 1:, 0], + ) + else: + # NOTE: when spherical_correction is False the curve coordinates + # must already be in metres (projected x/y). Raw lon/lat degrees + # will produce dimensionally incorrect costs. + dx = jnp.diff(curve[:, :, 0], axis=1) + dy = jnp.diff(curve[:, :, 1], axis=1) + + # SOG = displacement / dt (convert dt from hours to seconds so + # that SOG is in m/s, matching the wind field units). + dt_s = dt * 3600.0 + dxdt = dx / dt_s + dydt = dy / dt_s + + # STW cost = ‖SOG - current‖² / 2 · dt_s + cost = ((dxdt - uinterp) ** 2 + (dydt - vinterp) ** 2) / 2 + return cost * dt_s + + +# --------------------------------------------------------------------------- +# RISE performance-model cost (for SWOPP3) +# --------------------------------------------------------------------------- +@partial( + jit, + static_argnames=( + "windfield", + "wavefield", + "travel_time", + "wps", + ), +) +def cost_function_rise( + windfield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ], + curve: jnp.ndarray, + travel_time: float, + wavefield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ] + | None = None, + wps: bool = False, + time_offset: float = 0.0, +) -> jnp.ndarray: + """Compute SWOPP3 energy consumption for a batch of fixed-time routes. + + Uses the closed-form RISE performance model (hull drag, aerodynamic + drag, wave added resistance, wingsail thrust) evaluated entirely in + JAX so that the function is JIT-compilable and could support + gradient-based optimisation in the future. + + Each segment takes ``dt = travel_time / (L-1)``. Wind and waves are + sampled at each segment midpoint at its corresponding timestamp. + + Parameters + ---------- + windfield : Callable + ``(lon, lat, t) -> (u10, v10)`` where ``u10, v10`` are wind + components in m/s. ``t`` is in the same unit as *travel_time* + (hours for ERA5). + curve : jnp.ndarray + Batch of trajectories, shape ``(B, L, 2)`` with ``(lon, lat)`` + in degrees. Coordinate order must match the wind/wave fields. + travel_time : float + Fixed total passage time (hours). + wavefield : Callable, optional + ``(lon, lat, t) -> (hs, mwd)`` where ``hs`` is significant wave + height in metres and ``mwd`` is mean wave direction (degrees + from North). + wps : bool + Whether wingsails are deployed. + time_offset : float + Departure offset in hours from the field's time origin. + + Returns + ------- + jnp.ndarray + Total energy in MWh per route, shape ``(B,)``. + """ + if predict_power_jax is None: + raise ModuleNotFoundError( + "routetools.performance is required for cost_function_rise." + ) + + n_seg = curve.shape[1] - 1 + dt_h = travel_time / n_seg # hours per segment + + # ---- segment midpoints (position) ---- + mid_lon = (curve[:, :-1, 0] + curve[:, 1:, 0]) / 2 # (B, n_seg) + mid_lat = (curve[:, :-1, 1] + curve[:, 1:, 1]) / 2 + + # ---- segment midpoints (time) ---- + seg_times = time_offset + (jnp.arange(n_seg) + 0.5) * dt_h # (n_seg,) + curvet = jnp.broadcast_to(seg_times[None, :], mid_lon.shape) # (B, n_seg) + + # ---- segment bearings (great-circle, all in JAX) ---- + lon1_rad = jnp.radians(curve[:, :-1, 0]) + lon2_rad = jnp.radians(curve[:, 1:, 0]) + lat1_rad = jnp.radians(curve[:, :-1, 1]) + lat2_rad = jnp.radians(curve[:, 1:, 1]) + dlon_rad = lon2_rad - lon1_rad + x = jnp.sin(dlon_rad) * jnp.cos(lat2_rad) + y = jnp.cos(lat1_rad) * jnp.sin(lat2_rad) - jnp.sin(lat1_rad) * jnp.cos( + lat2_rad + ) * jnp.cos(dlon_rad) + bearing_rad = jnp.arctan2(x, y) + bearing_deg = jnp.mod(jnp.degrees(bearing_rad), 360.0) + + # ---- segment distances (haversine, metres) & ship speed ---- + dx_m, dy_m = haversine_meters_components( + curve[:, :-1, 1], + curve[:, :-1, 0], + curve[:, 1:, 1], + curve[:, 1:, 0], + ) + seg_dist_m = jnp.sqrt(dx_m**2 + dy_m**2) # (B, n_seg) + dt_s = dt_h * 3600.0 + v_mps = seg_dist_m / dt_s # ship speed m/s per segment + + # ---- wind ---- + u10, v10 = windfield(mid_lon, mid_lat, curvet) + tws = jnp.sqrt(u10**2 + v10**2) + # Wind FROM direction (meteorological convention) + wind_from_deg = jnp.mod(180.0 + jnp.degrees(jnp.arctan2(u10, v10)), 360.0) + twa = jnp.mod(wind_from_deg - bearing_deg, 360.0) + + # ---- waves ---- + if wavefield is not None: + hs, mwd = wavefield(mid_lon, mid_lat, curvet) + mwa = jnp.mod(mwd - bearing_deg, 360.0) + else: + hs = jnp.zeros_like(mid_lon) + mwa = jnp.zeros_like(mid_lon) + + # ---- RISE power model (kW) ---- + power_kw = predict_power_jax(tws, twa, hs, mwa, v_mps, wps=wps) + + # ---- integrate: energy = Σ P_kW · Δt_h → kWh, then /1000 → MWh ---- + energy_mwh = jnp.sum(power_kw, axis=1) * dt_h / 1000.0 + + return energy_mwh + + +def cost_function_rise_penalized( + windfield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ], + curve: jnp.ndarray, + travel_time: float, + wavefield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ] + | None = None, + wps: bool = False, + time_offset: float = 0.0, + wave_penalty_weight: float = 0.0, + wind_penalty_weight: float = 0.0, + hs_limit: float = DEFAULT_HS_LIMIT, + tws_limit: float = DEFAULT_TWS_LIMIT, +) -> jnp.ndarray: + """Compute RISE energy with penalties for high wind/wave conditions. + + This is a variant of cost_function_rise that adds penalty terms to the + energy cost for segments with high wind speeds or wave heights. The + penalties are linear in the segment's maximum wind speed and wave height, + scaled by the provided penalty factors. + + Parameters + ---------- + windfield : Callable + ``(lon, lat, t) -> (u10, v10)`` where ``u10, v10`` are wind + components in m/s. ``t`` is in the same unit as *travel_time* + (hours for ERA5). + curve : jnp.ndarray + Batch of trajectories, shape ``(B, L, 2)`` with ``(lon, lat)`` + in degrees. Coordinate order must match the wind/wave fields. + travel_time : float + Fixed total passage time (hours). + wavefield : Callable, optional + ``(lon, lat, t) -> (hs, mwd)`` where ``hs`` is significant wave + height in metres and ``mwd`` is mean wave direction (degrees + from North). + wps : bool + Whether wingsails are deployed. + time_offset : float + Departure offset in hours from the field's time origin. + wave_penalty_weight : float + Multiplier for the smooth wave-height penalty term. + wind_penalty_weight : float + Multiplier for the smooth wind-speed penalty term. + hs_limit : float + Wave-height threshold in metres used by the smooth penalty. + tws_limit : float + Wind-speed threshold in m/s used by the smooth penalty. + """ + cost = cost_function_rise( + windfield=windfield, + curve=curve, + travel_time=travel_time, + wavefield=wavefield, + wps=wps, + time_offset=time_offset, + ) + + cost_wave = jnp.zeros_like(cost) + if wavefield is not None and wave_penalty_weight > 0: + cost_wave = wave_penalty_smooth( + curve, + wavefield, + hs_limit=hs_limit, + weight=wave_penalty_weight, + travel_time=travel_time, + time_offset=time_offset, + ) + + cost_wind = jnp.zeros_like(cost) + if wind_penalty_weight > 0: + cost_wind = wind_penalty_smooth( + curve, + windfield, + tws_limit=tws_limit, + weight=wind_penalty_weight, + travel_time=travel_time, + time_offset=time_offset, + ) + + return cost + cost_wave + cost_wind + + def interpolate_to_constant_cost( curve: jnp.ndarray, vectorfield: Callable[ diff --git a/routetools/era5/__init__.py b/routetools/era5/__init__.py new file mode 100644 index 00000000..f742a379 --- /dev/null +++ b/routetools/era5/__init__.py @@ -0,0 +1,37 @@ +"""ERA5 weather data ingestion for routetools. + +This module provides two backends for accessing ERA5 reanalysis data: + +1. **CDS API** (``download_cds``): Downloads ERA5 data from the Copernicus + Climate Data Store using the ``cdsapi`` package. Requires a CDS account + and API key. + +2. **Google Cloud Storage** (``download_gcs``): Accesses the ERA5 dataset + stored as Zarr on Google Cloud (via the WeatherBench2 / Pangeo archive). + No API key required. + +Both backends produce NetCDF files on disk that can be loaded with +:func:`load_era5_vectorfield` and :func:`load_era5_wavefield` to obtain +JAX-compatible field closures matching the interface expected by +:func:`routetools.cost.cost_function`. +""" + +from routetools.era5.loader import ( + load_era5_vectorfield as load_era5_vectorfield, +) +from routetools.era5.loader import ( + load_era5_wavefield as load_era5_wavefield, +) +from routetools.era5.loader import ( + load_era5_windfield as load_era5_windfield, +) +from routetools.era5.loader import ( + load_natural_earth_land_mask as load_natural_earth_land_mask, +) + +__all__ = [ + "load_era5_vectorfield", + "load_era5_wavefield", + "load_era5_windfield", + "load_natural_earth_land_mask", +] diff --git a/routetools/era5/download_cds.py b/routetools/era5/download_cds.py new file mode 100644 index 00000000..4c620d3b --- /dev/null +++ b/routetools/era5/download_cds.py @@ -0,0 +1,263 @@ +"""Download ERA5 data from the Copernicus Climate Data Store (CDS). + +Requires the ``cdsapi`` package and valid CDS API credentials. +See https://cds.climate.copernicus.eu/how-to-api for setup instructions. + +The functions in this module download ERA5 reanalysis single-level and wave +data for the route corridors needed by SWOPP3 and store them as NetCDF files. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Route corridor bounding boxes (North, West, South, East) +# With generous padding around the actual route endpoints. +# --------------------------------------------------------------------------- +CORRIDORS: dict[str, list[float]] = { + # Atlantic: Santander (43.6, -4.0) → New York (40.6, -69.0) + # Pad: +10° N/S, +5° E/W + "atlantic": [60, -80, 25, 10], + # Pacific: Tokyo (34.8, 140.0) → Los Angeles (34.4, -121.0) + # Great-circle goes up to ~50°N; pad generously + # Split into two boxes? No — ERA5 handles wrap-around. + # We use lon 120E to 240E (== -120W) via 0-360 convention. + "pacific": [55, 120, 15, 240], +} + +# ERA5 variables we need +WIND_VARIABLES = ["10m_u_component_of_wind", "10m_v_component_of_wind"] +WAVE_VARIABLES = [ + "significant_height_of_combined_wind_waves_and_swell", + "mean_wave_direction", +] + +# Default years / months for SWOPP3 (all of 2024) +DEFAULT_YEAR = "2024" +DEFAULT_MONTHS = [f"{m:02d}" for m in range(1, 13)] +DEFAULT_DAYS = [f"{d:02d}" for d in range(1, 32)] +DEFAULT_TIMES = [f"{h:02d}:00" for h in range(0, 24)] # hourly + + +def _output_filename( + output_dir: Path, + field: str, + corridor: str, + year: str, + months: list[str], +) -> Path: + """Build the output filename, including month range for partial years.""" + all_months = [f"{m:02d}" for m in range(1, 13)] + sorted_months = sorted(months) + if sorted_months != all_months: + m_min, m_max = sorted_months[0], sorted_months[-1] + suffix = m_min if m_min == m_max else f"{m_min}-{m_max}" + return output_dir / f"era5_{field}_{corridor}_{year}_{suffix}.nc" + return output_dir / f"era5_{field}_{corridor}_{year}.nc" + + +def _ensure_cdsapi() -> Any: + """Import and return a CDS API client, raising a clear error if missing.""" + try: + import cdsapi + except ImportError as exc: + raise ImportError( + "The 'cdsapi' package is required for CDS downloads. " + "Install it with: pip install cdsapi\n" + "Then configure your API key: https://cds.climate.copernicus.eu/how-to-api" + ) from exc + return cdsapi.Client() + + +def download_era5_wind( + output_dir: str | Path = "data/era5", + corridor: str = "atlantic", + year: str = DEFAULT_YEAR, + months: list[str] | None = None, + days: list[str] | None = None, + times: list[str] | None = None, + grid: list[float] | None = None, +) -> Path: + """Download ERA5 10-m wind components for a route corridor. + + Parameters + ---------- + output_dir : str or Path + Directory to store downloaded files. + corridor : str + One of ``"atlantic"`` or ``"pacific"``. + year : str + Year to download (default ``"2024"``). + months : list[str], optional + Months (default: all 12). + days : list[str], optional + Days (default: all 31). + times : list[str], optional + Hours in ``"HH:00"`` format (default: hourly). + grid : list[float], optional + ``[lat_res, lon_res]`` in degrees (default ``[0.25, 0.25]``). + + Returns + ------- + Path + Path to the downloaded NetCDF file. + """ + client = _ensure_cdsapi() + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + months = months or DEFAULT_MONTHS + days = days or DEFAULT_DAYS + times = times or DEFAULT_TIMES + grid = grid or [0.25, 0.25] + + area = CORRIDORS[corridor] + filename = _output_filename(output_dir, "wind", corridor, year, months) + + if filename.exists(): + logger.info("File already exists, skipping download: %s", filename) + return filename + + logger.info( + "Downloading ERA5 wind data for %s corridor, year %s ...", corridor, year + ) + + client.retrieve( + "reanalysis-era5-single-levels", + { + "product_type": "reanalysis", + "variable": WIND_VARIABLES, + "year": year, + "month": months, + "day": days, + "time": times, + "area": area, + "grid": grid, + "data_format": "netcdf", + }, + str(filename), + ) + + logger.info("Downloaded: %s", filename) + return filename + + +def download_era5_waves( + output_dir: str | Path = "data/era5", + corridor: str = "atlantic", + year: str = DEFAULT_YEAR, + months: list[str] | None = None, + days: list[str] | None = None, + times: list[str] | None = None, + grid: list[float] | None = None, +) -> Path: + """Download ERA5 wave data (Hs and mean direction) for a route corridor. + + Parameters + ---------- + output_dir : str or Path + Directory to store downloaded files. + corridor : str + One of ``"atlantic"`` or ``"pacific"``. + year : str + Year to download (default ``"2024"``). + months : list[str], optional + Months (default: all 12). + days : list[str], optional + Days (default: all 31). + times : list[str], optional + Hours in ``"HH:00"`` format (default: hourly). + grid : list[float], optional + ``[lat_res, lon_res]`` in degrees (default ``[0.25, 0.25]``). + + Returns + ------- + Path + Path to the downloaded NetCDF file. + """ + client = _ensure_cdsapi() + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + months = months or DEFAULT_MONTHS + days = days or DEFAULT_DAYS + times = times or DEFAULT_TIMES + grid = grid or [0.25, 0.25] + + area = CORRIDORS[corridor] + filename = _output_filename(output_dir, "waves", corridor, year, months) + + if filename.exists(): + logger.info("File already exists, skipping download: %s", filename) + return filename + + logger.info( + "Downloading ERA5 wave data for %s corridor, year %s ...", corridor, year + ) + + client.retrieve( + "reanalysis-era5-single-levels", + { + "product_type": "reanalysis", + "variable": WAVE_VARIABLES, + "year": year, + "month": months, + "day": days, + "time": times, + "area": area, + "grid": grid, + "data_format": "netcdf", + }, + str(filename), + ) + + logger.info("Downloaded: %s", filename) + return filename + + +def download_all( + output_dir: str | Path = "data/era5", + year: str = DEFAULT_YEAR, + corridors: list[str] | None = None, + **kwargs: object, +) -> list[Path]: + """Download all ERA5 data needed for SWOPP3. + + Downloads wind and wave data for both Atlantic and Pacific corridors. + + Parameters + ---------- + output_dir : str or Path + Directory to store downloaded files. + year : str + Year to download. + corridors : list[str], optional + Corridors to download (default: both). + **kwargs + Forwarded to :func:`download_era5_wind` and + :func:`download_era5_waves`. + + Returns + ------- + list[Path] + Paths to all downloaded NetCDF files. + """ + corridors = corridors or ["atlantic", "pacific"] + files: list[Path] = [] + for corridor in corridors: + files.append( + download_era5_wind( + output_dir=output_dir, corridor=corridor, year=year, **kwargs + ) + ) + files.append( + download_era5_waves( + output_dir=output_dir, corridor=corridor, year=year, **kwargs + ) + ) + return files diff --git a/routetools/era5/download_gcs.py b/routetools/era5/download_gcs.py new file mode 100644 index 00000000..b9715ca3 --- /dev/null +++ b/routetools/era5/download_gcs.py @@ -0,0 +1,467 @@ +"""Download ERA5 data from Google Cloud Storage (Zarr format). + +Accesses ERA5 reanalysis data from the WeatherBench2 / Pangeo archive +hosted on Google Cloud. No API key is required — the data is publicly +accessible. + +References +---------- +- WeatherBench2: https://weatherbench2.readthedocs.io/ +- ERA5 on GCS: gs://gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3 +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + import xarray as xr + +logger = logging.getLogger(__name__) + +# Google Cloud Storage paths for ERA5 (ARCO-ERA5, 0.25° hourly) +GCS_ERA5_SINGLE_LEVEL = ( + "gs://gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3" +) + +# Route corridor bounds: {name: (lat_min, lat_max, lon_min, lon_max)} +# Longitudes in 0..360 convention for the Pacific to handle antimeridian. +CORRIDORS: dict[str, tuple[float, float, float, float]] = { + "atlantic": (25.0, 60.0, -80.0, 10.0), + "pacific": (15.0, 55.0, 120.0, 240.0), +} + +# ERA5 variable names in the ARCO-ERA5 Zarr archive +WIND_U_VAR = "10m_u_component_of_wind" +WIND_V_VAR = "10m_v_component_of_wind" +WAVE_HS_VAR = "significant_height_of_combined_wind_waves_and_swell" +WAVE_DIR_VAR = "mean_wave_direction" + + +def _ensure_deps() -> None: + """Check that xarray, gcsfs, and zarr are available.""" + missing = [] + for pkg in ("xarray", "gcsfs", "zarr"): + try: + __import__(pkg) + except ImportError: + missing.append(pkg) + if missing: + raise ImportError( + f"The following packages are required for GCS downloads: " + f"{', '.join(missing)}. Install them with:\n" + f" pip install {' '.join(missing)}" + ) + + +def _open_era5_zarr(variables: list[str]) -> xr.Dataset: + """Open the ERA5 Zarr store on GCS and select the given variables. + + Uses ``drop_variables`` to avoid loading metadata for the ~270 + other ERA5 fields, which cuts memory from ~16 GB to ~300 MB. + """ + import gcsfs + import xarray as xr + + fs = gcsfs.GCSFileSystem(token="anon") + store_path = GCS_ERA5_SINGLE_LEVEL.removeprefix("gs://") + + # List top-level entries to discover all variable/coordinate names + entries = fs.ls(store_path, detail=False) + all_names = [ + e.split("/")[-1] for e in entries if not e.split("/")[-1].startswith(".") + ] + + # Keep only the requested variables + coordinate dimensions + coord_names = {"time", "valid_time", "latitude", "longitude", "lat", "lon"} + keep = set(variables) | coord_names + drop = [n for n in all_names if n not in keep] + + store = fs.get_mapper(GCS_ERA5_SINGLE_LEVEL) + ds = xr.open_zarr(store, consolidated=True, drop_variables=drop) + return ds[variables] + + +def _detect_time_dim(ds: xr.Dataset) -> str: + """Return the name of the time dimension ('time' or 'valid_time').""" + for name in ("time", "valid_time"): + if name in ds.dims or name in ds.coords: + return name + raise KeyError( + f"Cannot find time dimension in dataset. " + f"Available dims: {list(ds.dims)}, coords: {list(ds.coords)}" + ) + + +def _normalize_time_dim(ds: xr.Dataset) -> xr.Dataset: + """Rename the time dimension to 'valid_time' for CDS compatibility.""" + time_dim = _detect_time_dim(ds) + if time_dim != "valid_time": + ds = ds.rename({time_dim: "valid_time"}) + return ds + + +def _select_corridor( + ds: xr.Dataset, + corridor: str, + year: str = "2024", + months: list[int] | None = None, + time_step: int = 6, +) -> xr.Dataset: + """Subset dataset to a corridor, year, and temporal step. + + Parameters + ---------- + ds : xarray.Dataset + Full ERA5 dataset. + corridor : str + Name of the corridor (``"atlantic"`` or ``"pacific"``). + year : str + Year to select. + months : list[int], optional + Months to include (1-12). Default: all 12. + time_step : int + Hours between time steps (default 1 for hourly). + + Returns + ------- + xarray.Dataset + Subset dataset. + """ + lat_min, lat_max, lon_min, lon_max = CORRIDORS[corridor] + time_dim = _detect_time_dim(ds) + + # Time selection + if months is not None: + first_month = min(months) + last_month = max(months) + t_start = f"{year}-{first_month:02d}-01" + # Use end of last month by selecting up to the start of next month + if last_month == 12: + t_end = f"{year}-12-31T23:59:59" + else: + t_end = f"{year}-{last_month + 1:02d}-01" + ds = ds.sel({time_dim: slice(t_start, t_end)}) + # Filter to exact months in case non-contiguous months are requested + ds = ds.sel({time_dim: ds[time_dim].dt.month.isin(months)}) + else: + ds = ds.sel({time_dim: slice(f"{year}-01-01", f"{year}-12-31")}) + if time_step > 1: + ds = ds.isel({time_dim: slice(None, None, time_step)}) + + # Spatial selection + # ERA5 latitude is typically 90 to -90 (descending) + lat_dim = "latitude" if "latitude" in ds.dims else "lat" + lon_dim = "longitude" if "longitude" in ds.dims else "lon" + + lats = ds[lat_dim].values + lons = ds[lon_dim].values + + # Latitude selection (ERA5 lat is often descending: 90 → -90) + if lats[0] > lats[-1]: + lat_slice = slice(lat_max, lat_min) + else: + lat_slice = slice(lat_min, lat_max) + + # Determine whether the dataset uses [0, 360) longitudes + ds_uses_0_360 = float(np.min(lons)) >= 0 and float(np.max(lons)) > 180 + + if lon_min < 0 and ds_uses_0_360: + # Corridor spans negative longitudes but dataset is in [0, 360). + # E.g. Atlantic (-80, 10) → need [280, 360) ∪ [0, 10] then + # relabel to [-80, 10]. + import xarray as xr + + lon_min_360 = lon_min % 360 # -80 → 280 + part_west = ds.sel({lat_dim: lat_slice, lon_dim: slice(lon_min_360, 360.0)}) + part_east = ds.sel({lat_dim: lat_slice, lon_dim: slice(0.0, lon_max)}) + ds = xr.concat([part_west, part_east], dim=lon_dim) + # Relabel longitudes to [-180, 180) convention + new_lons = ds[lon_dim].values.copy() + new_lons[new_lons >= 180] -= 360 + ds = ds.assign_coords({lon_dim: new_lons}) + elif lon_max > 180: + # Pacific-style corridor already in [0, 360) range (e.g. 120–240) + # Convert dataset lons from [-180, 180] to [0, 360] if needed + if np.any(lons < 0): + ds = ds.assign_coords({lon_dim: np.mod(lons, 360)}) + ds = ds.sortby(lon_dim) + + ds = ds.sel({lat_dim: lat_slice, lon_dim: slice(lon_min, lon_max)}) + else: + # Both corridor and dataset are in compatible ranges + ds = ds.sel({lat_dim: lat_slice, lon_dim: slice(lon_min, lon_max)}) + + return ds + + +def _save_with_low_memory(ds: xr.Dataset, path: Path) -> None: + """Save a dask-backed dataset to NetCDF with bounded memory usage. + + Uses a bounded thread pool (16 workers) instead of the default + scheduler which launches too many concurrent tasks. This caps + the number of chunks held in RAM simultaneously while still + downloading in parallel. + """ + import dask + from dask.threaded import get as threaded_get + + with dask.config.set(scheduler=threaded_get, num_workers=16): + ds.to_netcdf(path) + + +def _download_by_month( + variables: list[str], + field: str, + corridor: str, + year: str, + months: list[int], + time_step: int, + output_path: Path, +) -> None: + """Download data month-by-month to bound peak memory usage. + + Opens a fresh Zarr connection per month so the dask graph stays small, + writes each month to a temporary NetCDF file, then concatenates them + into the final output. + """ + import dask + import xarray as xr + from dask.threaded import get as threaded_get + + tmp_dir = output_path.parent / f"_tmp_{output_path.stem}" + tmp_dir.mkdir(exist_ok=True) + tmp_files: list[Path] = [] + + try: + for month in months: + tmp_path = tmp_dir / f"m{month:02d}.nc" + if tmp_path.exists(): + tmp_files.append(tmp_path) + continue + logger.info(" Month %02d/%s ...", month, year) + ds = _open_era5_zarr(variables) + ds = _select_corridor(ds, corridor, year, [month], time_step) + ds = _normalize_time_dim(ds) + with dask.config.set(scheduler=threaded_get, num_workers=16): + ds.to_netcdf(tmp_path) + del ds + tmp_files.append(tmp_path) + + # Concatenate monthly files into final output + logger.info(" Merging %d monthly files ...", len(tmp_files)) + ds_merged = xr.open_mfdataset(tmp_files, combine="by_coords") + try: + _save_with_low_memory(ds_merged, output_path) + finally: + ds_merged.close() + finally: + # Clean up temporary files + for f in tmp_files: + f.unlink(missing_ok=True) + if tmp_dir.exists(): + tmp_dir.rmdir() + + +def _output_filename( + output_dir: Path, + field: str, + corridor: str, + year: str, + months: list[int] | None = None, +) -> Path: + """Build the output filename, including month range for partial years.""" + if months is not None and sorted(months) != list(range(1, 13)): + m_min, m_max = min(months), max(months) + suffix = f"{m_min:02d}" if m_min == m_max else f"{m_min:02d}-{m_max:02d}" + return output_dir / f"era5_{field}_{corridor}_{year}_{suffix}.nc" + return output_dir / f"era5_{field}_{corridor}_{year}.nc" + + +def download_era5_wind_gcs( + output_dir: str | Path = "data/era5", + corridor: str = "atlantic", + year: str = "2024", + months: list[int] | None = None, + time_step: int = 1, +) -> Path: + """Download ERA5 10-m wind data from GCS and save as NetCDF. + + Parameters + ---------- + output_dir : str or Path + Output directory. + corridor : str + Route corridor name. + year : str + Year to download. + months : list[int], optional + Months to include (1-12). Default: all 12. + time_step : int + Hours between time steps (default 1 for hourly). + + Returns + ------- + Path + Path to saved NetCDF file. + """ + _ensure_deps() + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + filename = _output_filename(output_dir, "wind", corridor, year, months) + + if filename.exists(): + logger.info("File already exists, skipping: %s", filename) + return filename + + dl_months = months if months is not None else list(range(1, 13)) + + if len(dl_months) == 1: + # Single month — download directly without temp files + logger.info("Opening ERA5 wind data on GCS for %s/%s ...", corridor, year) + ds = _open_era5_zarr([WIND_U_VAR, WIND_V_VAR]) + ds = _select_corridor(ds, corridor, year, dl_months, time_step) + ds = _normalize_time_dim(ds) + logger.info("Downloading and saving to %s ...", filename) + _save_with_low_memory(ds, filename) + else: + logger.info( + "Downloading ERA5 wind for %s/%s (%d months) ...", + corridor, + year, + len(dl_months), + ) + _download_by_month( + [WIND_U_VAR, WIND_V_VAR], + "wind", + corridor, + year, + dl_months, + time_step, + filename, + ) + + logger.info("Saved: %s (%.1f MB)", filename, filename.stat().st_size / 1e6) + return filename + + +def download_era5_waves_gcs( + output_dir: str | Path = "data/era5", + corridor: str = "atlantic", + year: str = "2024", + months: list[int] | None = None, + time_step: int = 1, +) -> Path: + """Download ERA5 wave data (Hs + direction) from GCS and save as NetCDF. + + Parameters + ---------- + output_dir : str or Path + Output directory. + corridor : str + Route corridor name. + year : str + Year to download. + months : list[int], optional + Months to include (1-12). Default: all 12. + time_step : int + Hours between time steps (default 1 for hourly). + + Returns + ------- + Path + Path to saved NetCDF file. + """ + _ensure_deps() + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + filename = _output_filename(output_dir, "waves", corridor, year, months) + + if filename.exists(): + logger.info("File already exists, skipping: %s", filename) + return filename + + dl_months = months if months is not None else list(range(1, 13)) + + if len(dl_months) == 1: + # Single month — download directly without temp files + logger.info("Opening ERA5 wave data on GCS for %s/%s ...", corridor, year) + ds = _open_era5_zarr([WAVE_HS_VAR, WAVE_DIR_VAR]) + ds = _select_corridor(ds, corridor, year, dl_months, time_step) + ds = _normalize_time_dim(ds) + logger.info("Downloading and saving to %s ...", filename) + _save_with_low_memory(ds, filename) + else: + logger.info( + "Downloading ERA5 waves for %s/%s (%d months) ...", + corridor, + year, + len(dl_months), + ) + _download_by_month( + [WAVE_HS_VAR, WAVE_DIR_VAR], + "waves", + corridor, + year, + dl_months, + time_step, + filename, + ) + + logger.info("Saved: %s (%.1f MB)", filename, filename.stat().st_size / 1e6) + return filename + + +def download_all_gcs( + output_dir: str | Path = "data/era5", + year: str = "2024", + months: list[int] | None = None, + corridors: list[str] | None = None, + time_step: int = 1, +) -> list[Path]: + """Download all ERA5 data needed for SWOPP3 from GCS. + + Parameters + ---------- + output_dir : str or Path + Output directory. + year : str + Year to download. + months : list[int], optional + Months to include (1-12). Default: all 12. + corridors : list[str], optional + Corridors to download (default: both). + time_step : int + Hours between time steps. + + Returns + ------- + list[Path] + Paths to all downloaded NetCDF files. + """ + corridors = corridors or ["atlantic", "pacific"] + files: list[Path] = [] + for corridor in corridors: + files.append( + download_era5_wind_gcs( + output_dir=output_dir, + corridor=corridor, + year=year, + months=months, + time_step=time_step, + ) + ) + files.append( + download_era5_waves_gcs( + output_dir=output_dir, + corridor=corridor, + year=year, + months=months, + time_step=time_step, + ) + ) + return files diff --git a/routetools/era5/loader.py b/routetools/era5/loader.py new file mode 100644 index 00000000..7f7d9387 --- /dev/null +++ b/routetools/era5/loader.py @@ -0,0 +1,995 @@ +"""Load ERA5 NetCDF data into JAX-compatible field closures. + +The closures returned by the ``load_*`` functions conform to the interface +expected by :func:`routetools.cost.cost_function`: + +- **vectorfield** ``(lon, lat, t) -> (u, v)`` — ocean current components. + For ERA5 wind data this returns 10-m wind components, which can be used + by a ship performance / polar model. +- **wavefield** ``(lon, lat, t) -> (height, direction)`` — significant wave + height (m) and mean wave direction (degrees from North). +- **windfield** ``(lon, lat, t) -> (u10, v10)`` — alias for the wind loader, + separated from "vectorfield" to make the distinction explicit. + +The ``t`` coordinate represents *hours elapsed since a reference epoch* +(typically the departure time). The loader precomputes the mapping from +absolute NetCDF timestamps to a ``[0, N_t)`` integer index so that +interpolation via ``jax.scipy.ndimage.map_coordinates`` works correctly. +""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Callable, Sequence +from datetime import UTC, datetime +from math import ceil +from pathlib import Path +from typing import TYPE_CHECKING + +import jax +import jax.numpy as jnp +import numpy as np +import xarray as xr + +from routetools.vectorfield import time_variant + +if TYPE_CHECKING: + from routetools.land import Land + +logger = logging.getLogger(__name__) + +_ERA5_FILE_RE = re.compile( + r"^(?Pera5_[^_]+_[^_]+_)(?P\d{4})(?:_(?P\d{2}(?:-\d{2})?))?\.nc$" +) + + +# ── helpers ─────────────────────────────────────────────────────────────── + + +def _to_numpy_datetime64( + value: datetime | str | np.datetime64, +) -> np.datetime64: + """Normalize supported datetime inputs to ``numpy.datetime64``.""" + if isinstance(value, np.datetime64): + return value + return np.datetime64(value) + + +def _load_dataset(path: str | Path) -> xr.Dataset: + """Open a NetCDF file with xarray. + + Tries the ``scipy`` engine first (pure-Python, no C-library + compatibility issues), then falls back to ``netcdf4``. + """ + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"ERA5 data file not found: {path}") + last_exc: Exception | None = None + for engine in ("scipy", "netcdf4", None): + try: + return xr.open_dataset(path, engine=engine) + except Exception as exc: # noqa: BLE001 + last_exc = exc + logger.debug( + "Failed to open %s with engine %r", path, engine, exc_info=True + ) + continue + raise RuntimeError( + f"Failed to open ERA5 data file {path!s} with engines " + "('scipy', 'netcdf4', None)" + ) 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")) + 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``. + + CDS downloads produce ``valid_time`` while the GCS Zarr archive uses + ``time``. This helper normalises to ``valid_time`` so that multi-file + concatenation works regardless of provenance. + """ + if "time" in ds.dims and "valid_time" not in ds.dims: + ds = ds.rename({"time": "valid_time"}) + return ds + + +def _load_datasets(paths: str | Path | Sequence[str | Path]) -> xr.Dataset: + """Open one or more NetCDF files and concatenate along the time axis. + + When multiple files are provided they are concatenated in order. The + time dimension is normalised to ``valid_time`` before concatenation so + that files downloaded from CDS (``valid_time``) and GCS (``time``) can + be freely mixed. + """ + if isinstance(paths, str | Path): + return _normalize_time_coord(_load_dataset(paths)) + + datasets = [_normalize_time_coord(_load_dataset(p)) for p in paths] + if len(datasets) == 1: + return datasets[0] + + time_name = _get_coord_name(datasets[0], ["valid_time", "time"]) + combined = xr.concat(datasets, dim=time_name) + # Ensure monotonic time after concatenation + combined = combined.sortby(time_name) + return combined + + +def _get_coord_name(ds: xr.Dataset, candidates: list[str]) -> str: + """Return the first coordinate name that exists in *ds*.""" + for name in candidates: + if name in ds.coords or name in ds.dims: + return name + raise KeyError( + f"None of {candidates} found in dataset coordinates: {list(ds.coords)}" + ) + + +def _slice_dataset_time( + ds: xr.Dataset, + *, + time_start: datetime | str | np.datetime64 | None = None, + time_end: datetime | str | np.datetime64 | None = None, +) -> xr.Dataset: + """Return a dataset sliced to the requested time window.""" + if time_start is None and time_end is None: + return ds + + time_name = _get_coord_name(ds, ["time", "valid_time"]) + start_np = None if time_start is None else _to_numpy_datetime64(time_start) + end_np = None if time_end is None else _to_numpy_datetime64(time_end) + + if start_np is not None and end_np is not None and end_np < start_np: + raise ValueError("time_end must be greater than or equal to time_start") + + sliced = ds.sel({time_name: slice(start_np, end_np)}) + if sliced.sizes.get(time_name, 0) == 0: + raise ValueError( + "Requested ERA5 time window is empty: " + f"start={time_start!s}, end={time_end!s}" + ) + return sliced + + +def _prepare_grid( + ds: xr.Dataset, + departure_time: datetime | str | np.datetime64 | None = None, +) -> dict: + """Extract grid metadata and convert to JAX arrays. + + Returns a dict with keys: + - ``lat``: 1-D numpy array of latitudes (ascending) + - ``lon``: 1-D numpy array of longitudes (ascending) + - ``begin``: ``jnp.array([t0, lat0, lon0])`` — grid origin + - ``spacing``: ``jnp.array([dt, dlat, dlon])`` — grid step sizes + - ``departure_offset_h``: hours from first NetCDF timestamp to + *departure_time* (0 if *departure_time* is ``None``). + """ + lat_name = _get_coord_name(ds, ["latitude", "lat"]) + lon_name = _get_coord_name(ds, ["longitude", "lon"]) + time_name = _get_coord_name(ds, ["time", "valid_time"]) + + lats = ds[lat_name].values.astype(np.float64) + lons = ds[lon_name].values.astype(np.float64) + times = ds[time_name].values # numpy datetime64 array + + # Ensure ascending latitude + if lats[0] > lats[-1]: + lats = lats[::-1] + ds = ds.isel({lat_name: slice(None, None, -1)}) + + # Ensure ascending longitude + if lons[0] > lons[-1]: + lons = lons[::-1] + ds = ds.isel({lon_name: slice(None, None, -1)}) + + # Convert times to hours since first timestamp + t0_np = times[0] + times_hours = (times - t0_np) / np.timedelta64(1, "h") + times_hours = times_hours.astype(np.float64) + + # Compute departure offset + if departure_time is not None: + if isinstance(departure_time, str | datetime): + departure_time = np.datetime64(departure_time) + departure_offset_h = float((departure_time - t0_np) / np.timedelta64(1, "h")) + else: + departure_offset_h = 0.0 + + # Grid parameters + dt = float(times_hours[1] - times_hours[0]) if len(times_hours) > 1 else 1.0 + dlat = float(lats[1] - lats[0]) if len(lats) > 1 else 1.0 + dlon = float(lons[1] - lons[0]) if len(lons) > 1 else 1.0 + + begin = jnp.array([times_hours[0], lats[0], lons[0]], dtype=jnp.float32)[None, :] + spacing = jnp.array([dt, dlat, dlon], dtype=jnp.float32)[None, :] + + return { + "lat": lats, + "lon": lons, + "times_hours": times_hours, + "begin": begin, + "spacing": spacing, + "departure_offset_h": departure_offset_h, + "ds": ds, + "lat_name": lat_name, + "lon_name": lon_name, + "time_name": time_name, + } + + +def load_dataset_epoch( + path: str | Path | Sequence[str | Path], + *, + time_start: datetime | str | np.datetime64 | None = None, + time_end: datetime | str | np.datetime64 | None = None, +) -> datetime: + """Return the first ERA5 timestamp as a naive UTC datetime. + + Parameters + ---------- + path : str, Path, or list thereof + Path(s) to ERA5 NetCDF dataset(s). When multiple paths are provided, + they are concatenated and the earliest timestamp is returned. + + Returns + ------- + datetime + First dataset timestamp as timezone-naive UTC datetime. + """ + ds = _slice_dataset_time( + _load_datasets(path), + time_start=time_start, + time_end=time_end, + ) + try: + time_name = _get_coord_name(ds, ["time", "valid_time"]) + epoch_np = ds[time_name].values[0] + finally: + ds.close() + + ts = (epoch_np - np.datetime64("1970-01-01T00:00:00")) / np.timedelta64(1, "s") + return datetime.fromtimestamp(float(ts), tz=UTC).replace(tzinfo=None) + + +def _build_field_closure( + data_a: jnp.ndarray, + data_b: jnp.ndarray, + begin: jnp.ndarray, + spacing: jnp.ndarray, + departure_offset_h: float, + order: int = 1, + mode: str = "nearest", + add_time_variant_attr: bool = False, +) -> Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], + tuple[jnp.ndarray, jnp.ndarray], +]: + """Build a JAX-interpolated field closure. + + Parameters + ---------- + data_a, data_b : jnp.ndarray + 3-D arrays of shape ``(T, lat, lon)`` for the two field components. + begin, spacing : jnp.ndarray + Grid origin and step size, each of shape ``(1, 3)`` — ``[t, lat, lon]``. + departure_offset_h : float + Offset in hours to add to the ``t`` argument so that ``t=0`` maps to + the departure time within the dataset. + order : int + Interpolation order (1 = linear). + mode : str + Boundary mode for ``map_coordinates``. + add_time_variant_attr : bool + If ``True``, mark the returned function as ``time_variant``. + + Returns + ------- + Callable + ``(lon, lat, t) -> (a, b)`` closure. + """ + dep_offset = jnp.float32(departure_offset_h) + + def _field( + lon: jnp.ndarray, + lat: jnp.ndarray, + ts: jnp.ndarray | int | float, + ) -> tuple[jnp.ndarray, jnp.ndarray]: + # Normalise ts + if isinstance(ts, int | float): + ts = jnp.array([ts], dtype=jnp.float32) + ts = jnp.atleast_1d(ts) + + # Handle mismatched lengths (same pattern as benchmark.py) + diff = lat.shape[0] - ts.shape[0] + if diff > 0: + ts_full = jnp.concatenate([ts, jnp.full(diff, ts[-1])]) + elif diff < 0: + ts_full = ts[: lat.shape[0]] + else: + ts_full = ts + + # Offset: t=0 means departure time + ts_full = ts_full + dep_offset + + # Handle 2D inputs + if lat.ndim > 1: + shape = lat.shape + if ts_full.ndim < lat.ndim: + ts_full = jnp.repeat(ts_full[:, None], lat.shape[1], axis=1) + ts_full = ts_full.flatten() + lat = lat.flatten() + lon = lon.flatten() + else: + shape = None + + # Build coordinates: [t, lat, lon] + x = jnp.stack([ts_full, lat, lon], axis=-1) + + # Normalise to grid indices + coords = (x - begin) / spacing # shape (N, 3) + + # Interpolate + a = jax.scipy.ndimage.map_coordinates(data_a, coords.T, order=order, mode=mode) + b = jax.scipy.ndimage.map_coordinates(data_b, coords.T, order=order, mode=mode) + + # Reshape if needed + if shape is not None: + a = a.reshape(shape) + b = b.reshape(shape) + + return a, b + + if add_time_variant_attr: + _field = time_variant(_field) + + return _field + + +# ── public API ──────────────────────────────────────────────────────────── + + +def load_era5_windfield( + path: str | Path | Sequence[str | Path], + departure_time: datetime | str | np.datetime64 | None = None, + voyage_hours: float | None = None, + time_start: datetime | str | np.datetime64 | None = None, + time_end: datetime | str | np.datetime64 | None = None, + order: int = 1, + u_var: str | None = None, + v_var: str | None = None, +) -> Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], + tuple[jnp.ndarray, jnp.ndarray], +]: + """Load ERA5 10-m wind data and return a windfield closure. + + The returned function has the signature + ``(lon, lat, t) -> (u10, v10)`` + where ``u10`` and ``v10`` are the 10-m wind components in m/s. + + Parameters + ---------- + path : str, Path, or list thereof + Path(s) to ERA5 wind NetCDF file(s). When multiple paths are + given they are concatenated along the time axis. + departure_time : datetime or str or np.datetime64, optional + The voyage departure time. When provided, ``t = 0`` in the returned + closure corresponds to this datetime; otherwise ``t = 0`` maps to the + first timestamp in the dataset. + voyage_hours : float, optional + Expected voyage duration in hours. If provided and the dataset does + not cover the full voyage, a warning is logged. + time_start, time_end : datetime or str or np.datetime64, optional + Inclusive time window to load from the underlying ERA5 dataset before + converting it to JAX arrays. Use this to cap memory to a rolling + subset of the full timeline. + order : int + Interpolation order (1 = linear, default). + u_var : str, optional + Name of the u-wind variable in the NetCDF file. + v_var : str, optional + Name of the v-wind variable in the NetCDF file. + + Returns + ------- + Callable + ``(lon, lat, t) -> (u10, v10)`` with ``.is_time_variant = True``. + """ + ds = _slice_dataset_time( + _load_datasets(path), + time_start=time_start, + time_end=time_end, + ) + + # Auto-detect variable names + if u_var is None: + for candidate in ["u10", "10m_u_component_of_wind", "U10"]: + if candidate in ds.data_vars: + u_var = candidate + break + if u_var is None: + ds.close() + raise KeyError( + f"Cannot find u-wind variable in {path}. " + f"Available: {list(ds.data_vars)}" + ) + if v_var is None: + for candidate in ["v10", "10m_v_component_of_wind", "V10"]: + if candidate in ds.data_vars: + v_var = candidate + break + if v_var is None: + ds.close() + raise KeyError( + f"Cannot find v-wind variable in {path}. " + f"Available: {list(ds.data_vars)}" + ) + + grid = _prepare_grid(ds, departure_time) + + # Warn if the dataset does not cover the full voyage + if voyage_hours is not None: + coverage_h = float(grid["times_hours"][-1] - grid["times_hours"][0]) + needed_h = voyage_hours + grid["departure_offset_h"] + if coverage_h < needed_h: + logger.warning( + "ERA5 wind data covers %.0f h but the voyage needs " + "%.0f h (%.0f h voyage + %.0f h departure offset). " + "Late-voyage interpolation will clamp to the last " + "available timestep.", + coverage_h, + needed_h, + voyage_hours, + grid["departure_offset_h"], + ) + ds = grid["ds"] # use the reindexed dataset from _prepare_grid + + # Extract data as JAX arrays: shape (T, lat, lon) + udata = ds[u_var] + vdata = ds[v_var] + + umat = jnp.array(udata.values, dtype=jnp.float32) + vmat = jnp.array(vdata.values, dtype=jnp.float32) + + ds.close() # release file handles; data is now in JAX arrays + + logger.info( + "Loaded ERA5 wind: shape=%s, lat=[%.1f, %.1f], lon=[%.1f, %.1f], " + "t=[%.0f, %.0f] h", + umat.shape, + grid["lat"][0], + grid["lat"][-1], + grid["lon"][0], + grid["lon"][-1], + grid["times_hours"][0], + grid["times_hours"][-1], + ) + + return _build_field_closure( + umat, + vmat, + grid["begin"], + grid["spacing"], + grid["departure_offset_h"], + order=order, + mode="nearest", + add_time_variant_attr=True, + ) + + +def load_era5_vectorfield( + path: str | Path | Sequence[str | Path], + departure_time: datetime | str | np.datetime64 | None = None, + voyage_hours: float | None = None, + time_start: datetime | str | np.datetime64 | None = None, + time_end: datetime | str | np.datetime64 | None = None, + order: int = 1, + u_var: str | None = None, + v_var: str | None = None, +) -> Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], + tuple[jnp.ndarray, jnp.ndarray], +]: + """Load ERA5 wind data and return a vectorfield-compatible closure. + + This is identical to :func:`load_era5_windfield` but named to align with + the existing ``vectorfield`` interface used by ``cost_function``. + + The 10-m wind components are returned as ``(u, v)`` in m/s. For SWOPP3, + these are passed to the RISE polar model rather than being used directly + as ocean currents, but the function signature is the same. + + See :func:`load_era5_windfield` for parameter docs. + """ + return load_era5_windfield( + path, + departure_time=departure_time, + voyage_hours=voyage_hours, + time_start=time_start, + time_end=time_end, + order=order, + u_var=u_var, + v_var=v_var, + ) + + +def load_era5_wavefield( + path: str | Path | Sequence[str | Path], + departure_time: datetime | str | np.datetime64 | None = None, + voyage_hours: float | None = None, + time_start: datetime | str | np.datetime64 | None = None, + time_end: datetime | str | np.datetime64 | None = None, + order: int = 1, + hs_var: str | None = None, + dir_var: str | None = None, +) -> Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], + tuple[jnp.ndarray, jnp.ndarray], +]: + """Load ERA5 wave data and return a wavefield closure. + + The returned function has the signature + ``(lon, lat, t) -> (hs, mwd)`` + where ``hs`` is significant wave height in metres and ``mwd`` is mean wave + direction in degrees from North. + + Parameters + ---------- + path : str, Path, or list thereof + Path(s) to ERA5 wave NetCDF file(s). When multiple paths are + given they are concatenated along the time axis. + departure_time : datetime or str or np.datetime64, optional + The voyage departure time. When provided, ``t = 0`` in the returned + closure corresponds to this datetime. + voyage_hours : float, optional + Expected voyage duration in hours. If provided and the dataset does + not cover the full voyage, a warning is logged. + time_start, time_end : datetime or str or np.datetime64, optional + Inclusive time window to load from the underlying ERA5 dataset before + converting it to JAX arrays. + order : int + Interpolation order (1 = linear, default). + hs_var : str, optional + Name of the significant wave height variable. + dir_var : str, optional + Name of the mean wave direction variable. + + Returns + ------- + Callable + ``(lon, lat, t) -> (hs, mwd)``. + """ + ds = _slice_dataset_time( + _load_datasets(path), + time_start=time_start, + time_end=time_end, + ) + + # Auto-detect variable names + if hs_var is None: + for candidate in [ + "swh", + "significant_height_of_combined_wind_waves_and_swell", + "Hs", + "hs", + ]: + if candidate in ds.data_vars: + hs_var = candidate + break + if hs_var is None: + ds.close() + raise KeyError( + f"Cannot find wave height variable in {path}. " + f"Available: {list(ds.data_vars)}" + ) + + if dir_var is None: + for candidate in ["mwd", "mean_wave_direction", "MWD"]: + if candidate in ds.data_vars: + dir_var = candidate + break + if dir_var is None: + ds.close() + raise KeyError( + f"Cannot find wave direction variable in {path}. " + f"Available: {list(ds.data_vars)}" + ) + + grid = _prepare_grid(ds, departure_time) + + # Warn if the dataset does not cover the full voyage + if voyage_hours is not None: + coverage_h = float(grid["times_hours"][-1] - grid["times_hours"][0]) + needed_h = voyage_hours + grid["departure_offset_h"] + if coverage_h < needed_h: + logger.warning( + "ERA5 wave data covers %.0f h but the voyage needs " + "%.0f h (%.0f h voyage + %.0f h departure offset). " + "Late-voyage interpolation will clamp to the last " + "available timestep.", + coverage_h, + needed_h, + voyage_hours, + grid["departure_offset_h"], + ) + + ds = grid["ds"] # use the reindexed dataset from _prepare_grid + + hsdata = ds[hs_var] + dirdata = ds[dir_var] + + hmat = jnp.array(hsdata.values, dtype=jnp.float32) + dmat = jnp.array(dirdata.values, dtype=jnp.float32) + + ds.close() # release file handles; data is now in JAX arrays + + # Replace NaN with 0 (land points in wave data) + hmat = jnp.nan_to_num(hmat, nan=0.0) + dmat = jnp.nan_to_num(dmat, nan=0.0) + + logger.info( + "Loaded ERA5 waves: shape=%s, lat=[%.1f, %.1f], lon=[%.1f, %.1f], " + "t=[%.0f, %.0f] h", + hmat.shape, + grid["lat"][0], + grid["lat"][-1], + grid["lon"][0], + grid["lon"][-1], + grid["times_hours"][0], + grid["times_hours"][-1], + ) + + return _build_field_closure( + hmat, + dmat, + grid["begin"], + grid["spacing"], + grid["departure_offset_h"], + order=order, + mode="nearest", + add_time_variant_attr=False, + ) + + +def load_era5_land_mask( + wave_path: str | Path, + hs_var: str | None = None, +) -> Land: + """Create a :class:`~routetools.land.Land` mask from ERA5 wave NaN values. + + ERA5 wave variables (SWH, MWD) are NaN over land. This function + reads the first timestep, marks every grid cell that is NaN as land, + and returns a ``Land`` object that can be passed to CMA-ES. + + Parameters + ---------- + wave_path : str or Path + Path to an ERA5 wave NetCDF file. + hs_var : str, optional + Name of the significant wave height variable. Auto-detected if + ``None``. + + Returns + ------- + Land + A ``Land`` object whose grid covers the ERA5 file extent. + """ + from routetools.land import Land + + ds = _load_dataset(wave_path) + + # Auto-detect wave-height variable + if hs_var is None: + for candidate in [ + "swh", + "significant_height_of_combined_wind_waves_and_swell", + "Hs", + "hs", + ]: + if candidate in ds.data_vars: + hs_var = candidate + break + if hs_var is None: + ds.close() + raise KeyError( + f"Cannot find wave height variable in {wave_path}. " + f"Available: {list(ds.data_vars)}" + ) + + # Get grid coordinates + lat_name = _get_coord_name(ds, ["latitude", "lat"]) + lon_name = _get_coord_name(ds, ["longitude", "lon"]) + + lats = ds[lat_name].values.astype(np.float64) + lons = ds[lon_name].values.astype(np.float64) + + # Ensure ascending + if lats[0] > lats[-1]: + lats = lats[::-1] + ds = ds.isel({lat_name: slice(None, None, -1)}) + if lons[0] > lons[-1]: + lons = lons[::-1] + ds = ds.isel({lon_name: slice(None, None, -1)}) + + # First timestep of wave height: NaN → land + hs_t0 = ds[hs_var].isel({_get_coord_name(ds, ["time", "valid_time"]): 0}).values + + ds.close() + + # hs_t0 shape is (lat, lon). Land class expects (x, y) = (lon, lat). + is_land_latlon = np.isnan(hs_t0) # (lat, lon) + is_land_lonlat = is_land_latlon.T # (lon, lat) + + # Land class: values > water_level → land. + # We use 1.0 for land, 0.0 for water, water_level = 0.5. + land_array = is_land_lonlat.astype(np.float32) + + n_lon, n_lat = land_array.shape + lon_min, lon_max = float(lons[0]), float(lons[-1]) + lat_min, lat_max = float(lats[0]), float(lats[-1]) + + # Land.__init__ computes lenx = ceil(xlim[1] - xlim[0]) * resolution[0] + # and expects land_array.shape == (lenx, leny). + # To match the ERA5 grid exactly we construct the Land object directly, + # bypassing the Perlin-noise generation path entirely. + land = Land.__new__(Land) + land._array = jnp.array(land_array) + land.x = jnp.linspace(lon_min, lon_max, n_lon) + land.y = jnp.linspace(lat_min, lat_max, n_lat) + land.xmin = lon_min + land.xmax = lon_max + land.xnorm = (n_lon - 1) / (lon_max - lon_min) if lon_max > lon_min else 1.0 + land.ymin = lat_min + land.ymax = lat_max + land.ynorm = (n_lat - 1) / (lat_max - lat_min) if lat_max > lat_min else 1.0 + land.resolution = (1, 1) + land.random_seed = None + land.water_level = 0.5 + land.shape = land_array.shape + land.interpolate = 10 # insert 10 sub-points between waypoints to catch narrow land + land.outbounds_is_land = True + land.penalize_segments = False + land._map_mode = "nearest" + land._map_order = 0 + + land_indices = jnp.argwhere(land._array > land.water_level) + if land_indices.size > 0: + land._lats = land.y[land_indices[:, 1]] + land._lons = land.x[land_indices[:, 0]] + else: + land._lats = jnp.array([]) + land._lons = jnp.array([]) + + # Precompute EDT for distance_penalty + from scipy.ndimage import distance_transform_edt + + binary_land = np.asarray(land._array > land.water_level) + land._edt = jnp.asarray(distance_transform_edt(~binary_land), dtype=jnp.float32) + + n_land = int(np.sum(is_land_lonlat)) + n_total = int(np.prod(is_land_lonlat.shape)) + logger.info( + "ERA5 land mask: %d/%d cells are land (%.1f%%), " + "lon=[%.1f, %.1f], lat=[%.1f, %.1f], shape=%s", + n_land, + n_total, + 100 * n_land / n_total, + lon_min, + lon_max, + lat_min, + lat_max, + land_array.shape, + ) + + return land + + +def load_natural_earth_land_mask( + lon_range: tuple[float, float], + lat_range: tuple[float, float], + resolution: float = 0.01, + ne_resolution: str = "10m", + interpolate: int = 50, +) -> Land: + """Create a high-resolution land mask from Natural Earth shapefiles. + + Uses cartopy's Natural Earth 1:10m land polygons rasterized onto a + regular grid. This is much more accurate than the ERA5 wave-NaN + approach for narrow land features (Cape Cod, Aleutian Islands, + Channel Islands, narrow straits, etc.). + + Parameters + ---------- + lon_range : tuple[float, float] + ``(lon_min, lon_max)`` — longitude extent of the corridor in + degrees East. Values > 180 are supported (unwrapped antimeridian). + lat_range : tuple[float, float] + ``(lat_min, lat_max)`` — latitude extent in degrees North. + resolution : float + Grid cell size in degrees. Default 0.01° ≈ 1.1 km at the equator. + ne_resolution : str + Natural Earth resolution: ``"10m"``, ``"50m"``, or ``"110m"``. + interpolate : int + Number of sub-points inserted between waypoints for segment + checking (passed to ``Land``). Default 50 gives ~1 km spacing + with L=100 waypoints, matching the 0.01° mask resolution. + + Returns + ------- + Land + A ``Land`` object compatible with CMA-ES. + """ + try: + import cartopy.io.shapereader as shpreader + from shapely.affinity import translate + from shapely.geometry import box + from shapely.validation import make_valid + except ImportError as exc: + raise ImportError( + "Natural Earth land mask requires cartopy, shapely, and rasterio. " + "Install them with:\n pip install cartopy shapely rasterio" + ) from exc + + try: + import rasterio # noqa: F401 — validate availability early + except ImportError as exc: + raise ImportError( + "Natural Earth land mask requires rasterio. " + "Install it with:\n pip install rasterio" + ) from exc + + from routetools.land import Land + + # Load Natural Earth land polygons + shp_path = shpreader.natural_earth( + resolution=ne_resolution, category="physical", name="land" + ) + reader = shpreader.Reader(shp_path) + land_geoms = list(reader.geometries()) + + lon_min, lon_max = float(lon_range[0]), float(lon_range[1]) + lat_min, lat_max = float(lat_range[0]), float(lat_range[1]) + + # Build a bounding box with a small buffer for clipping + buf = 1.0 # degree buffer + clip_box = box(lon_min - buf, lat_min - buf, lon_max + buf, lat_max + buf) + + # If the corridor uses longitudes > 180 (unwrapped antimeridian), + # we need shifted copies of the NE geometries (which are in [-180, 180]). + need_shift = lon_max > 180 or lon_min > 180 + + # Clip geometries to the region of interest for efficiency + clipped = [] + for geom in land_geoms: + if geom is None or geom.is_empty: + continue + + # Original geometry (NE coordinates in [-180, 180]) + try: + g = make_valid(geom) if not geom.is_valid else geom + intersection = g.intersection(clip_box) + if not intersection.is_empty: + clipped.append(intersection) + except Exception as exc: + logger.debug("NE geom intersection failed: %s", exc) + + # For antimeridian-crossing corridors, also shift by +360° + # so that NE lon -180..-120 becomes 180..240, etc. + if need_shift: + try: + shifted = translate(geom, xoff=360) + shifted = make_valid(shifted) if not shifted.is_valid else shifted + intersection = shifted.intersection(clip_box) + if not intersection.is_empty: + clipped.append(intersection) + except Exception as exc: + logger.debug("NE shifted geom intersection failed: %s", exc) + + if not clipped: + clipped = [] + + # Build the raster grid dimensions + n_lon = int(ceil((lon_max - lon_min) / resolution)) + 1 + n_lat = int(ceil((lat_max - lat_min) / resolution)) + 1 + + # Rasterize using rasterio scan-line fill (O(pixels), very fast) + from rasterio.features import rasterize + from rasterio.transform import from_bounds + + # rasterio transform: maps pixel (col, row) → (lon, lat). + # rows = lat axis (n_lat), cols = lon axis (n_lon). + # The Land object expects array shape (n_lon, n_lat) with + # axis-0 = lon, axis-1 = lat, so we rasterize with + # width=n_lon, height=n_lat, then transpose. + transform = from_bounds(lon_min, lat_min, lon_max, lat_max, n_lon, n_lat) + shapes = [(geom, 1) for geom in clipped if geom is not None and not geom.is_empty] + + if shapes: + # rasterize returns (height=n_lat, width=n_lon) uint8 array + # Row 0 = lat_max (north) in rasterio convention. + raster = rasterize( + shapes, + out_shape=(n_lat, n_lon), + transform=transform, + fill=0, + dtype=np.uint8, + all_touched=True, # mark cells touched by polygon boundary + ) + # Flip latitude so row 0 = lat_min (south), matching + # Land.y = linspace(lat_min, lat_max, n_lat). + raster = raster[::-1, :] + # Transpose to (n_lon, n_lat) to match Land's (x=lon, y=lat) convention + land_array = raster.T.astype(np.float32) + else: + land_array = np.zeros((n_lon, n_lat), dtype=np.float32) + + # Construct the Land object (same bypass pattern as load_era5_land_mask) + land = Land.__new__(Land) + land._array = jnp.array(land_array) + land.x = jnp.linspace(lon_min, lon_max, n_lon) + land.y = jnp.linspace(lat_min, lat_max, n_lat) + land.xmin = lon_min + land.xmax = lon_max + land.xnorm = (n_lon - 1) / (lon_max - lon_min) if lon_max > lon_min else 1.0 + land.ymin = lat_min + land.ymax = lat_max + land.ynorm = (n_lat - 1) / (lat_max - lat_min) if lat_max > lat_min else 1.0 + land.resolution = (1, 1) + land.random_seed = None + land.water_level = 0.5 + land.shape = land_array.shape + land.interpolate = interpolate + land.outbounds_is_land = True + land.penalize_segments = False + land._map_mode = "nearest" + land._map_order = 0 + + land_indices = jnp.argwhere(land._array > land.water_level) + if land_indices.size > 0: + land._lats = land.y[land_indices[:, 1]] + land._lons = land.x[land_indices[:, 0]] + else: + land._lats = jnp.array([]) + land._lons = jnp.array([]) + + # Precompute EDT for distance_penalty + from scipy.ndimage import distance_transform_edt + + binary_land = np.asarray(land._array > land.water_level) + land._edt = jnp.asarray(distance_transform_edt(~binary_land), dtype=jnp.float32) + + n_land = int(np.sum(land_array > 0.5)) + n_total = n_lon * n_lat + logger.info( + "Natural Earth land mask (%s): %d/%d cells are land (%.1f%%), " + "lon=[%.1f, %.1f], lat=[%.1f, %.1f], res=%.3f°, shape=%s", + ne_resolution, + n_land, + n_total, + 100 * n_land / n_total, + lon_min, + lon_max, + lat_min, + lat_max, + resolution, + land_array.shape, + ) + + return land diff --git a/routetools/fms.py b/routetools/fms.py index 043c6e6c..b4b29913 100644 --- a/routetools/fms.py +++ b/routetools/fms.py @@ -1,6 +1,31 @@ +"""Finite-difference route refinement utilities. + +This module implements the FMS post-processing stage used after CMA-ES. Given +an initial route, `optimize_fms` repeatedly solves local Euler-Lagrange style +updates for the interior waypoints, applies land and optional hard weather +constraints, and tracks the best route found across iterations. It supports +both the built-in physics cost and custom objective callables, including the +SWOPP3 RISE energy objective forwarded through `costfun` and `costfun_kwargs`. + +The file also contains the custom-cost caching helpers introduced for the +SWOPP3 speedup work. Those helpers memoize the travel-time evaluator and solver +stack when the custom objective and forwarded kwargs are hashable, avoiding a +fresh JAX build for every departure while preserving the same numerical update +rule. + +New in this workflow is the optional `snapshot_callback` hook on +`optimize_fms`. The callback receives one dictionary per iteration with the +current routes, current costs, best-so-far routes, best-so-far costs, and the +early-stop counters. This makes the refinement loop observable for animation +and debugging, while keeping the normal API and solver behavior unchanged when +the hook is not used. +""" + +import inspect import time from collections.abc import Callable -from typing import Any +from functools import lru_cache +from typing import Any, Protocol import jax import jax.numpy as jnp @@ -11,6 +36,439 @@ from routetools.cost import cost_function from routetools.land import Land from routetools.vectorfield import vectorfield_fourvortices +from routetools.weather import ( + DEFAULT_HS_LIMIT, + DEFAULT_TWS_LIMIT, +) +from routetools.weather import ( + weather_penalty as _weather_penalty, +) + + +class FmsCustomCostPositional(Protocol): + """Custom FMS cost that takes the curve as a positional argument.""" + + def __call__(self, curve: jnp.ndarray, /, **kwargs: Any) -> jnp.ndarray: + """Evaluate a batch of routes.""" + + +class FmsCustomCostKeyword(Protocol): + """Custom FMS cost that takes the curve as a keyword argument.""" + + def __call__(self, *, curve: jnp.ndarray, **kwargs: Any) -> jnp.ndarray: + """Evaluate a batch of routes.""" + + +type FmsCustomCostFunction = FmsCustomCostPositional | FmsCustomCostKeyword +type FmsCostFunction = Callable[..., jnp.ndarray] | FmsCustomCostFunction +type FmsSnapshot = dict[str, Any] +type FmsSnapshotCallback = Callable[[FmsSnapshot], None] + + +def _sorted_costfun_kwargs_items( + costfun_kwargs: dict[str, Any], +) -> tuple[tuple[str, Any], ...] | None: + """Return a hashable representation of custom cost kwargs when possible.""" + try: + return tuple(sorted(costfun_kwargs.items())) + except TypeError: + return None + + +def _make_custom_evaluate_cost( + user_costfun: FmsCostFunction, + resolved_costfun_kwargs: dict[str, Any], + custom_cost_accepts_kwargs: bool, + custom_cost_accepts_curve_keyword: bool, + custom_cost_parameter_names: set[str], + weight_l1: float, + weight_l2: float, + spherical_correction: bool, +) -> Callable[..., jnp.ndarray]: + """Build a custom cost wrapper for FMS (not cached). + + Constructs the internal ``_evaluate_cost`` closure that normalises the + call to ``user_costfun``: it merges ``costfun_kwargs``, travel context, + and metric weights into a single ``cost_kwargs`` dict, then filters it to + the parameters the function actually accepts before dispatching positionally + or by keyword based on the inspected signature. + """ + # Take a snapshot so later mutations to the caller's dict do not affect the + # closure. + captured_kwargs = dict(resolved_costfun_kwargs) + accepted_names = set(custom_cost_parameter_names) + + def _evaluate_cost( + curve_eval: jnp.ndarray, + *, + travel_stw_eval: float | None = None, + travel_time_eval: float | None = None, + time_offset_eval: float = 0.0, + ) -> jnp.ndarray: + cost_kwargs = { + **captured_kwargs, + "travel_stw": travel_stw_eval, + "travel_time": travel_time_eval, + "weight_l1": weight_l1, + "weight_l2": weight_l2, + "spherical_correction": spherical_correction, + "time_offset": time_offset_eval, + } + if not custom_cost_accepts_kwargs: + cost_kwargs = { + name: value + for name, value in cost_kwargs.items() + if name in accepted_names + } + if custom_cost_accepts_curve_keyword: + return user_costfun(curve=curve_eval, **cost_kwargs) + return user_costfun(curve_eval, **cost_kwargs) + + return _evaluate_cost + + +@lru_cache(maxsize=32) +def _build_custom_evaluate_cost( + user_costfun: FmsCostFunction, + costfun_kwargs_items: tuple[tuple[str, Any], ...], + custom_cost_accepts_kwargs: bool, + custom_cost_accepts_curve_keyword: bool, + custom_cost_parameter_names: tuple[str, ...], + weight_l1: float, + weight_l2: float, + spherical_correction: bool, +) -> Callable[..., jnp.ndarray]: + """Build and cache a custom cost wrapper for FMS. + + Accepts only hashable arguments so the result can be memoised via + lru_cache. Delegates to _make_custom_evaluate_cost for the actual closure + construction. + """ + return _make_custom_evaluate_cost( + user_costfun, + dict(costfun_kwargs_items), + custom_cost_accepts_kwargs, + custom_cost_accepts_curve_keyword, + set(custom_cost_parameter_names), + weight_l1, + weight_l2, + spherical_correction, + ) + + +@lru_cache(maxsize=32) +def _build_travel_time_custom_solver( + evaluate_cost: Callable[..., jnp.ndarray], + damping: float, + h: float, +) -> Callable[[jnp.ndarray, jnp.ndarray, jnp.ndarray], jnp.ndarray]: + """Build a reusable travel-time FMS solver for cacheable custom costs.""" + + def lagrangian( + q0: jnp.ndarray, + q1: jnp.ndarray, + segment_time_offset: float, + ) -> jnp.ndarray: + q = jnp.vstack([q0, q1])[None, ...] + lag = evaluate_cost( + q, + travel_time_eval=h, + time_offset_eval=segment_time_offset, + ) + return jnp.sum(h * lag) + + d1ld = grad(lagrangian, argnums=0) + d2ld = grad(lagrangian, argnums=1) + d11ld = hessian(lagrangian, argnums=0) + d22ld = hessian(lagrangian, argnums=1) + + @jit # type: ignore[misc] + def jacobian( + qkm1: jnp.ndarray, + qk: jnp.ndarray, + qkp1: jnp.ndarray, + left_time_offset: float, + right_time_offset: float, + ) -> jnp.ndarray: + b = -d2ld(qkm1, qk, left_time_offset) - d1ld( + qk, + qkp1, + right_time_offset, + ) + a = d22ld(qkm1, qk, left_time_offset) + d11ld( + qk, + qkp1, + right_time_offset, + ) + q: jnp.ndarray = jnp.linalg.solve(a, b) + return jnp.nan_to_num(q) + + # Each tree-index is an integer; no need to wrap a single axis in a tuple. + jac_vectorized = vmap(jacobian, in_axes=(0, 0, 0, 0, 0), out_axes=0) + + @jit # type: ignore[misc] + def solve_equation( + curve: jnp.ndarray, + segment_time_offsets: jnp.ndarray, + q_max: jnp.ndarray, + ) -> jnp.ndarray: + # .at[].set() always returns a new array; no copy needed beforehand. + q = jac_vectorized( + curve[:-2], + curve[1:-1], + curve[2:], + segment_time_offsets[:-1], + segment_time_offsets[1:], + ) + dq = (1 - damping) * q + dq = jnp.clip(dq, -q_max, q_max) + return curve.at[1:-1].set(dq + curve[1:-1]) + + return jit(vmap(solve_equation, in_axes=(0, None, None), out_axes=0)) + + +@lru_cache(maxsize=32) +def _build_travel_stw_custom_solver( + evaluate_cost: Callable[..., jnp.ndarray], + damping: float, + h: float, + travel_stw: float, +) -> Callable[[jnp.ndarray, jnp.ndarray, jnp.ndarray], jnp.ndarray]: + """Build and cache a speed-through-water FMS solver for custom costs. + + Mirrors _build_travel_time_custom_solver for the STW mode. The Lagrangian + squares the cost (L2 style) and passes travel_stw instead of travel_time + to the cost evaluator. Caching avoids a fresh JAX trace for every + departure that shares the same evaluator, damping, step size, and speed. + """ + + def lagrangian( + q0: jnp.ndarray, + q1: jnp.ndarray, + segment_time_offset: float, + ) -> jnp.ndarray: + q = jnp.vstack([q0, q1])[None, ...] + lag = evaluate_cost( + q, + travel_stw_eval=travel_stw, + time_offset_eval=segment_time_offset, + ) + return jnp.sum(h * lag**2) + + d1ld = grad(lagrangian, argnums=0) + d2ld = grad(lagrangian, argnums=1) + d11ld = hessian(lagrangian, argnums=0) + d22ld = hessian(lagrangian, argnums=1) + + @jit # type: ignore[misc] + def jacobian( + qkm1: jnp.ndarray, + qk: jnp.ndarray, + qkp1: jnp.ndarray, + left_time_offset: float, + right_time_offset: float, + ) -> jnp.ndarray: + b = -d2ld(qkm1, qk, left_time_offset) - d1ld( + qk, + qkp1, + right_time_offset, + ) + a = d22ld(qkm1, qk, left_time_offset) + d11ld( + qk, + qkp1, + right_time_offset, + ) + q: jnp.ndarray = jnp.linalg.solve(a, b) + return jnp.nan_to_num(q) + + # Each tree-index is an integer; no need to wrap a single axis in a tuple. + jac_vectorized = vmap(jacobian, in_axes=(0, 0, 0, 0, 0), out_axes=0) + + @jit # type: ignore[misc] + def solve_equation( + curve: jnp.ndarray, + segment_time_offsets: jnp.ndarray, + q_max: jnp.ndarray, + ) -> jnp.ndarray: + # .at[].set() always returns a new array; no copy needed beforehand. + q = jac_vectorized( + curve[:-2], + curve[1:-1], + curve[2:], + segment_time_offsets[:-1], + segment_time_offsets[1:], + ) + dq = (1 - damping) * q + dq = jnp.clip(dq, -q_max, q_max) + return curve.at[1:-1].set(dq + curve[1:-1]) + + return jit(vmap(solve_equation, in_axes=(0, None, None), out_axes=0)) + + +def _weather_violation_mask( + curve: jnp.ndarray, + *, + windfield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ] + | None = None, + wavefield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ] + | None = None, + enforce_weather_limits: bool = False, + tws_limit: float = DEFAULT_TWS_LIMIT, + hs_limit: float = DEFAULT_HS_LIMIT, + travel_stw: float | None = None, + travel_time: float | None = None, + spherical_correction: bool = False, + time_offset: float = 0.0, +) -> jnp.ndarray: + """Return a per-route mask for weather-limit violations.""" + if not enforce_weather_limits or (windfield is None and wavefield is None): + return jnp.zeros(curve.shape[0], dtype=bool) + + return ( + _weather_penalty( + curve, + windfield=windfield, + wavefield=wavefield, + tws_limit=tws_limit, + hs_limit=hs_limit, + penalty=1.0, + travel_stw=travel_stw, + travel_time=travel_time, + spherical_correction=spherical_correction, + time_offset=time_offset, + ) + > 0 + ) + + +def _neighbor_mask(mask: jnp.ndarray) -> jnp.ndarray: + """Expand a per-route boolean mask to immediate neighboring positions.""" + left = jnp.pad(mask[:, :-1], ((0, 0), (1, 0)), constant_values=False) + right = jnp.pad(mask[:, 1:], ((0, 0), (0, 1)), constant_values=False) + return mask | left | right + + +def _rollback_land_increases( + curve: jnp.ndarray, + curve_old: jnp.ndarray, + *, + land: Land, +) -> jnp.ndarray: + """Rollback only local changes that increase land-invalid route positions. + + The rollback is intentionally local: it reverts changed waypoints that are + directly implicated in newly-invalid positions while preserving unrelated + updates elsewhere on the route. If local rollback cannot restore a route to + a non-increasing land count, the full route falls back to its previous + iterate as a final safety check. + """ + curve_constrained = curve + land_old = land(curve_old) > 0 + old_counts = jnp.sum(land_old, axis=1) + + for _ in range(curve.shape[1]): + land_new = land(curve_constrained) > 0 + increased = jnp.sum(land_new, axis=1) > old_counts + if not bool(jnp.any(increased)): + return curve_constrained + + changed = jnp.any(curve_constrained != curve_old, axis=-1) + newly_invalid = (~land_old) & land_new & increased[:, None] + revert_mask = changed & _neighbor_mask(newly_invalid) & land_new + + needs_fallback_mask = increased & (~jnp.any(revert_mask, axis=1)) + revert_mask = revert_mask | (needs_fallback_mask[:, None] & changed & land_new) + + if not bool(jnp.any(revert_mask)): + break + + curve_constrained = jnp.where( + revert_mask[..., None], + curve_old, + curve_constrained, + ) + + land_new = land(curve_constrained) > 0 + increased = jnp.sum(land_new, axis=1) > old_counts + return jnp.where(increased[:, None, None], curve_old, curve_constrained) + + +def _apply_curve_constraints( + curve: jnp.ndarray, + curve_old: jnp.ndarray, + *, + land: Land | None = None, + windfield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ] + | None = None, + wavefield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ] + | None = None, + enforce_weather_limits: bool = False, + tws_limit: float = DEFAULT_TWS_LIMIT, + hs_limit: float = DEFAULT_HS_LIMIT, + travel_stw: float | None = None, + travel_time: float | None = None, + spherical_correction: bool = False, + time_offset: float = 0.0, + penalty: float = 0.0, +) -> jnp.ndarray: + """Reject updates that newly become invalid and keep previous valid states. + + Land updates are reverted point-wise only when a previously valid waypoint + becomes invalid. Waypoints that were already invalid are allowed to keep + moving, so FMS can escape an initially invalid route. Weather updates are + reverted per route only when a previously weather-feasible route becomes + weather-infeasible. Routes that were already weather-invalid are allowed to + keep moving, so FMS can escape an initially violating route. + """ + curve_constrained = curve + + if land is not None and penalty > 0: + curve_constrained = _rollback_land_increases( + curve_constrained, + curve_old, + land=land, + ) + + # Skip both mask evaluations when weather enforcement is inactive or no + # field is provided; _weather_violation_mask already returns zeros in that + # case, but calling it twice per iteration is wasteful. + if not enforce_weather_limits or (windfield is None and wavefield is None): + return curve_constrained + + weather_invalid_old = _weather_violation_mask( + curve_old, + windfield=windfield, + wavefield=wavefield, + enforce_weather_limits=enforce_weather_limits, + tws_limit=tws_limit, + hs_limit=hs_limit, + travel_stw=travel_stw, + travel_time=travel_time, + spherical_correction=spherical_correction, + time_offset=time_offset, + ) + weather_invalid_new = _weather_violation_mask( + curve_constrained, + windfield=windfield, + wavefield=wavefield, + enforce_weather_limits=enforce_weather_limits, + tws_limit=tws_limit, + hs_limit=hs_limit, + travel_stw=travel_stw, + travel_time=travel_time, + spherical_correction=spherical_correction, + time_offset=time_offset, + ) + newly_weather_invalid = (~weather_invalid_old) & weather_invalid_new + return jnp.where(newly_weather_invalid[:, None, None], curve_old, curve_constrained) def random_piecewise_curve( @@ -31,13 +489,15 @@ def random_piecewise_curve( Ending point of the curves. num_curves : int Number of curves to generate. - key : jax.random.PRNGKey - Random key for generating random numbers. + num_points : int + Total number of waypoints per curve. + seed : int + Integer seed for reproducible random curve generation. Returns ------- jnp.ndarray - Generated curves with shape (num_curves, num_segments, 2). + Generated curves with shape (num_curves, num_points, 2). """ key = jax.random.PRNGKey(seed) num_segments = jax.random.randint(key, (num_curves,), minval=2, maxval=5) @@ -118,11 +578,15 @@ def optimize_fms( dst: jnp.ndarray | None = None, curve: jnp.ndarray | None = None, land: Land | None = None, + windfield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] + ] + | None = None, wavefield: Callable[ [jnp.ndarray, jnp.ndarray, jnp.ndarray], tuple[jnp.ndarray, jnp.ndarray] ] | None = None, - penalty: float = 1e8, + penalty: float = 1e10, num_curves: int = 10, num_points: int = 200, travel_stw: float | None = None, @@ -133,9 +597,15 @@ def optimize_fms( weight_l1: float = 1.0, weight_l2: float = 0.0, spherical_correction: bool = False, - costfun: Callable | None = None, + costfun: FmsCostFunction | None = None, + costfun_kwargs: dict[str, Any] | None = None, seed: int = 0, verbose: bool = True, + snapshot_callback: FmsSnapshotCallback | None = None, + time_offset: float = 0.0, + enforce_weather_limits: bool = False, + tws_limit: float = DEFAULT_TWS_LIMIT, + hs_limit: float = DEFAULT_HS_LIMIT, ) -> tuple[jnp.ndarray, dict[str, Any]]: """ Optimize a curve using the FMS algorithm. @@ -153,8 +623,11 @@ def optimize_fms( Destination point, by default None curve : jnp.ndarray | None, optional Curve to optimize, shape L x 2, by default None - land_function : Callable[[jnp.ndarray], jnp.ndarray] | None, optional - Land function, by default None + land : Land | None, optional + Land mask used to reject updates that move waypoints onto land. + By default None. + windfield : Callable, optional + Wind field closure used to enforce route weather limits. num_curves : int, optional Number of curves to optimize, only used when initial curves are not provided, by default 10 @@ -177,12 +650,45 @@ def optimize_fms( Weight for the L2 norm in the combined cost. Default is 0.0. spherical_correction : bool, optional Whether to apply spherical correction, by default False - costfun : Callable | None, optional - Custom cost function, by default None + costfun : FmsCostFunction | None, optional + Cost function used by FMS for both motion and best-route tracking. + + Two modes are supported. + + 1. ``None`` or ``routetools.cost.cost_function``: + FMS uses the built-in default cost and injects ``vectorfield``, + ``wavefield``, travel parameters, weights, and spherical correction. + + 2. Custom closure, like CMA-ES ``cost_fn``: + the closure may capture any required vector, wind, or wave fields + internally, or they may be supplied via ``costfun_kwargs``. + FMS forwards the route plus generic travel context, + namely ``travel_stw``, ``travel_time``, ``weight_l1``, + ``weight_l2``, ``spherical_correction``, and ``time_offset``. + The route may be accepted either positionally + (``costfun(curve, ...)``) or by keyword (``costfun(curve=..., ...)``). + costfun_kwargs : dict[str, Any] | None, optional + Extra keyword arguments forwarded to a custom ``costfun`` on every + evaluation. Ignored when using the built-in default cost. seed : int, optional Random seed for reproducibility, by default 0 verbose : bool, optional Print optimization progress, by default True + snapshot_callback : callable, optional + Called after every FMS iteration with the current routes, their costs, + and the best-so-far routes/costs. Default is ``None``. + time_offset : float, optional + Offset added to segment timestamps before querying time-variant + fields, by default 0.0 + enforce_weather_limits : bool, optional + Reject only FMS updates that newly violate the configured weather + limits. Already-violating routes may keep moving so FMS can escape an + initially infeasible route. By default False. + tws_limit : float, optional + Maximum allowed true wind speed in m/s when weather limits are enforced. + hs_limit : float, optional + Maximum allowed significant wave height in m when weather limits are + enforced. Returns ------- @@ -205,104 +711,279 @@ def optimize_fms( raise ValueError("Input curve must be 2D (L x 2) or 3D (B x L x 2)") assert curve.shape[-1] == 2, "Last dimension must be 2 (X, Y)" - # If land is provided, ensure that no points are on land at initialization - if land is not None and penalty > 0: - is_land = land(curve) > 0 - if is_land.any(): - # List the indices in land - indices_in_land = jnp.argwhere(is_land).tolist() - raise ValueError( - "[ERROR] Initial curve has points on land at indices: " - f"{indices_in_land} " - "Please provide a valid curve for FMS." - ) + # Normalize costfun to the internal call signature used by _evaluate_cost. + user_costfun = cost_function if costfun is None else costfun + use_builtin_costfun = user_costfun is cost_function + resolved_costfun_kwargs = {} if costfun_kwargs is None else dict(costfun_kwargs) + costfun_kwargs_items: tuple[tuple[str, Any], ...] | None = None - # Define cost function - if costfun is None: - costfun = cost_function + if use_builtin_costfun and resolved_costfun_kwargs: + raise ValueError("costfun_kwargs requires a custom costfun") - # Initialize lagrangians - if travel_stw is not None: - # Average distance between points - d = jnp.mean(jnp.linalg.norm(curve[:, 1:] - curve[:, :-1], axis=-1)) - h = float(d / travel_stw) + custom_cost_signature: inspect.Signature | None = None + custom_cost_accepts_kwargs = False + custom_cost_accepts_curve_keyword = False + custom_cost_parameter_names: set[str] = set() + if not use_builtin_costfun: + try: + custom_cost_signature = inspect.signature(user_costfun) + except (TypeError, ValueError): + custom_cost_signature = None + if custom_cost_signature is not None: + custom_cost_parameter_names = set(custom_cost_signature.parameters) + custom_cost_accepts_kwargs = any( + parameter.kind == inspect.Parameter.VAR_KEYWORD + for parameter in custom_cost_signature.parameters.values() + ) + custom_cost_accepts_curve_keyword = ( + "curve" in custom_cost_parameter_names or custom_cost_accepts_kwargs + ) + + if use_builtin_costfun: - def lagrangian(q0: jnp.ndarray, q1: jnp.ndarray) -> jnp.ndarray: - # Stack q0 and q1 to form array of shape (1, 2, 2) - q = jnp.vstack([q0, q1])[None, ...] - lag = costfun( + def _evaluate_cost( + curve_eval: jnp.ndarray, + *, + travel_stw_eval: float | None = None, + travel_time_eval: float | None = None, + time_offset_eval: float = 0.0, + ) -> jnp.ndarray: + return user_costfun( vectorfield=vectorfield, - curve=q, + curve=curve_eval, wavefield=wavefield, - travel_stw=travel_stw, + travel_stw=travel_stw_eval, + travel_time=travel_time_eval, weight_l1=weight_l1, weight_l2=weight_l2, spherical_correction=spherical_correction, + time_offset=time_offset_eval, + **resolved_costfun_kwargs, + ) + else: + costfun_kwargs_items = _sorted_costfun_kwargs_items(resolved_costfun_kwargs) + custom_cost_parameter_names_tuple = tuple(sorted(custom_cost_parameter_names)) + + if costfun_kwargs_items is not None: + _evaluate_cost = _build_custom_evaluate_cost( + user_costfun, + costfun_kwargs_items, + custom_cost_accepts_kwargs, + custom_cost_accepts_curve_keyword, + custom_cost_parameter_names_tuple, + weight_l1, + weight_l2, + spherical_correction, + ) + else: + # costfun_kwargs contain unhashable values so caching is not + # possible. Build the wrapper directly without caching. + _evaluate_cost = _make_custom_evaluate_cost( + user_costfun, + resolved_costfun_kwargs, + custom_cost_accepts_kwargs, + custom_cost_accepts_curve_keyword, + custom_cost_parameter_names, + weight_l1, + weight_l2, + spherical_correction, + ) + + # Initialize lagrangians + if travel_stw is not None: + # Average distance between points, used as the per-segment time step. + d = jnp.mean(jnp.linalg.norm(curve[:, 1:] - curve[:, :-1], axis=-1)) + h = float(d / travel_stw) + # NOTE: segment_time_offsets is derived from the initial route geometry + # and remains fixed throughout the optimization. As the route evolves, + # the actual per-segment travel times change, so the time offsets become + # approximate. This is acceptable for weakly time-variant fields but + # may introduce drift for strongly time-variant fields over many fevals. + segment_time_offsets = jnp.asarray( + time_offset + jnp.arange(curve.shape[1] - 1) * h, + dtype=jnp.float32, + ) + + q_max = jnp.mean(jnp.linalg.norm(curve[:, 1:] - curve[:, :-1], axis=-1)) + + if not use_builtin_costfun and costfun_kwargs_items is not None: + # All arguments are hashable; reuse a previously compiled solver. + cached_solve_vectorized = _build_travel_stw_custom_solver( + _evaluate_cost, + damping, + h, + travel_stw, ) - ld = jnp.sum(h * lag**2) - # Do note: The original formula used q0, q1 to compute l1, l2 and then - # took the average of (l1**2 + l2**2) / 2 - # We simplified that without loss of generality - return ld + + def solve_vectorized(curve_eval: jnp.ndarray) -> jnp.ndarray: + return cached_solve_vectorized( + curve_eval, + segment_time_offsets, + q_max, + ) + + else: + + def lagrangian( + q0: jnp.ndarray, + q1: jnp.ndarray, + segment_time_offset: float, + ) -> jnp.ndarray: + # Stack q0 and q1 to form array of shape (1, 2, 2) + q = jnp.vstack([q0, q1])[None, ...] + lag = _evaluate_cost( + q, + travel_stw_eval=travel_stw, + time_offset_eval=segment_time_offset, + ) + ld = jnp.sum(h * lag**2) + # Do note: The original formula used q0, q1 to compute l1, l2 and then + # took the average of (l1**2 + l2**2) / 2 + # We simplified that without loss of generality + return ld + + d1ld = grad(lagrangian, argnums=0) + d2ld = grad(lagrangian, argnums=1) + d11ld = hessian(lagrangian, argnums=0) + d22ld = hessian(lagrangian, argnums=1) + + @jit # type: ignore[misc] + def jacobian( + qkm1: jnp.ndarray, + qk: jnp.ndarray, + qkp1: jnp.ndarray, + left_time_offset: float, + right_time_offset: float, + ) -> jnp.ndarray: + b = -d2ld(qkm1, qk, left_time_offset) - d1ld( + qk, + qkp1, + right_time_offset, + ) + a = d22ld(qkm1, qk, left_time_offset) + d11ld( + qk, + qkp1, + right_time_offset, + ) + q: jnp.ndarray = jnp.linalg.solve(a, b) + return jnp.nan_to_num(q) + + # Each tree-index is an integer; no need to wrap a single axis in a tuple. + jac_vectorized = vmap(jacobian, in_axes=(0, 0, 0, 0, 0), out_axes=0) + + @jit # type: ignore[misc] + def solve_equation(curve: jnp.ndarray) -> jnp.ndarray: + q = jac_vectorized( + curve[:-2], + curve[1:-1], + curve[2:], + segment_time_offsets[:-1], + segment_time_offsets[1:], + ) + dq = (1 - damping) * q + # Clip updates to prevent divergence when the route is still + # far from a locally optimal path. + dq = jnp.clip(dq, -q_max, q_max) + return curve.at[1:-1].set(dq + curve[1:-1]) + + solve_vectorized = vmap(solve_equation, in_axes=0, out_axes=0) elif travel_time is not None: assert travel_time > 0, "Travel time must be positive" - h = float(travel_time / curve.shape[1]) + h = float(travel_time / (curve.shape[1] - 1)) + segment_time_offsets = jnp.asarray( + time_offset + jnp.arange(curve.shape[1] - 1) * h, + dtype=jnp.float32, + ) - def lagrangian(q0: jnp.ndarray, q1: jnp.ndarray) -> jnp.ndarray: - # Stack q0 and q1 to form array of shape (1, 2, 2) - q = jnp.vstack([q0, q1])[None, ...] - lag = costfun( - vectorfield=vectorfield, - curve=q, - wavefield=wavefield, - travel_time=h, - weight_l1=weight_l1, - weight_l2=weight_l2, - spherical_correction=spherical_correction, + q_max = jnp.mean(jnp.linalg.norm(curve[:, 1:] - curve[:, :-1], axis=-1)) + + if not use_builtin_costfun and costfun_kwargs_items is not None: + cached_solve_vectorized = _build_travel_time_custom_solver( + _evaluate_cost, + damping, + h, ) - ld = jnp.sum(h * lag) - # Do note: The original formula used q0, q1 to compute l1, l2 and then - # took the average of (l1 + l2) / 2 - # We simplified that without loss of generality - return ld - else: - raise ValueError("Either travel_stw or travel_time must be provided") + def solve_vectorized(curve_eval: jnp.ndarray) -> jnp.ndarray: + return cached_solve_vectorized( + curve_eval, + segment_time_offsets, + q_max, + ) + else: - d1ld = grad(lagrangian, argnums=0) - d2ld = grad(lagrangian, argnums=1) - d11ld = hessian(lagrangian, argnums=0) - d22ld = hessian(lagrangian, argnums=1) + def lagrangian( + q0: jnp.ndarray, + q1: jnp.ndarray, + segment_time_offset: float, + ) -> jnp.ndarray: + # Stack q0 and q1 to form array of shape (1, 2, 2) + q = jnp.vstack([q0, q1])[None, ...] + lag = _evaluate_cost( + q, + travel_time_eval=h, + time_offset_eval=segment_time_offset, + ) + ld = jnp.sum(h * lag) + # Do note: The original formula used q0, q1 to compute l1, l2 and then + # took the average of (l1 + l2) / 2 + # We simplified that without loss of generality + return ld - @jit # type: ignore[misc] - def jacobian(qkm1: jnp.ndarray, qk: jnp.ndarray, qkp1: jnp.ndarray) -> jnp.ndarray: - b = -d2ld(qkm1, qk) - d1ld(qk, qkp1) - a = d22ld(qkm1, qk) + d11ld(qk, qkp1) - q: jnp.ndarray = jnp.linalg.solve(a, b) - return jnp.nan_to_num(q) + d1ld = grad(lagrangian, argnums=0) + d2ld = grad(lagrangian, argnums=1) + d11ld = hessian(lagrangian, argnums=0) + d22ld = hessian(lagrangian, argnums=1) - jac_vectorized = vmap(jacobian, in_axes=(0, 0, 0), out_axes=(0)) + @jit # type: ignore[misc] + def jacobian( + qkm1: jnp.ndarray, + qk: jnp.ndarray, + qkp1: jnp.ndarray, + left_time_offset: float, + right_time_offset: float, + ) -> jnp.ndarray: + b = -d2ld(qkm1, qk, left_time_offset) - d1ld( + qk, + qkp1, + right_time_offset, + ) + a = d22ld(qkm1, qk, left_time_offset) + d11ld( + qk, + qkp1, + right_time_offset, + ) + q: jnp.ndarray = jnp.linalg.solve(a, b) + return jnp.nan_to_num(q) - @jit # type: ignore[misc] - def solve_equation(curve: jnp.ndarray) -> jnp.ndarray: - curve_new = jnp.copy(curve) - q = jac_vectorized(curve[:-2], curve[1:-1], curve[2:]) - return curve_new.at[1:-1].set((1 - damping) * q + curve[1:-1]) + jac_vectorized = vmap(jacobian, in_axes=(0, 0, 0, 0, 0), out_axes=0) - solve_vectorized: Callable[[jnp.ndarray], jnp.ndarray] = vmap( - solve_equation, in_axes=(0), out_axes=(0) - ) + @jit # type: ignore[misc] + def solve_equation(curve: jnp.ndarray) -> jnp.ndarray: + q = jac_vectorized( + curve[:-2], + curve[1:-1], + curve[2:], + segment_time_offsets[:-1], + segment_time_offsets[1:], + ) + dq = (1 - damping) * q + # Clip updates to prevent divergence when the route is still + # far from a locally optimal path. + dq = jnp.clip(dq, -q_max, q_max) + return curve.at[1:-1].set(dq + curve[1:-1]) - cost_now = costfun( - vectorfield=vectorfield, - curve=curve, - wavefield=wavefield, - travel_stw=travel_stw, - travel_time=travel_time, - weight_l1=weight_l1, - weight_l2=weight_l2, - spherical_correction=spherical_correction, + solve_vectorized = vmap(solve_equation, in_axes=0, out_axes=0) + + else: + raise ValueError("Either travel_stw or travel_time must be provided") + + cost_now = _evaluate_cost( + curve, + travel_stw_eval=travel_stw, + travel_time_eval=travel_time, + time_offset_eval=time_offset, ) cost_best = cost_now.copy() curve_best = curve.copy() @@ -313,23 +994,33 @@ def solve_equation(curve: jnp.ndarray) -> jnp.ndarray: while (idx < maxfevals) & (early_stop < patience).any(): curve_old = curve.copy() curve = solve_vectorized(curve) - # Replace points on land with previous iteration - if land is not None and penalty > 0: - is_land = land(curve) > 0 - curve = jnp.where(is_land[..., None], curve_old, curve) - cost_now = costfun( - vectorfield=vectorfield, - curve=curve, + curve = _apply_curve_constraints( + curve, + curve_old, + land=land, + windfield=windfield, wavefield=wavefield, + enforce_weather_limits=enforce_weather_limits, + tws_limit=tws_limit, + hs_limit=hs_limit, travel_stw=travel_stw, travel_time=travel_time, - weight_l1=weight_l1, - weight_l2=weight_l2, spherical_correction=spherical_correction, + time_offset=time_offset, + penalty=penalty, + ) + cost_now = _evaluate_cost( + curve, + travel_stw_eval=travel_stw, + travel_time_eval=travel_time, + time_offset_eval=time_offset, ) - # Update early stopping counter - early_stop += jnp.where(cost_now >= cost_best, 1, 0) + # Update early stopping counter. + # Only count stagnation once a feasible (finite-cost) solution exists; + # while cost_best is still inf the optimizer is still searching. + has_feasible_best = jnp.isfinite(cost_best) + early_stop += jnp.where(has_feasible_best & (cost_now >= cost_best), 1, 0) early_stop = jnp.where(cost_best > cost_now, 0, early_stop) # Store best solution @@ -338,13 +1029,24 @@ def solve_equation(curve: jnp.ndarray) -> jnp.ndarray: curve_best = jnp.where(improved[:, None, None], curve, curve_best) idx += 1 + if snapshot_callback is not None: + snapshot_callback( + { + "iteration": idx, + "curve": curve, + "cost": cost_now, + "best_curve": curve_best, + "best_cost": cost_best, + "early_stop": early_stop, + } + ) if verbose and (idx % 500 == 0 or idx == 1): print(f"FMS - Iteration {idx}, cost: {cost_now.min():.4f}") if verbose: print("FMS - Number of iterations:", idx) print("FMS - Optimization time:", time.time() - start) - print("FMS - Fuel cost:", cost_now.min()) + print("FMS - Fuel cost:", cost_best.min()) dict_fms = { "cost": cost_best.tolist(), @@ -379,6 +1081,7 @@ def main(gpu: bool = True, optimize_time: bool = False) -> None: travel_stw=None if optimize_time else 1, travel_time=10 if optimize_time else None, patience=50, + damping=0.99, ) xmin, xmax = curve[..., 0].min(), curve[..., 0].max() diff --git a/routetools/land.py b/routetools/land.py index c6ae1089..24595642 100644 --- a/routetools/land.py +++ b/routetools/land.py @@ -117,6 +117,16 @@ def __init__( self._map_mode = map_mode self._map_order = map_order + # Precompute EDT: distance (in grid cells) from each water cell + # to the nearest land cell. Land cells get distance 0. + binary_land = np.asarray(self._array > self.water_level) + # distance_transform_edt measures distance from 0-cells to nearest + # 1-cell, so we pass the *inverted* mask (water=0 → measure distance). + from scipy.ndimage import distance_transform_edt + + edt = distance_transform_edt(~binary_land) + self._edt = jnp.asarray(edt, dtype=jnp.float32) + @property def array(self) -> jnp.ndarray: """Return a boolean array indicating land presence.""" @@ -279,6 +289,64 @@ def penalization(self, curve: jnp.ndarray, penalty: float) -> jnp.ndarray: # Return the sum of the number of land intersections times the penalty return jnp.sum(is_land, axis=1) * penalty + def distance_penalty( + self, + curve: jnp.ndarray, + weight: float = 1.0, + epsilon: float = 1.0, + ) -> jnp.ndarray: + """Smooth repulsive penalty based on precomputed EDT. + + Samples the Euclidean Distance Transform at each waypoint and + returns ``weight * sum(1 / (edt + epsilon))`` per route. Points on + or very near land produce large penalties; points far from land + contribute negligibly. Uses ``map_coordinates`` for O(1) per-point + lookups and is fully JIT-compatible. + + Parameters + ---------- + curve : jnp.ndarray + Batch of curves, shape ``(W, L, 2)`` with ``(lon, lat)``. + weight : float + Scaling factor for the penalty (default 1.0). + epsilon : float + Regularisation constant to avoid division by zero (default 1.0). + + Returns + ------- + jnp.ndarray + Penalty per route, shape ``(W,)``. + """ + x_coords = curve[..., 0] + y_coords = curve[..., 1] + + x_norm = (x_coords - self.xmin) * self.xnorm + y_norm = (y_coords - self.ymin) * self.ynorm + + # Sample the EDT field using instance interpolation settings + edt_vals = map_coordinates( + self._edt, + [x_norm, y_norm], + order=self._map_order, + mode=self._map_mode, + ) + + # If requested, treat out-of-bounds points as land by forcing + # their EDT to zero, which yields the maximum penalty via + # 1 / (edt + epsilon), consistent with Land.__call__. + if getattr(self, "outbounds_is_land", False): + oob_mask = ( + (x_coords < self.xmin) + | (x_coords > self.xmax) + | (y_coords < self.ymin) + | (y_coords > self.ymax) + ) + edt_vals = jnp.where(oob_mask, 0.0, edt_vals) + + # Inverse-distance penalty: closer to land → larger cost + point_penalty = 1.0 / (edt_vals + epsilon) + return weight * jnp.sum(point_penalty, axis=-1) + def distance_to_land( self, curve: jnp.ndarray, haversine: bool = False ) -> jnp.ndarray: diff --git a/routetools/performance.py b/routetools/performance.py new file mode 100644 index 00000000..ed61c700 --- /dev/null +++ b/routetools/performance.py @@ -0,0 +1,370 @@ +"""SWOPP3 ship performance model — closed-form parametric approximation. + +This module provides a fully closed-form approximation of the SWOPP3 +compiled Rust performance model for a generic 88 m cargo ship (CPP, +electric propulsion) with four 138 m² wingsails. + +The model computes propulsive power (kW) as a function of environmental +conditions and ship speed. It supports two modes: + +- **Without WPS** (Wind-Powered Ship / sails retracted): + ``P = max(0, P_hull + P_wind + P_wave)`` + +- **With WPS** (wingsails deployed): + ``P = max(0, P_hull + P_wind + P_wave − P_sail)`` + +Components: + +- Hull drag: ``P_hull = K_H · v³`` +- Wind drag: ``P_wind = K_A · v · (VR · u_x − v²)`` +- Wave added resistance: ``P_wave = A_W · SWH² · v^{1.5} · exp(−K_W · |MWA_rad|³)`` + where ``MWA_rad`` is MWA centered to [-180°, 180°] then converted to radians. +- Sail thrust: ``P_sail = C(AWA) · VR² · v`` + where ``C(AWA) = K_S · sin(α) · (1 + 3/20 · sin²(α))`` for AWA ≥ 10°, + and ``C(AWA) = 0`` for AWA < 10° (dead zone). + +Accuracy vs the compiled SWOPP3 reference (50 000 random samples): + - Mean absolute error: 0.004 kW + - Max absolute error: 0.050 kW + - 100% of samples < 0.1 kW error + +References +---------- +SWOPP3 competition performance model (RISE binary wheel). +Reverse-engineered via systematic probing and spline identification; +see ``docs/parametric_model.md`` for the full derivation. + +Example +------- +>>> from routetools.performance import predict_power +>>> # Without sails: TWS=10 m/s, TWA=90°, SWH=2 m, MWA=45°, v=8 m/s +>>> predict_power(10, 90, 2, 45, 8, wps=False) +2568.7... +>>> # With sails +>>> predict_power(10, 90, 2, 45, 8, wps=True) +1832.3... +""" + +from __future__ import annotations + +import math + +import jax.numpy as jnp +import numpy as np +from numpy.typing import ArrayLike + +__all__ = [ + "predict_power", + "predict_power_batch", + "predict_power_no_wps", + "predict_power_with_wps", + "K_H", + "K_A", + "A_W", + "K_W", + "K_S", + "SAIL_DEAD_ZONE_DEG", + "SAIL_QUADRATIC_CORRECTION", +] + +# --------------------------------------------------------------------------- +# Physical constants (reverse-engineered from SWOPP3 binary) +# --------------------------------------------------------------------------- +K_H: float = 969 / 226 +"""Hull drag coefficient (≈ 4.28761). ``P_hull = K_H · v³``.""" + +K_A: float = 49 / 320 +"""Aerodynamic drag coefficient (≈ 0.153125). + +``P_wind = K_A · v · (VR · u_x − v²)``. +""" + +A_W: float = 11.1395 +"""Wave added-resistance amplitude (exact). + +``P_wave = A_W · SWH² · v^{1.5} · exp(…)``. +""" + +K_W: float = 0.28935 +"""Wave directional decay rate. ``exp(−K_W · |MWA_rad|³)``.""" + +K_S: float = 0.85903125 +"""Sail thrust coefficient. ``C(AWA) = K_S · sin(α) · (1 + 3/20 · sin²(α))``.""" + +SAIL_DEAD_ZONE_DEG: float = 10.0 +"""Below this apparent wind angle (degrees), sail power is zero.""" + +SAIL_QUADRATIC_CORRECTION: float = 3 / 20 +"""Quadratic correction factor in the sail polar (= 0.15).""" + + +# --------------------------------------------------------------------------- +# Core scalar functions +# --------------------------------------------------------------------------- +def predict_power_no_wps( + tws: float, + twa: float, + swh: float, + mwa: float, + v: float, +) -> float: + """Predict propulsive power without wingsails (sails retracted). + + Parameters + ---------- + tws : float + True wind speed (m/s), ≥ 0. + twa : float + True wind angle (degrees), 0 = headwind, 180 = tailwind. + Symmetric in sign. + swh : float + Significant wave height (m), ≥ 0. + mwa : float + Mean wave angle (degrees), same convention as TWA. + Symmetric in sign. + v : float + Ship speed through water (m/s), ≥ 0. + + Returns + ------- + float + Propulsive power in kW, clamped to ≥ 0. + """ + twa_rad = math.radians(twa) + mwa_rad = math.radians((mwa + 180.0) % 360.0 - 180.0) + + # Apparent wind components + ux = tws * math.cos(twa_rad) + v + uy = tws * math.sin(twa_rad) + vr = math.sqrt(ux * ux + uy * uy) + + p_hull = K_H * v * v * v + p_wind = K_A * v * (vr * ux - v * v) + p_wave = A_W * swh * swh * v**1.5 * math.exp(-K_W * abs(mwa_rad) ** 3) + + return max(0.0, p_hull + p_wind + p_wave) + + +def predict_power_with_wps( + tws: float, + twa: float, + swh: float, + mwa: float, + v: float, +) -> float: + """Predict propulsive power with wingsails deployed. + + Same interface as :func:`predict_power_no_wps` but subtracts sail + thrust from the total resistance. + + Parameters + ---------- + tws : float + True wind speed (m/s), ≥ 0. + twa : float + True wind angle (degrees). + swh : float + Significant wave height (m), ≥ 0. + mwa : float + Mean wave angle (degrees). + v : float + Ship speed through water (m/s), ≥ 0. + + Returns + ------- + float + Propulsive power in kW, clamped to ≥ 0. + """ + twa_rad = math.radians(twa) + mwa_rad = math.radians((mwa + 180.0) % 360.0 - 180.0) + + ux = tws * math.cos(twa_rad) + v + uy = tws * math.sin(twa_rad) + vr2 = ux * ux + uy * uy + vr = math.sqrt(vr2) + + p_hull = K_H * v * v * v + p_wind = K_A * v * (vr * ux - v * v) + p_wave = A_W * swh * swh * v**1.5 * math.exp(-K_W * abs(mwa_rad) ** 3) + + # Sail thrust (closed-form polar) + awa_deg = math.degrees(math.atan2(abs(uy), ux)) + if awa_deg < SAIL_DEAD_ZONE_DEG: + p_sail = 0.0 + else: + alpha = math.radians(awa_deg - SAIL_DEAD_ZONE_DEG) + sin_a = math.sin(alpha) + c_awa = K_S * sin_a * (1.0 + SAIL_QUADRATIC_CORRECTION * sin_a * sin_a) + p_sail = c_awa * vr2 * v + + return max(0.0, p_hull + p_wind + p_wave - p_sail) + + +def predict_power( + tws: float, + twa: float, + swh: float, + mwa: float, + v: float, + *, + wps: bool = False, +) -> float: + """Predict propulsive power for the SWOPP3 vessel. + + Unified entry point that dispatches to the no-WPS or with-WPS model + depending on the ``wps`` flag. + + Parameters + ---------- + tws : float + True wind speed (m/s), ≥ 0. + twa : float + True wind angle (degrees), 0 = headwind, 180 = tailwind. + swh : float + Significant wave height (m), ≥ 0. + mwa : float + Mean wave angle (degrees). + v : float + Ship speed through water (m/s), ≥ 0. + wps : bool, optional + Whether to include wingsail thrust (Wind-Powered Ship mode). + Default is False (sails retracted). + + Returns + ------- + float + Propulsive power in kW, clamped to ≥ 0. + + Examples + -------- + >>> predict_power(10, 90, 2, 45, 8) # no sails + 2568.7... + >>> predict_power(10, 90, 2, 45, 8, wps=True) # with sails + 1832.3... + """ + if wps: + return predict_power_with_wps(tws, twa, swh, mwa, v) + return predict_power_no_wps(tws, twa, swh, mwa, v) + + +# --------------------------------------------------------------------------- +# Vectorized (NumPy) entry points +# --------------------------------------------------------------------------- +def predict_power_batch( + tws: ArrayLike, + twa: ArrayLike, + swh: ArrayLike, + mwa: ArrayLike, + v: ArrayLike, + *, + wps: bool = False, +) -> np.ndarray: + """Vectorized power prediction over arrays of inputs. + + All input arrays must be broadcast-compatible. + + Parameters + ---------- + tws, twa, swh, mwa, v : array_like + Same semantics as :func:`predict_power`. + wps : bool, optional + Wind-Powered Ship mode, default False. + + Returns + ------- + np.ndarray + Propulsive power in kW for each input combination. + """ + tws = np.asarray(tws, dtype=np.float64) + twa = np.asarray(twa, dtype=np.float64) + swh = np.asarray(swh, dtype=np.float64) + mwa = np.asarray(mwa, dtype=np.float64) + v = np.asarray(v, dtype=np.float64) + + twa_rad = np.radians(twa) + mwa_rad = np.radians(np.mod(mwa + 180.0, 360.0) - 180.0) + + ux = tws * np.cos(twa_rad) + v + uy = tws * np.sin(twa_rad) + vr2 = ux**2 + uy**2 + vr = np.sqrt(vr2) + + p_hull = K_H * v**3 + p_wind = K_A * v * (vr * ux - v**2) + p_wave = A_W * swh**2 * v**1.5 * np.exp(-K_W * np.abs(mwa_rad) ** 3) + + total = p_hull + p_wind + p_wave + + if wps: + awa_deg = np.degrees(np.arctan2(np.abs(uy), ux)) + alpha = np.radians(np.maximum(awa_deg - SAIL_DEAD_ZONE_DEG, 0.0)) + sin_a = np.sin(alpha) + c_awa = K_S * sin_a * (1.0 + SAIL_QUADRATIC_CORRECTION * sin_a**2) + p_sail = c_awa * vr2 * v + total = total - p_sail + + return np.maximum(total, 0.0) + + +# --------------------------------------------------------------------------- +# JAX-compatible (JIT / autodiff) entry point +# --------------------------------------------------------------------------- +def predict_power_jax( + tws: jnp.ndarray, + twa: jnp.ndarray, + swh: jnp.ndarray, + mwa: jnp.ndarray, + v: jnp.ndarray, + *, + wps: bool = False, +) -> jnp.ndarray: + """JAX-compatible vectorized power prediction. + + Identical physics to :func:`predict_power_batch` but uses ``jax.numpy`` + throughout. Fully compatible with ``jax.jit``, ``jax.vmap``, and + ``jax.grad``. + + Parameters + ---------- + tws : jnp.ndarray + True wind speed (m/s). + twa : jnp.ndarray + True wind angle (degrees), 0 = headwind, 180 = tailwind. + swh : jnp.ndarray + Significant wave height (m). + mwa : jnp.ndarray + Mean wave angle (degrees), same convention as TWA. + v : jnp.ndarray + Ship speed through water (m/s). + wps : bool + Whether to include wingsail thrust. **Must be a static + (compile-time) value** when used inside ``jax.jit``. + + Returns + ------- + jnp.ndarray + Propulsive power in kW. + """ + twa_rad = jnp.radians(twa) + mwa_rad = jnp.radians(jnp.mod(mwa + 180.0, 360.0) - 180.0) + + ux = tws * jnp.cos(twa_rad) + v + uy = tws * jnp.sin(twa_rad) + vr2 = ux**2 + uy**2 + vr = jnp.sqrt(vr2) + + p_hull = K_H * v**3 + p_wind = K_A * v * (vr * ux - v**2) + p_wave = A_W * swh**2 * v**1.5 * jnp.exp(-K_W * jnp.abs(mwa_rad) ** 3) + + total = p_hull + p_wind + p_wave + + if wps: + awa_deg = jnp.degrees(jnp.arctan2(jnp.abs(uy), ux)) + alpha = jnp.radians(jnp.maximum(awa_deg - SAIL_DEAD_ZONE_DEG, 0.0)) + sin_a = jnp.sin(alpha) + c_awa = K_S * sin_a * (1.0 + SAIL_QUADRATIC_CORRECTION * sin_a**2) + p_sail = c_awa * vr2 * v + total = total - p_sail + + return jnp.maximum(total, 0.0) diff --git a/routetools/swopp3.py b/routetools/swopp3.py new file mode 100644 index 00000000..37f9e9b3 --- /dev/null +++ b/routetools/swopp3.py @@ -0,0 +1,256 @@ +"""SWOPP3 competition configuration — routes, departures, and cases. + +Defines the two SWOPP3 routes (Trans-Atlantic and Trans-Pacific), their +fixed passage times, and the 366 daily departures throughout 2024. + +The 8 SWOPP3 cases (Table 2 in the SWOPP3 info package): + +==== ========= ==================== ========================= === +Case Name Route Strategy WPS +==== ========= ==================== ========================= === +1 AO_WPS Atlantic westbound Optimised route + speed yes +2 AO_noWPS Atlantic westbound Optimised route + speed no +3 AGC_WPS Atlantic westbound Great circle, fixed speed yes +4 AGC_noWPS Atlantic westbound Great circle, fixed speed no +5 PO_WPS Pacific eastbound Optimised route + speed yes +6 PO_noWPS Pacific eastbound Optimised route + speed no +7 PGC_WPS Pacific eastbound Great circle, fixed speed yes +8 PGC_noWPS Pacific eastbound Great circle, fixed speed no +==== ========= ==================== ========================= === + +- **Optimised (O):** CMA-ES finds minimum-energy route and speed profile. +- **Great Circle (GC):** Fixed geodesic route, constant speed. +- **WPS:** RISE polar model with wingsails enabled. +- **noWPS:** RISE polar model with wingsails disabled (engine only). + +Example +------- +>>> from routetools.swopp3 import SWOPP3_CASES, departures_2024 +>>> case = SWOPP3_CASES["AO_WPS"] +>>> deps = departures_2024() +>>> print(f"{case['name']}: {len(deps)} departures, {case['passage_hours']}h passage") +AO_WPS: 366 departures, 354h passage +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import jax.numpy as jnp + +from routetools._cost.haversine import great_circle_route as _great_circle_route +from routetools._ports import DICT_PORTS + +# --------------------------------------------------------------------------- +# Port coordinates (lon, lat) — matching routetools._ports.DICT_PORTS +# --------------------------------------------------------------------------- +_SWOPP3_PORT_CODES = ("ESSDR", "USNYS", "JPTYO", "USLAX") +PORTS: dict[str, dict[str, str | float]] = { + code: { + "name": str(DICT_PORTS[code].get("city", code)), + "lat": float(DICT_PORTS[code]["lat"]), + "lon": float(DICT_PORTS[code]["lon"]), + } + for code in _SWOPP3_PORT_CODES +} + +# --------------------------------------------------------------------------- +# Route definitions +# --------------------------------------------------------------------------- +ROUTE_ATLANTIC = { + "id": "atlantic", + "label": "Trans-Atlantic", + "src_port": "ESSDR", + "dst_port": "USNYS", + "passage_hours": 354, + "gc_distance_nm": 2826, +} + +ROUTE_PACIFIC = { + "id": "pacific", + "label": "Trans-Pacific", + "src_port": "JPTYO", + "dst_port": "USLAX", + "passage_hours": 583, + "gc_distance_nm": 4663, +} + +# --------------------------------------------------------------------------- +# The 8 SWOPP3 cases +# --------------------------------------------------------------------------- +SWOPP3_CASES: dict[str, dict] = { + "AO_WPS": { + "name": "AO_WPS", + "label": "Atlantic Optimised, with WPS", + "route": "atlantic", + "src_port": "ESSDR", + "dst_port": "USNYS", + "passage_hours": 354, + "strategy": "optimised", + "wps": True, + }, + "AO_noWPS": { + "name": "AO_noWPS", + "label": "Atlantic Optimised, without WPS", + "route": "atlantic", + "src_port": "ESSDR", + "dst_port": "USNYS", + "passage_hours": 354, + "strategy": "optimised", + "wps": False, + }, + "AGC_WPS": { + "name": "AGC_WPS", + "label": "Atlantic Great Circle, with WPS", + "route": "atlantic", + "src_port": "ESSDR", + "dst_port": "USNYS", + "passage_hours": 354, + "strategy": "gc", + "wps": True, + }, + "AGC_noWPS": { + "name": "AGC_noWPS", + "label": "Atlantic Great Circle, without WPS", + "route": "atlantic", + "src_port": "ESSDR", + "dst_port": "USNYS", + "passage_hours": 354, + "strategy": "gc", + "wps": False, + }, + "PO_WPS": { + "name": "PO_WPS", + "label": "Pacific Optimised, with WPS", + "route": "pacific", + "src_port": "JPTYO", + "dst_port": "USLAX", + "passage_hours": 583, + "strategy": "optimised", + "wps": True, + }, + "PO_noWPS": { + "name": "PO_noWPS", + "label": "Pacific Optimised, without WPS", + "route": "pacific", + "src_port": "JPTYO", + "dst_port": "USLAX", + "passage_hours": 583, + "strategy": "optimised", + "wps": False, + }, + "PGC_WPS": { + "name": "PGC_WPS", + "label": "Pacific Great Circle, with WPS", + "route": "pacific", + "src_port": "JPTYO", + "dst_port": "USLAX", + "passage_hours": 583, + "strategy": "gc", + "wps": True, + }, + "PGC_noWPS": { + "name": "PGC_noWPS", + "label": "Pacific Great Circle, without WPS", + "route": "pacific", + "src_port": "JPTYO", + "dst_port": "USLAX", + "passage_hours": 583, + "strategy": "gc", + "wps": False, + }, +} + +# --------------------------------------------------------------------------- +# Departure schedule +# --------------------------------------------------------------------------- +_YEAR = 2024 # Leap year → 366 days +_DEPARTURE_HOUR = 12 # Noon UTC + + +def departures_2024() -> list[datetime]: + """Return the 366 daily noon-UTC departures for 2024. + + Returns + ------- + list[datetime] + 366 timezone-aware ``datetime`` objects (UTC), one per day from + 2024-01-01 12:00 UTC through 2024-12-31 12:00 UTC. + """ + start = datetime(_YEAR, 1, 1, _DEPARTURE_HOUR, tzinfo=UTC) + return [start + timedelta(days=d) for d in range(366)] + + +def departure_strings(fmt: str = "%Y-%m-%dT%H:%M:%S") -> list[str]: + """Return departure datetimes as formatted strings. + + Parameters + ---------- + fmt : str + ``strftime`` format, default ISO-8601 without timezone suffix. + + Returns + ------- + list[str] + 366 formatted date strings. + """ + return [d.strftime(fmt) for d in departures_2024()] + + +# --------------------------------------------------------------------------- +# Helper: build (src, dst) JAX arrays for a case +# --------------------------------------------------------------------------- +def case_endpoints(case_id: str) -> tuple[jnp.ndarray, jnp.ndarray]: + """Return ``(src, dst)`` as JAX arrays of ``(lon, lat)`` for a case. + + Parameters + ---------- + case_id : str + Identifier of a SWOPP3 case, i.e. one of ``SWOPP3_CASES.keys()`` (for + example ``"AO_WPS"``). + + Returns + ------- + tuple[jnp.ndarray, jnp.ndarray] + ``src`` and ``dst``, each shape ``(2,)`` with ``(lon, lat)``. + + Raises + ------ + KeyError + If *case_id* is not a valid SWOPP3 case. + """ + case = SWOPP3_CASES[case_id] + src_port = PORTS[case["src_port"]] + dst_port = PORTS[case["dst_port"]] + src = jnp.array([src_port["lon"], src_port["lat"]]) + dst = jnp.array([dst_port["lon"], dst_port["lat"]]) + return src, dst + + +def case_travel_time_seconds(case_id: str) -> float: + """Return the fixed passage time in seconds for a case. + + Parameters + ---------- + case_id : str + Identifier of a SWOPP3 case, i.e. one of ``SWOPP3_CASES.keys()``. + + Returns + ------- + float + Passage time in seconds. + """ + return float(SWOPP3_CASES[case_id]["passage_hours"] * 3600) + + +def great_circle_route( + src: jnp.ndarray, + dst: jnp.ndarray, + n_points: int = 100, +) -> jnp.ndarray: + """Compute a great-circle route between two points. + + This wrapper preserves the public SWOPP3 API and delegates to + :func:`routetools._cost.haversine.great_circle_route`. + """ + return _great_circle_route(src, dst, n_points=n_points) diff --git a/routetools/swopp3_output.py b/routetools/swopp3_output.py new file mode 100644 index 00000000..ea644058 --- /dev/null +++ b/routetools/swopp3_output.py @@ -0,0 +1,344 @@ +"""SWOPP3 output formatters — File A (energy summary) and File B (tracks). + +Produces CSV files in the exact format required by the SWOPP3 competition. + +**File A** — one CSV per case, 366 rows (one per departure): + + departure_time_utc, arrival_time_utc, energy_cons_mwh, + max_wind_mps, max_hs_m, sailed_distance_nm, details_filename + +**File B** — one CSV per departure (referenced by ``details_filename``): + + time_utc, lat_deg, lon_deg + +Naming convention:: + + IEUniversity-{submission}-{casename}.csv (File A) + IEUniversity-{submission}-{casename}-{dep}.csv (File B) +""" + +from __future__ import annotations + +import csv +from datetime import datetime, timedelta +from pathlib import Path +from typing import TYPE_CHECKING + +import jax.numpy as jnp + +from routetools._cost.haversine import curve_distance_nm, waypoint_times_uniform + +if TYPE_CHECKING: + import pandas as pd + +# SWOPP3 datetime format +_DTFMT = "%Y-%m-%d %H:%M:%S" + +# Team identifier +TEAM = "IEUniversity" + +__all__ = [ + "TEAM", + "file_a_row", + "resolve_file_a_path", + "resolve_file_b_path", + "read_file_a_dataframe", + "read_file_b_dataframe", + "write_file_a", + "write_file_b", + "sailed_distance_nm", + "waypoint_times", +] + + +# --------------------------------------------------------------------------- +# Utilities +# --------------------------------------------------------------------------- +def sailed_distance_nm(curve: jnp.ndarray) -> float: + """Total sailed distance in nautical miles. + + Parameters + ---------- + curve : jnp.ndarray + Shape ``(L, 2)`` with ``(lon, lat)`` in degrees. + + Returns + ------- + float + Distance in nautical miles. + """ + return curve_distance_nm(curve) + + +def waypoint_times( + curve: jnp.ndarray, + departure: datetime, + passage_hours: float, +) -> list[datetime]: + """Compute UTC timestamps at each waypoint assuming constant speed. + + Distributes waypoints uniformly in time over the passage duration. + + Parameters + ---------- + curve : jnp.ndarray + Shape ``(L, 2)`` waypoints in ``(lon, lat)`` order. + departure : datetime + Departure time (UTC). + passage_hours : float + Total passage time in hours. + + Returns + ------- + list[datetime] + ``L`` UTC datetimes, one per waypoint. + + Raises + ------ + ValueError + If *curve* has no waypoints. + """ + return waypoint_times_uniform(curve, departure, passage_hours) + + +def resolve_file_a_path( + input_dir: str | Path, + casename: str, + submission: int | None = None, +) -> Path: + """Resolve File A path for a case. + + When *submission* is ``None``, returns the latest submission for the case. + """ + input_dir = Path(input_dir) + if submission is None: + pattern = f"{TEAM}-*-{casename}.csv" + candidates = sorted(input_dir.glob(pattern)) + if not candidates: + raise FileNotFoundError( + f"No summary CSV found for case '{casename}' in '{input_dir}'. " + f"Tried pattern '{pattern}'." + ) + + def _submission_key(path: Path) -> int: + parts = path.stem.split("-") + if len(parts) >= 3: + try: + return int(parts[1]) + except ValueError: + return -1 + return -1 + + candidates.sort(key=lambda path: (_submission_key(path), path.name)) + return candidates[-1] + + path = input_dir / file_a_name(submission, casename) + if not path.exists(): + raise FileNotFoundError(f"Summary CSV not found: {path}") + return path + + +def resolve_file_b_path( + input_dir: str | Path, + filename: str, +) -> Path: + """Resolve File B path from details filename.""" + path = Path(input_dir) / "tracks" / filename + if not path.exists(): + raise FileNotFoundError(f"Track CSV not found: {path}") + return path + + +def read_file_a_dataframe( + input_dir: str | Path, + casename: str, + submission: int | None = None, +) -> pd.DataFrame: + """Read a File A CSV into a pandas DataFrame.""" + import pandas as pd + + path = resolve_file_a_path(input_dir, casename, submission=submission) + return pd.read_csv(path, parse_dates=["departure_time_utc", "arrival_time_utc"]) + + +def read_file_b_dataframe( + input_dir: str | Path, + filename: str, +) -> pd.DataFrame: + """Read a File B CSV into a pandas DataFrame.""" + import pandas as pd + + path = resolve_file_b_path(input_dir, filename) + return pd.read_csv(path, parse_dates=["time_utc"]) + + +# --------------------------------------------------------------------------- +# File A — Energy summary +# --------------------------------------------------------------------------- +def file_a_row( + departure: datetime, + passage_hours: float, + energy_mwh: float, + max_wind_mps: float, + max_hs_m: float, + distance_nm: float, + details_filename: str, +) -> dict[str, str]: + """Build one row of File A as a dict. + + Parameters + ---------- + departure : datetime + Departure time UTC. + passage_hours : float + Passage time in hours. + energy_mwh : float + Total energy consumption in MWh. + max_wind_mps : float + Maximum true wind speed encountered (m/s). + max_hs_m : float + Maximum significant wave height encountered (m). + distance_nm : float + Sailed distance in nautical miles. + details_filename : str + Name of the corresponding File B CSV. + + Returns + ------- + dict[str, str] + Column-name → string-value mapping. + """ + arrival = departure + timedelta(hours=passage_hours) + return { + "departure_time_utc": departure.strftime(_DTFMT), + "arrival_time_utc": arrival.strftime(_DTFMT), + "energy_cons_mwh": f"{energy_mwh:.6f}", + "max_wind_mps": f"{max_wind_mps:.4f}", + "max_hs_m": f"{max_hs_m:.4f}", + "sailed_distance_nm": f"{distance_nm:.4f}", + "details_filename": details_filename, + } + + +_FILE_A_COLUMNS = [ + "departure_time_utc", + "arrival_time_utc", + "energy_cons_mwh", + "max_wind_mps", + "max_hs_m", + "sailed_distance_nm", + "details_filename", +] + + +def file_a_name(submission: int, casename: str) -> str: + """Generate the File A filename. + + Parameters + ---------- + submission : int + Submission number (e.g. 1, 2, …). + casename : str + Case name (e.g. ``"AO_WPS"``). + + Returns + ------- + str + Filename like ``IEUniversity-1-AO_WPS.csv``. + """ + return f"{TEAM}-{submission}-{casename}.csv" + + +def write_file_a( + rows: list[dict[str, str]], + path: str | Path, +) -> Path: + """Write a File A CSV. + + Parameters + ---------- + rows : list[dict] + List of row dicts (from :func:`file_a_row`). + path : str or Path + Output file path. + + Returns + ------- + Path + The written path. + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=_FILE_A_COLUMNS) + writer.writeheader() + writer.writerows(rows) + return path + + +# --------------------------------------------------------------------------- +# File B — Track coordinates +# --------------------------------------------------------------------------- +_FILE_B_COLUMNS = ["time_utc", "lat_deg", "lon_deg"] + + +def file_b_name(submission: int, casename: str, departure: datetime) -> str: + """Generate the File B filename. + + Parameters + ---------- + submission : int + Submission number. + casename : str + Case name (e.g. ``"AO_WPS"``). + departure : datetime + Departure date. + + Returns + ------- + str + Filename like ``IEUniversity-1-AO_WPS-20240101.csv``. + """ + date_str = departure.strftime("%Y%m%d") + return f"{TEAM}-{submission}-{casename}-{date_str}.csv" + + +def write_file_b( + curve: jnp.ndarray, + times: list[datetime], + path: str | Path, +) -> Path: + """Write a File B (track) CSV. + + Parameters + ---------- + curve : jnp.ndarray + Shape ``(L, 2)`` with ``(lon, lat)`` in degrees. + times : list[datetime] + ``L`` UTC timestamps (one per waypoint). + path : str or Path + Output file path. + + Returns + ------- + Path + The written path. + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + L = curve.shape[0] + if len(times) != L: + raise ValueError(f"Expected {L} times, got {len(times)}") + + with path.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=_FILE_B_COLUMNS) + writer.writeheader() + for i in range(L): + writer.writerow( + { + "time_utc": times[i].strftime(_DTFMT), + "lat_deg": f"{float(curve[i, 1]):.6f}", + "lon_deg": f"{float(curve[i, 0]):.6f}", + } + ) + return path diff --git a/routetools/swopp3_runner.py b/routetools/swopp3_runner.py new file mode 100644 index 00000000..14930f8d --- /dev/null +++ b/routetools/swopp3_runner.py @@ -0,0 +1,575 @@ +"""SWOPP3 runner — execute all 8 cases × 366 departures. + +Orchestrates the end-to-end pipeline: + +1. **Great-Circle (GC) cases** — fixed geodesic route, constant speed, + energy evaluated via the RISE performance model. +2. **Optimised (O) cases** — CMA-ES route optimisation followed by energy + evaluation. + +Both modes support WPS (wingsails on) and noWPS (engine only). + +Main entry points: + +- :func:`evaluate_energy` — evaluate energy along a route. +- :func:`run_gc_departure` — single GC departure. +- :func:`run_optimised_departure` — single optimised departure. +- :func:`run_case` — all departures for one case. +""" + +from __future__ import annotations + +import logging +import time as _time +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +import jax.numpy as jnp + +from routetools.cost import evaluate_route_energy +from routetools.cost import segment_bearings_deg as _segment_bearings_deg +from routetools.swopp3 import ( + SWOPP3_CASES, + case_endpoints, + great_circle_route, +) +from routetools.swopp3_output import ( + file_a_name, + file_a_row, + file_b_name, + sailed_distance_nm, + waypoint_times, + write_file_a, + write_file_b, +) + +logger = logging.getLogger(__name__) + +__all__ = [ + "DepartureResult", + "segment_bearings_deg", + "evaluate_energy", + "run_gc_departure", + "run_optimised_departure", + "run_case", + "log_run_parameters", +] + +# Type alias for field closures: (lon, lat, t) -> (comp1, comp2) +FieldClosure = Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], + tuple[jnp.ndarray, jnp.ndarray], +] + + +# --------------------------------------------------------------------------- +# Parameter logging +# --------------------------------------------------------------------------- +def log_run_parameters( + case_id: str, + n_departures: int, + n_points: int, + **kwargs: object, +) -> None: + """Log run configuration parameters at the start of a case. + + Parameters + ---------- + case_id : str + SWOPP3 case identifier. + n_departures : int + Number of departures to run. + n_points : int + Number of waypoints. + **kwargs + Additional parameters (penalty weights, CMA-ES settings, etc.). + """ + case = SWOPP3_CASES[case_id] + lines = [ + "=" * 60, + f"SWOPP3 Run: {case['name']} ({case_id})", + f" strategy: {case['strategy']}", + f" wps: {case['wps']}", + f" passage_h: {case['passage_hours']}", + f" departures: {n_departures}", + f" n_points: {n_points}", + ] + for key, val in sorted(kwargs.items()): + lines.append(f" {key}: {val}") + lines.append("=" * 60) + msg = "\n".join(lines) + logger.info(msg) + print(msg) + + +# --------------------------------------------------------------------------- +# Result container +# --------------------------------------------------------------------------- +@dataclass +class DepartureResult: + """Result for a single departure evaluation. + + Attributes + ---------- + departure : datetime + Departure datetime (UTC). + curve : jnp.ndarray + Optimised or GC route, shape ``(L, 2)`` with ``(lon, lat)``. + energy_mwh : float + Total energy consumption in MWh. + max_tws_mps : float + Maximum true wind speed encountered (m/s). + max_hs_m : float + Maximum significant wave height encountered (m). + distance_nm : float + Total sailed distance in nautical miles. + comp_time_s : float + Computation time in seconds. + """ + + departure: datetime + curve: jnp.ndarray + energy_mwh: float + max_tws_mps: float + max_hs_m: float + distance_nm: float + comp_time_s: float = 0.0 + + +# --------------------------------------------------------------------------- +# Ship bearing computation +# --------------------------------------------------------------------------- +def segment_bearings_deg(curve: jnp.ndarray) -> jnp.ndarray: + """Compute true-north bearing (degrees) for each route segment. + + This wrapper is kept for backwards compatibility and delegates to + :func:`routetools.cost.segment_bearings_deg`. + """ + return _segment_bearings_deg(curve) + + +# --------------------------------------------------------------------------- +# Energy evaluation +# --------------------------------------------------------------------------- +def evaluate_energy( + curve: jnp.ndarray, + departure: datetime, + passage_hours: float, + wps: bool, + windfield: FieldClosure | None = None, + wavefield: FieldClosure | None = None, + departure_offset_h: float = 0.0, +) -> tuple[float, float, float]: + """Evaluate energy consumption along a route. + + This wrapper preserves the SWOPP3 runner API and delegates to + :func:`routetools.cost.evaluate_route_energy`. + """ + _ = departure + return evaluate_route_energy( + curve, + passage_hours, + wps=wps, + windfield=windfield, + wavefield=wavefield, + departure_offset_h=departure_offset_h, + ) + + +# --------------------------------------------------------------------------- +# GC departure +# --------------------------------------------------------------------------- +def run_gc_departure( + case_id: str, + departure: datetime, + windfield: FieldClosure | None = None, + wavefield: FieldClosure | None = None, + departure_offset_h: float = 0.0, + n_points: int = 100, +) -> DepartureResult: + """Evaluate a single Great-Circle departure. + + Parameters + ---------- + case_id : str + SWOPP3 case identifier (e.g. ``"AGC_WPS"``). + departure : datetime + Departure time (UTC). + windfield, wavefield : FieldClosure, optional + Pre-loaded closures. + departure_offset_h : float + Time offset (hours) from field origin to departure. + n_points : int + Number of waypoints on the great-circle route. + + Returns + ------- + DepartureResult + """ + case = SWOPP3_CASES[case_id] + src, dst = case_endpoints(case_id) + + t0 = _time.time() + curve = great_circle_route(src, dst, n_points=n_points) + distance_nm = sailed_distance_nm(curve) + + energy_mwh, max_tws, max_hs = evaluate_energy( + curve, + departure, + case["passage_hours"], + wps=case["wps"], + windfield=windfield, + wavefield=wavefield, + departure_offset_h=departure_offset_h, + ) + comp_time = _time.time() - t0 + + return DepartureResult( + departure=departure, + curve=curve, + energy_mwh=energy_mwh, + max_tws_mps=max_tws, + max_hs_m=max_hs, + distance_nm=distance_nm, + comp_time_s=comp_time, + ) + + +# --------------------------------------------------------------------------- +# Optimised departure +# --------------------------------------------------------------------------- +def run_optimised_departure( + case_id: str, + departure: datetime, + vectorfield: FieldClosure | None = None, + windfield: FieldClosure | None = None, + wavefield: FieldClosure | None = None, + land=None, + departure_offset_h: float = 0.0, + n_points: int = 100, + **cmaes_kwargs, +) -> DepartureResult: + """Optimise and evaluate a single departure using CMA-ES. + + The CMA-ES optimizer minimises travel cost through the wind field. + Energy is then evaluated post-hoc with the SWOPP3 performance model. + Missing optimisation inputs are treated as an error; this function does + not fall back to a great-circle route. + + Parameters + ---------- + case_id : str + SWOPP3 case identifier (e.g. ``"AO_WPS"``). + departure : datetime + Departure time (UTC). + vectorfield : FieldClosure, optional + Vector field for the CMA-ES cost function. For SWOPP3 this is + typically the ERA5 wind field. If ``None``, this function raises + ``ValueError`` instead of falling back to a great-circle route. + windfield, wavefield : FieldClosure, optional + Pre-loaded closures for energy evaluation. + land : Land, optional + Land mask for penalisation. + departure_offset_h : float + Time offset (hours) from field origin to departure. + n_points : int + Number of waypoints (CMA-ES ``L`` parameter). + **cmaes_kwargs + Additional keyword arguments passed to :func:`routetools.cmaes.optimize`. + + Returns + ------- + DepartureResult + + Raises + ------ + ValueError + If ``vectorfield`` is ``None``. Optimised departures require an ERA5 + wind-derived vector field so CMA-ES cannot silently degrade to a + great-circle route. + """ + case = SWOPP3_CASES[case_id] + src, dst = case_endpoints(case_id) + travel_time = float(case["passage_hours"]) + + t0 = _time.time() + + if vectorfield is not None: + if windfield is None: + import warnings + + warnings.warn( + "vectorfield provided without windfield; defaulting " + "windfield to vectorfield for RISE energy cost.", + stacklevel=2, + ) + windfield = vectorfield + # Lazy import to avoid circular dependency / heavy JAX load + from routetools.cmaes import optimize as cmaes_optimize + from routetools.cost import cost_function_rise + + # Initialise from the great-circle route so CMA-ES starts near + # the geodesic. + gc_init = great_circle_route(src, dst, n_points=n_points) + # great_circle_route may unwrap longitude through the + # antimeridian (e.g. -121° becomes 239°). Use the unwrapped + # endpoints so the CMA-ES endpoint check passes and the Bézier + # curve stays in a consistent longitude range. + src_opt = jnp.array([gc_init[0, 0], gc_init[0, 1]]) + dst_opt = jnp.array([gc_init[-1, 0], gc_init[-1, 1]]) + + # Build a RISE-based cost closure for CMA-ES. + # This directly minimises SWOPP3 energy (MWh) instead of the + # ocean-current proxy ‖SOG − wind‖². + _wps = case["wps"] + + def _rise_cost(curve_batch: jnp.ndarray) -> jnp.ndarray: + return cost_function_rise( + windfield=windfield, + curve=curve_batch, + travel_time=travel_time, + wavefield=wavefield, + wps=_wps, + time_offset=departure_offset_h, + ) + + defaults = dict( + K=10, + L=n_points, + travel_time=travel_time, + curve0=gc_init, + sigma0=0.1, + cost_fn=_rise_cost, + penalty=1000, + land_margin=2, + verbose=False, + time_offset=departure_offset_h, + windfield=windfield, + wavefield=wavefield, + ) + if cmaes_kwargs.pop("cmaes_verbose", False): + cmaes_kwargs["verbose"] = True + defaults.update(cmaes_kwargs) + + curve, info = cmaes_optimize( + vectorfield=vectorfield, + src=src_opt, + dst=dst_opt, + land=land, + **defaults, + ) + else: + # No vectorfield → raise error + raise ValueError( + "Optimised departure requires a vectorfield for CMA-ES. " + "Provide a vectorfield or use run_gc_departure for a great-circle route.", + ) + + distance_nm = sailed_distance_nm(curve) + + energy_mwh, max_tws, max_hs = evaluate_energy( + curve, + departure, + case["passage_hours"], + wps=case["wps"], + windfield=windfield, + wavefield=wavefield, + departure_offset_h=departure_offset_h, + ) + comp_time = _time.time() - t0 + + return DepartureResult( + departure=departure, + curve=curve, + energy_mwh=energy_mwh, + max_tws_mps=max_tws, + max_hs_m=max_hs, + distance_nm=distance_nm, + comp_time_s=comp_time, + ) + + +# --------------------------------------------------------------------------- +# Case runner +# --------------------------------------------------------------------------- +def run_case( + case_id: str, + departures: list[datetime], + vectorfield: FieldClosure | None = None, + windfield: FieldClosure | None = None, + wavefield: FieldClosure | None = None, + land=None, + output_dir: str | Path | None = None, + submission: int = 1, + n_points: int = 100, + verbose: bool = True, + dataset_epoch: datetime | None = None, + **cmaes_kwargs, +) -> list[DepartureResult]: + """Run all departures for a single SWOPP3 case. + + Dispatches to :func:`run_gc_departure` or :func:`run_optimised_departure` + depending on the case strategy. When *output_dir* is provided, writes + File A and File B CSVs. + + Parameters + ---------- + case_id : str + SWOPP3 case identifier (e.g. ``"AGC_WPS"``). + departures : list[datetime] + List of departure times. + vectorfield : FieldClosure, optional + Vector field for CMA-ES optimisation (optimised cases only). + windfield, wavefield : FieldClosure, optional + Pre-loaded closures for energy evaluation. + land : Land, optional + Land mask for penalisation. + output_dir : str or Path, optional + If provided, writes output CSVs to this directory. + submission : int + Submission number for file naming. + n_points : int + Number of route waypoints. + verbose : bool + Print progress. + dataset_epoch : datetime, optional + First timestamp of the loaded ERA5 dataset (UTC). When provided, + the departure-to-field time offset is computed automatically for + each departure. If ``None``, offset = 0 (suitable only when each + departure loads its own field with ``departure_time``). + **cmaes_kwargs + Extra arguments for CMA-ES (optimised cases only). Optimised cases + require ``vectorfield`` to be provided. + + Returns + ------- + list[DepartureResult] + One result per departure. + """ + case = SWOPP3_CASES[case_id] + casename = case["name"] + is_gc = case["strategy"] == "gc" + results: list[DepartureResult] = [] + + if verbose: + log_run_parameters( + case_id, + n_departures=len(departures), + n_points=n_points, + **{k: v for k, v in cmaes_kwargs.items() if v is not None and v != 0}, + ) + + for i, dep in enumerate(departures): + if verbose: + print( + f"[{casename}] Departure {i + 1}/{len(departures)} " + f"{dep.strftime('%Y-%m-%d')}", + end=" ", + flush=True, + ) + + # Compute time offset for this departure relative to field origin. + # When fields are loaded once for the whole year (without a per- + # departure reload), dataset_epoch tells us where t=0 lives. + if dataset_epoch is not None: + dep_naive = dep.replace(tzinfo=None) if dep.tzinfo else dep + epoch_naive = ( + dataset_epoch.replace(tzinfo=None) + if hasattr(dataset_epoch, "tzinfo") and dataset_epoch.tzinfo + else dataset_epoch + ) + departure_offset_h = (dep_naive - epoch_naive).total_seconds() / 3600.0 + else: + departure_offset_h = 0.0 + + if is_gc: + result = run_gc_departure( + case_id, + dep, + windfield=windfield, + wavefield=wavefield, + departure_offset_h=departure_offset_h, + n_points=n_points, + ) + else: + result = run_optimised_departure( + case_id, + dep, + vectorfield=vectorfield, + windfield=windfield, + wavefield=wavefield, + land=land, + departure_offset_h=departure_offset_h, + n_points=n_points, + **cmaes_kwargs, + ) + + results.append(result) + if verbose: + # Flag constraint violations + tws_flag = " [TWS!]" if result.max_tws_mps > 20.0 else "" + hs_flag = " [Hs!]" if result.max_hs_m > 7.0 else "" + print( + f"E={result.energy_mwh:.2f} MWh " + f"d={result.distance_nm:.0f} nm " + f"TWS={result.max_tws_mps:.1f}{tws_flag} " + f"Hs={result.max_hs_m:.1f}{hs_flag} " + f"t={result.comp_time_s:.1f}s" + ) + + # ---- Write outputs ---- + if output_dir is not None: + output_dir = Path(output_dir) + _write_case_outputs( + case_id, + results, + output_dir, + submission=submission, + ) + + return results + + +# --------------------------------------------------------------------------- +# Output writing +# --------------------------------------------------------------------------- +def _write_case_outputs( + case_id: str, + results: list[DepartureResult], + output_dir: Path, + submission: int = 1, +) -> None: + """Write File A and File B CSVs for a completed case.""" + case = SWOPP3_CASES[case_id] + casename = case["name"] + passage_hours = case["passage_hours"] + + file_b_dir = output_dir / "tracks" + output_dir.mkdir(parents=True, exist_ok=True) + file_b_dir.mkdir(parents=True, exist_ok=True) + rows = [] + + for res in results: + # File B + fb_name = file_b_name(submission, casename, res.departure) + times = waypoint_times(res.curve, res.departure, passage_hours) + write_file_b(res.curve, times, file_b_dir / fb_name) + + # File A row + rows.append( + file_a_row( + departure=res.departure, + passage_hours=passage_hours, + energy_mwh=res.energy_mwh, + max_wind_mps=res.max_tws_mps, + max_hs_m=res.max_hs_m, + distance_nm=res.distance_nm, + details_filename=fb_name, + ) + ) + + # File A + fa_path = output_dir / file_a_name(submission, casename) + write_file_a(rows, fa_path) diff --git a/routetools/swopp3_validate.py b/routetools/swopp3_validate.py new file mode 100644 index 00000000..6fe3483b --- /dev/null +++ b/routetools/swopp3_validate.py @@ -0,0 +1,466 @@ +"""SWOPP3 output validation — verify File A, File B and submission compliance. + +This module provides helpers to validate SWOPP3 output files and submission +directories. The functions check column presence and formats, file naming, +timestamp ordering in tracks, and simple cross-case energy sanity rules. + +Example +------- +>>> from routetools.swopp3_validate import validate_file_a, validate_file_b +>>> errors = validate_file_a("output/swopp3/IEUniversity-1-AGC_WPS.csv") +>>> assert not errors, errors +""" + +from __future__ import annotations + +import csv +import re +from datetime import datetime +from pathlib import Path + +__all__ = [ + "validate_file_a", + "validate_file_b", + "validate_case_pair_wps", + "validate_case_pair_strategy", + "validate_submission_dir", + "ValidationError", +] + +_DTFMT = "%Y-%m-%d %H:%M:%S" +_TEAM = "IEUniversity" + +_FILE_A_COLUMNS = [ + "departure_time_utc", + "arrival_time_utc", + "energy_cons_mwh", + "max_wind_mps", + "max_hs_m", + "sailed_distance_nm", + "details_filename", +] + +_FILE_B_COLUMNS = ["time_utc", "lat_deg", "lon_deg"] + + +class ValidationError: + """A single validation issue.""" + + def __init__(self, file: str, row: int | None, message: str): + self.file = file + self.row = row + self.message = message + + def __repr__(self) -> str: + """Return a readable representation for debugging output.""" + loc = f"row {self.row}" if self.row is not None else "file" + return f"ValidationError({self.file}, {loc}: {self.message})" + + +# --------------------------------------------------------------------------- +# File A validation +# --------------------------------------------------------------------------- +def validate_file_a( + path: str | Path, + expected_rows: int = 366, +) -> list[ValidationError]: + """Validate a File A CSV. + + Parameters + ---------- + path : str or Path + Path to the File A CSV. + expected_rows : int + Expected number of data rows (default 366). + + Returns + ------- + list[ValidationError] + Empty list if valid. + """ + path = Path(path) + errors: list[ValidationError] = [] + fname = path.name + + # ---- Naming convention ---- + pattern = re.compile(rf"^{_TEAM}-\d+-\w+\.csv$") + if not pattern.match(fname): + errors.append( + ValidationError(fname, None, f"Name '{fname}' doesn't match pattern") + ) + + if not path.exists(): + errors.append(ValidationError(fname, None, "File not found")) + return errors + + with path.open() as f: + reader = csv.DictReader(f) + + # ---- Columns ---- + if reader.fieldnames is None: + errors.append(ValidationError(fname, None, "No header row")) + return errors + + missing = set(_FILE_A_COLUMNS) - set(reader.fieldnames) + extra = set(reader.fieldnames) - set(_FILE_A_COLUMNS) + if missing: + errors.append(ValidationError(fname, None, f"Missing columns: {missing}")) + if extra: + errors.append(ValidationError(fname, None, f"Extra columns: {extra}")) + + rows = list(reader) + + # ---- Row count ---- + if len(rows) != expected_rows: + errors.append( + ValidationError( + fname, None, f"Expected {expected_rows} rows, got {len(rows)}" + ) + ) + + for i, row in enumerate(rows, 1): + # ---- No empty values ---- + for col in _FILE_A_COLUMNS: + val = row.get(col, "") + if not val.strip(): + errors.append(ValidationError(fname, i, f"Empty value in '{col}'")) + + # ---- Datetime format ---- + for col in ("departure_time_utc", "arrival_time_utc"): + try: + datetime.strptime(row[col], _DTFMT) + except (ValueError, KeyError): + errors.append( + ValidationError( + fname, i, f"Bad datetime in '{col}': {row.get(col)}" + ) + ) + + # ---- Numeric fields ---- + for col in ( + "energy_cons_mwh", + "max_wind_mps", + "max_hs_m", + "sailed_distance_nm", + ): + try: + v = float(row[col]) + if v != v: # NaN check + errors.append(ValidationError(fname, i, f"NaN in '{col}'")) + if v < 0: + errors.append( + ValidationError(fname, i, f"Negative value in '{col}': {v}") + ) + except (ValueError, KeyError): + errors.append( + ValidationError(fname, i, f"Non-numeric '{col}': {row.get(col)}") + ) + + # ---- Passage time vs arrival - departure ---- + try: + dep = datetime.strptime(row["departure_time_utc"], _DTFMT) + arr = datetime.strptime(row["arrival_time_utc"], _DTFMT) + passage_h = (arr - dep).total_seconds() / 3600 + if passage_h <= 0: + errors.append(ValidationError(fname, i, "Arrival before departure")) + except (ValueError, KeyError): + pass # already reported above + + # ---- details_filename ---- + details = row.get("details_filename", "") + if details and not details.endswith(".csv"): + errors.append( + ValidationError(fname, i, f"details_filename not .csv: {details}") + ) + + return errors + + +# --------------------------------------------------------------------------- +# File B validation +# --------------------------------------------------------------------------- +def validate_file_b( + path: str | Path, + min_waypoints: int = 2, +) -> list[ValidationError]: + """Validate a File B (track) CSV. + + Parameters + ---------- + path : str or Path + Path to the File B CSV. + min_waypoints : int + Minimum number of waypoints (default 2). + + Returns + ------- + list[ValidationError] + Empty list if valid. + """ + path = Path(path) + errors: list[ValidationError] = [] + fname = path.name + + if not path.exists(): + errors.append(ValidationError(fname, None, "File not found")) + return errors + + with path.open() as f: + reader = csv.DictReader(f) + + if reader.fieldnames is None: + errors.append(ValidationError(fname, None, "No header row")) + return errors + + missing = set(_FILE_B_COLUMNS) - set(reader.fieldnames) + if missing: + errors.append(ValidationError(fname, None, f"Missing columns: {missing}")) + + rows = list(reader) + + if len(rows) < min_waypoints: + errors.append( + ValidationError( + fname, None, f"Only {len(rows)} waypoints (min {min_waypoints})" + ) + ) + + prev_time = None + for i, row in enumerate(rows, 1): + # ---- Datetime ---- + try: + t = datetime.strptime(row["time_utc"], _DTFMT) + if prev_time is not None and t <= prev_time: + errors.append( + ValidationError(fname, i, "Timestamps not strictly increasing") + ) + prev_time = t + except (ValueError, KeyError): + errors.append( + ValidationError(fname, i, f"Bad time_utc: {row.get('time_utc')}") + ) + + # ---- Coordinates ---- + for col, lo, hi in [("lat_deg", -90, 90), ("lon_deg", -360, 360)]: + try: + v = float(row[col]) + if v != v: + errors.append(ValidationError(fname, i, f"NaN in '{col}'")) + elif v < lo or v > hi: + errors.append( + ValidationError( + fname, i, f"'{col}'={v} out of range [{lo},{hi}]" + ) + ) + except (ValueError, KeyError): + errors.append( + ValidationError(fname, i, f"Non-numeric '{col}': {row.get(col)}") + ) + + return errors + + +# --------------------------------------------------------------------------- +# Cross-case comparisons +# --------------------------------------------------------------------------- +def _load_energies(path: Path) -> list[float]: + """Load energy_cons_mwh column from a File A CSV. + + Raises + ------ + FileNotFoundError + If *path* does not exist. + KeyError + If the ``energy_cons_mwh`` column is missing. + ValueError + If a cell value is not numeric. + """ + with path.open() as f: + reader = csv.DictReader(f) + return [float(row["energy_cons_mwh"]) for row in reader] + + +def validate_case_pair_wps( + wps_path: str | Path, + nowps_path: str | Path, +) -> list[ValidationError]: + """Check that WPS energy ≤ noWPS energy for each departure. + + Returns + ------- + list[ValidationError] + One error per departure where WPS > noWPS. + """ + errors: list[ValidationError] = [] + try: + wps_e = _load_energies(Path(wps_path)) + nowps_e = _load_energies(Path(nowps_path)) + except (FileNotFoundError, KeyError, ValueError) as exc: + errors.append( + ValidationError(str(wps_path), None, f"Cannot load energies: {exc}") + ) + return errors + n = min(len(wps_e), len(nowps_e)) + violations = 0 + for i in range(n): + if wps_e[i] > nowps_e[i] + 1e-6: + violations += 1 + if violations: + errors.append( + ValidationError( + f"{Path(wps_path).name} vs {Path(nowps_path).name}", + None, + f"WPS energy > noWPS in {violations}/{n} departures", + ) + ) + return errors + + +def validate_case_pair_strategy( + opt_path: str | Path, + gc_path: str | Path, +) -> list[ValidationError]: + """Check that optimised energy ≤ GC energy for most departures. + + We allow up to 10% of departures where optimised is worse (stochastic + optimisation may not always beat the baseline). + + Returns + ------- + list[ValidationError] + Error if too many departures show optimised > GC. + """ + errors: list[ValidationError] = [] + try: + opt_e = _load_energies(Path(opt_path)) + gc_e = _load_energies(Path(gc_path)) + except (FileNotFoundError, KeyError, ValueError) as exc: + errors.append( + ValidationError(str(opt_path), None, f"Cannot load energies: {exc}") + ) + return errors + n = min(len(opt_e), len(gc_e)) + worse = sum(1 for i in range(n) if opt_e[i] > gc_e[i] + 1e-6) + pct = worse / n * 100 if n else 0 + if pct > 10: + errors.append( + ValidationError( + f"{Path(opt_path).name} vs {Path(gc_path).name}", + None, + f"Optimised worse than GC in {worse}/{n} ({pct:.1f}%) departures", + ) + ) + return errors + + +# --------------------------------------------------------------------------- +# Full submission directory validation +# --------------------------------------------------------------------------- +def validate_submission_dir( + output_dir: str | Path, + submission: int = 1, + expected_departures: int = 366, + verbose: bool = True, +) -> list[ValidationError]: + """Validate all files in a submission directory. + + Parameters + ---------- + output_dir : str or Path + Directory containing File A CSVs and ``tracks/`` subdirectory. + submission : int + Submission number. + expected_departures : int + Expected departures per case. + verbose : bool + Print progress. + + Returns + ------- + list[ValidationError] + All errors found. + """ + output_dir = Path(output_dir) + errors: list[ValidationError] = [] + + case_names = [ + "AO_WPS", + "AO_noWPS", + "AGC_WPS", + "AGC_noWPS", + "PO_WPS", + "PO_noWPS", + "PGC_WPS", + "PGC_noWPS", + ] + + # ---- File A ---- + for cname in case_names: + fa = output_dir / f"{_TEAM}-{submission}-{cname}.csv" + if verbose: + print(f"Validating File A: {fa.name} ... ", end="") + errs = validate_file_a(fa, expected_rows=expected_departures) + errors.extend(errs) + if verbose: + print(f"{'FAIL' if errs else 'OK'} ({len(errs)} issues)") + + # ---- File B for each departure ---- + if fa.exists(): + with fa.open() as f: + reader = csv.DictReader(f) + for _row_i, row in enumerate(reader, 1): + fb_name = row.get("details_filename", "") + if fb_name: + fb_path = output_dir / "tracks" / fb_name + fb_errs = validate_file_b(fb_path) + errors.extend(fb_errs) + + # ---- WPS vs noWPS comparisons ---- + pairs_wps = [ + ("AO_WPS", "AO_noWPS"), + ("AGC_WPS", "AGC_noWPS"), + ("PO_WPS", "PO_noWPS"), + ("PGC_WPS", "PGC_noWPS"), + ] + for wps_case, nowps_case in pairs_wps: + wps_fa = output_dir / f"{_TEAM}-{submission}-{wps_case}.csv" + nowps_fa = output_dir / f"{_TEAM}-{submission}-{nowps_case}.csv" + if wps_fa.exists() and nowps_fa.exists(): + if verbose: + print(f"Comparing {wps_case} vs {nowps_case} ... ", end="") + errs = validate_case_pair_wps(wps_fa, nowps_fa) + errors.extend(errs) + if verbose: + print(f"{'FAIL' if errs else 'OK'}") + + # ---- Optimised vs GC comparisons ---- + pairs_strategy = [ + ("AO_WPS", "AGC_WPS"), + ("AO_noWPS", "AGC_noWPS"), + ("PO_WPS", "PGC_WPS"), + ("PO_noWPS", "PGC_noWPS"), + ] + for opt_case, gc_case in pairs_strategy: + opt_fa = output_dir / f"{_TEAM}-{submission}-{opt_case}.csv" + gc_fa = output_dir / f"{_TEAM}-{submission}-{gc_case}.csv" + if opt_fa.exists() and gc_fa.exists(): + if verbose: + print(f"Comparing {opt_case} vs {gc_case} ... ", end="") + errs = validate_case_pair_strategy(opt_fa, gc_fa) + errors.extend(errs) + if verbose: + print(f"{'FAIL' if errs else 'OK'}") + + # ---- Summary ---- + if verbose: + print(f"\n{'=' * 50}") + if errors: + print(f"VALIDATION FAILED: {len(errors)} issue(s)") + for e in errors[:20]: + print(f" {e}") + if len(errors) > 20: + print(f" ... and {len(errors) - 20} more") + else: + print("VALIDATION PASSED: all checks OK") + + return errors diff --git a/routetools/violations.py b/routetools/violations.py new file mode 100644 index 00000000..095bac45 --- /dev/null +++ b/routetools/violations.py @@ -0,0 +1,611 @@ +"""Count Codabench-style route violations for SWOPP3 output folders. + +This module reproduces the user-facing violation counting convention used for +local analysis of SWOPP3 outputs. + +By default, great-circle (GC) cases are excluded from the report entirely. +Set ``include_gc=True`` to include them: + +- wind violations: number of File A rows with ``max_wind_mps > 20`` +- wave violations: number of File A rows with ``max_hs_m > 7`` +- land violations: number of sampled File B waypoints on land, using the same + waypoint subsampling rule as the Codabench scorer + (``step = max(1, len(waypoints) // 50)``) + +The resulting totals match the expected 2087 count for the +``output/cmaes_weather`` folder in this repository. + +When ERA5 resources are supplied, the same pass can also accumulate the smooth +wind and wave penalties used by the optimisation code. That keeps the local +report aligned with both the threshold-based Codabench counts and the softer +objective-level penalty signal. +""" + +from __future__ import annotations + +import csv +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Protocol + +import jax.numpy as jnp + +from routetools.era5.loader import ( + load_dataset_epoch, + load_era5_wavefield, + load_era5_windfield, + loadable_era5_paths, +) +from routetools.swopp3 import SWOPP3_CASES +from routetools.weather import ( + DEFAULT_HS_LIMIT, + DEFAULT_TWS_LIMIT, + wave_penalty_smooth, + wind_penalty_smooth, +) + +_DTFMT = "%Y-%m-%d %H:%M:%S" + +CASE_ORDER = [ + "AO_WPS", + "AO_noWPS", + "AGC_WPS", + "AGC_noWPS", + "PO_WPS", + "PO_noWPS", + "PGC_WPS", + "PGC_noWPS", +] + + +class LandChecker(Protocol): + """Callable protocol for land checks.""" + + def __call__(self, lat: float, lon: float) -> bool: # noqa: D102 + ... + + +@dataclass(frozen=True) +class ScenarioViolationCounts: + """Violation counts for one SWOPP3 scenario. + + Parameters + ---------- + folder : str + Name of the analysed output folder. + case_id : str + SWOPP3 case identifier. + wind_violations : int + Number of File A rows exceeding the wind threshold. + wave_violations : int + Number of File A rows exceeding the wave threshold. + land_violations : int + Number of sampled File B waypoints on land. + """ + + folder: str + case_id: str + wind_violations: int + wave_violations: int + land_violations: int + wind_penalty: float = 0.0 + wave_penalty: float = 0.0 + + @property + def total_violations(self) -> int: + """Return the total violations for this scenario.""" + return self.wind_violations + self.wave_violations + self.land_violations + + @property + def total_penalty(self) -> float: + """Return the total weather penalty for this scenario.""" + return self.wind_penalty + self.wave_penalty + + +@dataclass(frozen=True) +class CorridorWeatherResources: + """Loaded weather resources for one corridor.""" + + dataset_epoch: datetime + windfield: object + wavefield: object + + +def is_gc_case(case_id: str) -> bool: + """Return whether a SWOPP3 case is a great-circle case.""" + return str(SWOPP3_CASES[case_id]["strategy"]) == "gc" + + +def find_team_prefix(input_dir: Path) -> str: + """Detect the team prefix from submitted File A CSVs. + + Parameters + ---------- + input_dir : Path + SWOPP3 output directory containing File A CSV files. + + Returns + ------- + str + Team prefix including the submission id, for example ``IEUniversity-1``. + + Raises + ------ + FileNotFoundError + If no SWOPP3 File A CSV files are found. + """ + for path in sorted(input_dir.glob("*.csv")): + for case_id in CASE_ORDER: + suffix = f"-{case_id}.csv" + if path.name.endswith(suffix): + return path.name[: -len(suffix)] + raise FileNotFoundError(f"No SWOPP3 File A CSV files found in {input_dir}") + + +def load_default_land_checker(shapefile_path: Path | None = None) -> LandChecker: + """Load the Natural Earth 10m land checker used for local validation. + + Parameters + ---------- + shapefile_path : Path, optional + Explicit path to a Natural Earth land shapefile. When omitted, the + default Cartopy-managed ``ne_10m_land.shp`` is used. + + Returns + ------- + LandChecker + Callable ``(lat, lon) -> bool``. + + Notes + ----- + This helper imports ``cartopy`` and the shapefile stack lazily at runtime. + Local analysis environments therefore need the geospatial dependencies + required by ``cartopy`` and ``shapely`` when this checker is used. + """ + if shapefile_path is None: + import cartopy.io.shapereader as shpreader + + shapefile_path = Path( + shpreader.natural_earth( + resolution="10m", + category="physical", + name="land", + ) + ) + + import shapefile as shp + from shapely.geometry import Point, shape + from shapely.ops import unary_union + + reader = shp.Reader(str(shapefile_path)) + land_union = unary_union([shape(record) for record in reader.shapes()]) + + def is_on_land(lat: float, lon: float) -> bool: + return bool(land_union.contains(Point(lon, lat))) + + return is_on_land + + +def load_default_weather_resources( + *, + wind_path_atlantic: Path = Path("data/era5/era5_wind_atlantic_2024.nc"), + wave_path_atlantic: Path = Path("data/era5/era5_waves_atlantic_2024.nc"), + wind_path_pacific: Path = Path("data/era5/era5_wind_pacific_2024.nc"), + wave_path_pacific: Path = Path("data/era5/era5_waves_pacific_2024.nc"), +) -> dict[str, CorridorWeatherResources]: + """Load default ERA5 weather resources for both SWOPP3 corridors. + + Each input may resolve to a single annual file or to an annual file plus a + next-year continuation file when the dataset has been split on disk. + """ + path_map = { + "atlantic": (wind_path_atlantic, wave_path_atlantic), + "pacific": (wind_path_pacific, wave_path_pacific), + } + resources: dict[str, CorridorWeatherResources] = {} + for corridor, (wind_path, wave_path) in path_map.items(): + wind_paths = loadable_era5_paths(wind_path) + wave_paths = loadable_era5_paths(wave_path) + wind_target = wind_paths if len(wind_paths) > 1 else wind_paths[0] + wave_target = wave_paths if len(wave_paths) > 1 else wave_paths[0] + resources[corridor] = CorridorWeatherResources( + dataset_epoch=load_dataset_epoch(wind_target), + windfield=load_era5_windfield(wind_target), + wavefield=load_era5_wavefield(wave_target), + ) + return resources + + +def read_track_curve(track_path: Path) -> jnp.ndarray: + """Read a track CSV into a ``(L, 2)`` ``(lon, lat)`` array.""" + lons: list[float] = [] + lats: list[float] = [] + with track_path.open(newline="") as handle: + reader = csv.DictReader(handle) + for row in reader: + lats.append(float(row["lat_deg"])) + lons.append(float(row["lon_deg"])) + return jnp.stack( + [jnp.asarray(lons, dtype=jnp.float32), jnp.asarray(lats, dtype=jnp.float32)], + axis=1, + ) + + +def departure_offset_hours(departure: datetime, dataset_epoch: datetime) -> float: + """Return departure offset in hours relative to the dataset epoch.""" + departure_naive = departure.replace(tzinfo=None) if departure.tzinfo else departure + epoch_naive = ( + dataset_epoch.replace(tzinfo=None) + if getattr(dataset_epoch, "tzinfo", None) + else dataset_epoch + ) + return (departure_naive - epoch_naive).total_seconds() / 3600.0 + + +def count_land_violations(track_path: Path, land_checker: LandChecker) -> int: + """Count sampled File B waypoints on land. + + Uses the same subsampling rule as the Codabench scorer: + ``step = max(1, len(waypoints) // 50)`` and then checks ``waypoints[::step]``. + + Parameters + ---------- + track_path : Path + Track CSV path. + land_checker : LandChecker + Callable ``(lat, lon) -> bool``. + + Returns + ------- + int + Number of sampled waypoints on land. + """ + with track_path.open(newline="") as handle: + waypoints = list(csv.DictReader(handle)) + + step = max(1, len(waypoints) // 50) + violations = 0 + for waypoint in waypoints[::step]: + lat = float(waypoint["lat_deg"]) + lon = float(waypoint["lon_deg"]) + violations += int(land_checker(lat, lon)) + return violations + + +def count_summary_weather_violations( + summary_path: Path, +) -> tuple[int, int]: + """Count wind and wave threshold violations from one File A CSV. + + Parameters + ---------- + summary_path : Path + File A summary CSV. + + Returns + ------- + tuple[int, int] + ``(wind_violations, wave_violations)``. + """ + wind_violations = 0 + wave_violations = 0 + with summary_path.open(newline="") as handle: + reader = csv.DictReader(handle) + for row in reader: + wind_violations += int(float(row["max_wind_mps"]) > DEFAULT_TWS_LIMIT) + wave_violations += int(float(row["max_hs_m"]) > DEFAULT_HS_LIMIT) + return wind_violations, wave_violations + + +def count_folder_violations( + input_dir: Path, + *, + land_checker: LandChecker, + weather_resources: dict[str, CorridorWeatherResources] | None = None, + wind_penalty_weight: float = 1000.0, + wave_penalty_weight: float = 1000.0, + include_gc: bool = False, +) -> list[ScenarioViolationCounts]: + """Count violations for every SWOPP3 scenario in an output folder. + + Parameters + ---------- + input_dir : Path + SWOPP3 output directory. + land_checker : LandChecker + Callable ``(lat, lon) -> bool``. + weather_resources : dict[str, CorridorWeatherResources], optional + Preloaded ERA5 resources keyed by corridor. When provided, smooth wind + and wave penalties are accumulated alongside the threshold counts. + include_gc : bool, optional + When ``False`` (default), omit great-circle cases from the report. + + Returns + ------- + list[ScenarioViolationCounts] + One row per scenario in standard SWOPP3 order. + """ + input_dir = Path(input_dir) + team_prefix = find_team_prefix(input_dir) + tracks_dir = input_dir / "tracks" + rows: list[ScenarioViolationCounts] = [] + + for case_id in CASE_ORDER: + if not include_gc and is_gc_case(case_id): + continue + + summary_path = input_dir / f"{team_prefix}-{case_id}.csv" + if not summary_path.exists(): + continue + + case = SWOPP3_CASES[case_id] + corridor = str(case["route"]) + passage_hours = float(case["passage_hours"]) + + wind_violations, wave_violations = count_summary_weather_violations( + summary_path, + ) + + land_violations = 0 + wind_penalty = 0.0 + wave_penalty = 0.0 + with summary_path.open(newline="") as handle: + reader = csv.DictReader(handle) + for row in reader: + track_path = tracks_dir / row["details_filename"] + land_violations += count_land_violations(track_path, land_checker) + + if weather_resources is not None: + # Reuse the same track sample for the soft penalties so the + # weather totals line up with the voyage-level threshold counts. + departure = datetime.strptime(row["departure_time_utc"], _DTFMT) + curve = read_track_curve(track_path)[None, ...] + resources = weather_resources[corridor] + time_offset = departure_offset_hours( + departure, + resources.dataset_epoch, + ) + # travel_time is in hours (passage_hours), matching + # time_offset which is hours since the dataset epoch. + # The wind_penalty_smooth/wave_penalty_smooth docstrings + # say "seconds" but the implementation only requires + # consistent units with time_offset; hours is correct here. + wind_penalty += float( + wind_penalty_smooth( + curve, + resources.windfield, + weight=wind_penalty_weight, + travel_time=passage_hours, + time_offset=time_offset, + )[0] + ) + wave_penalty += float( + wave_penalty_smooth( + curve, + resources.wavefield, + weight=wave_penalty_weight, + travel_time=passage_hours, + time_offset=time_offset, + )[0] + ) + + rows.append( + ScenarioViolationCounts( + folder=input_dir.name, + case_id=case_id, + wind_violations=wind_violations, + wave_violations=wave_violations, + land_violations=land_violations, + wind_penalty=wind_penalty, + wave_penalty=wave_penalty, + ) + ) + + return rows + + +def format_violation_table(rows: list[ScenarioViolationCounts]) -> str: + """Format scenario counts and penalties as a plain-text table. + + Parameters + ---------- + rows : list[ScenarioViolationCounts] + Scenario rows to render. + + Returns + ------- + str + Human-readable table including a total row. + """ + header = ( + f"{'Folder':<18} {'Case':<10} {'Wind':>6} {'Wave':>6} {'Land':>6} {'Total':>6} " + f"{'Wind Pen':>12} {'Wave Pen':>12} {'Total Pen':>12}" + ) + lines = [header, "-" * len(header)] + + total_wind = 0 + total_wave = 0 + total_land = 0 + total = 0 + total_wind_penalty = 0.0 + total_wave_penalty = 0.0 + total_penalty = 0.0 + for row in rows: + lines.append( + f"{row.folder:<18} {row.case_id:<10} {row.wind_violations:>6} " + f"{row.wave_violations:>6} {row.land_violations:>6} " + f"{row.total_violations:>6} {row.wind_penalty:>12.3f} " + f"{row.wave_penalty:>12.3f} {row.total_penalty:>12.3f}" + ) + total_wind += row.wind_violations + total_wave += row.wave_violations + total_land += row.land_violations + total += row.total_violations + total_wind_penalty += row.wind_penalty + total_wave_penalty += row.wave_penalty + total_penalty += row.total_penalty + + lines.append("-" * len(header)) + lines.append( + f"{'TOTAL':<18} {'':<10} {total_wind:>6} {total_wave:>6} " + f"{total_land:>6} {total:>6} {total_wind_penalty:>12.3f} " + f"{total_wave_penalty:>12.3f} {total_penalty:>12.3f}" + ) + return "\n".join(lines) + + +def format_grouped_violation_table(rows: list[ScenarioViolationCounts]) -> str: + """Format rows grouped by case with folders stacked together.""" + folder_order = sorted({row.folder for row in rows}) + row_map = {(row.case_id, row.folder): row for row in rows} + header = ( + f"{'Folder':<18} {'Case':<10} {'Wind':>6} {'Wave':>6} {'Land':>6} {'Total':>6} " + f"{'Wind Pen':>12} {'Wave Pen':>12} {'Total Pen':>12}" + ) + lines = [header, "-" * len(header)] + + for case_id in CASE_ORDER: + printed = False + for folder in folder_order: + row = row_map.get((case_id, folder)) + if row is None: + continue + lines.append( + f"{row.folder:<18} {row.case_id:<10} {row.wind_violations:>6} " + f"{row.wave_violations:>6} {row.land_violations:>6} " + f"{row.total_violations:>6} {row.wind_penalty:>12.3f} " + f"{row.wave_penalty:>12.3f} {row.total_penalty:>12.3f}" + ) + printed = True + if printed: + lines.append("-" * len(header)) + + # Append one total row per folder so side-by-side experiment comparisons can + # be copied directly into spreadsheets or review comments. + totals_by_folder: dict[str, ScenarioViolationCounts] = {} + for folder in folder_order: + folder_rows = [row for row in rows if row.folder == folder] + totals_by_folder[folder] = ScenarioViolationCounts( + folder=folder, + case_id="TOTAL", + wind_violations=sum(row.wind_violations for row in folder_rows), + wave_violations=sum(row.wave_violations for row in folder_rows), + land_violations=sum(row.land_violations for row in folder_rows), + wind_penalty=sum(row.wind_penalty for row in folder_rows), + wave_penalty=sum(row.wave_penalty for row in folder_rows), + ) + + for folder in folder_order: + row = totals_by_folder[folder] + lines.append( + f"{row.folder:<18} {row.case_id:<10} {row.wind_violations:>6} " + f"{row.wave_violations:>6} {row.land_violations:>6} " + f"{row.total_violations:>6} {row.wind_penalty:>12.3f} " + f"{row.wave_penalty:>12.3f} {row.total_penalty:>12.3f}" + ) + return "\n".join(lines) + + +def grouped_violation_rows( + rows: list[ScenarioViolationCounts], +) -> list[dict[str, str | int | float]]: + """Return grouped rows ready to be written as CSV. + + Parameters + ---------- + rows : list[ScenarioViolationCounts] + Scenario rows to export. + + Returns + ------- + list[dict[str, str | int | float]] + Export rows in grouped case order with per-folder totals appended. + """ + folder_order = sorted({row.folder for row in rows}) + row_map = {(row.case_id, row.folder): row for row in rows} + export_rows: list[dict[str, str | int | float]] = [] + + for case_id in CASE_ORDER: + for folder in folder_order: + row = row_map.get((case_id, folder)) + if row is None: + continue + export_rows.append( + { + "folder": row.folder, + "case_id": row.case_id, + "wind_violations": row.wind_violations, + "wave_violations": row.wave_violations, + "land_violations": row.land_violations, + "total_violations": row.total_violations, + "wind_penalty": row.wind_penalty, + "wave_penalty": row.wave_penalty, + "total_penalty": row.total_penalty, + } + ) + + for folder in folder_order: + folder_rows = [row for row in rows if row.folder == folder] + total_row = ScenarioViolationCounts( + folder=folder, + case_id="TOTAL", + wind_violations=sum(row.wind_violations for row in folder_rows), + wave_violations=sum(row.wave_violations for row in folder_rows), + land_violations=sum(row.land_violations for row in folder_rows), + wind_penalty=sum(row.wind_penalty for row in folder_rows), + wave_penalty=sum(row.wave_penalty for row in folder_rows), + ) + export_rows.append( + { + "folder": total_row.folder, + "case_id": total_row.case_id, + "wind_violations": total_row.wind_violations, + "wave_violations": total_row.wave_violations, + "land_violations": total_row.land_violations, + "total_violations": total_row.total_violations, + "wind_penalty": total_row.wind_penalty, + "wave_penalty": total_row.wave_penalty, + "total_penalty": total_row.total_penalty, + } + ) + + return export_rows + + +def write_grouped_violation_csv( + rows: list[ScenarioViolationCounts], + output_path: Path, +) -> Path: + """Write grouped violation rows to CSV. + + Parameters + ---------- + rows : list[ScenarioViolationCounts] + Scenario rows to export. + output_path : Path + CSV destination. + + Returns + ------- + Path + Written CSV path. + """ + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + fieldnames = [ + "folder", + "case_id", + "wind_violations", + "wave_violations", + "land_violations", + "total_violations", + "wind_penalty", + "wave_penalty", + "total_penalty", + ] + with output_path.open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(grouped_violation_rows(rows)) + return output_path diff --git a/routetools/weather.py b/routetools/weather.py new file mode 100644 index 00000000..3c62ee6a --- /dev/null +++ b/routetools/weather.py @@ -0,0 +1,548 @@ +"""Weather constraint enforcement for route optimization. + +Provides penalty functions that discourage routes from traversing regions +where weather conditions exceed safe operating limits. The two SWOPP3 +constraints are: + +- True Wind Speed (TWS) < 20 m/s +- Significant Wave Height (Hs) < 7 m + +The penalty functions follow the same pattern as ``Land.penalization`` and +are designed to be added to the cost in the CMA-ES optimization loop:: + + cost += weather_penalty(curve, windfield, wavefield, ...) + +The module also provides a ``RouteWeatherStats`` dataclass to report +per-route max TWS and max Hs (required columns in SWOPP3 File A). + +Example +------- +>>> from routetools.weather import weather_penalty +>>> # penalty = weather_penalty(curve, windfield, wavefield) +>>> # cost += penalty +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +import jax.numpy as jnp + +from routetools._cost.haversine import haversine_meters_components + +# --------------------------------------------------------------------------- +# Default SWOPP3 constraint thresholds +# --------------------------------------------------------------------------- +DEFAULT_TWS_LIMIT: float = 20.0 +"""Maximum allowed true wind speed in m/s (SWOPP3 spec).""" + +DEFAULT_HS_LIMIT: float = 7.0 +"""Maximum allowed significant wave height in m (SWOPP3 spec).""" + +__all__ = [ + "DEFAULT_TWS_LIMIT", + "DEFAULT_HS_LIMIT", + "RouteWeatherStats", + "evaluate_weather", + "weather_penalty", + "weather_penalty_smooth", + "wind_penalty_smooth", + "wave_penalty_smooth", +] + + +# --------------------------------------------------------------------------- +# Route weather statistics +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class RouteWeatherStats: + """Per-route weather statistics for reporting. + + Attributes + ---------- + max_tws : jnp.ndarray + Maximum true wind speed along each route, shape ``(B,)``. + max_hs : jnp.ndarray + Maximum significant wave height along each route, shape ``(B,)``. + tws_exceeded : jnp.ndarray + Boolean array: whether any segment midpoint exceeded the TWS limit, + shape ``(B,)``. + hs_exceeded : jnp.ndarray + Boolean array: whether any segment midpoint exceeded the Hs limit, + shape ``(B,)``. + """ + + max_tws: jnp.ndarray + max_hs: jnp.ndarray + tws_exceeded: jnp.ndarray + hs_exceeded: jnp.ndarray + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- +def _safe_mean(x: jnp.ndarray, axis: int) -> jnp.ndarray: + """Mean over *axis*, returning 0 when that axis is empty.""" + n = x.shape[axis] + return jnp.where(n > 0, jnp.sum(x, axis=axis) / jnp.maximum(n, 1), 0.0) + + +def _safe_max(x: jnp.ndarray, axis: int) -> jnp.ndarray: + """Max over *axis*, returning 0 when that axis is empty.""" + n = x.shape[axis] + return jnp.where( + n > 0, + jnp.max(x, axis=axis, initial=0.0, where=jnp.ones(x.shape, dtype=bool)), + 0.0, + ) + + +def _segment_midpoints( + curve: jnp.ndarray, + travel_stw: float | None = None, + travel_time: float | None = None, + spherical_correction: bool = True, + time_offset: float = 0.0, +) -> tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]: + """Compute segment midpoint coordinates and timestamps. + + Parameters + ---------- + curve : jnp.ndarray + Shape ``(B, L, 2)`` with ``(lon, lat)``. + travel_stw : float, optional + Constant speed through water (m/s). Used to estimate elapsed time + per segment as ``distance / travel_stw``. + travel_time : float, optional + Total travel time. Time is distributed uniformly across + segments (each segment gets ``travel_time / n_seg``). + Units must match *time_offset*. + spherical_correction : bool + If ``True`` (default), use haversine distances (metres). + time_offset : float + Constant added to all midpoint timestamps (e.g. departure time + offset in the same unit as *travel_time*). Default ``0.0``. + + Returns + ------- + mid_lon, mid_lat, t_mid : jnp.ndarray + Each of shape ``(B, L-1)``. ``t_mid`` is ``time_offset`` plus + the estimated elapsed time at the midpoint of each segment. + """ + mid_lon = (curve[:, :-1, 0] + curve[:, 1:, 0]) / 2 + mid_lat = (curve[:, :-1, 1] + curve[:, 1:, 1]) / 2 + + if travel_stw is None and travel_time is None: + # Fallback: no time information → time_offset only + return mid_lon, mid_lat, jnp.full_like(mid_lon, time_offset) + + # Compute segment distances + if spherical_correction: + dx, dy = haversine_meters_components( + curve[:, :-1, 1], + curve[:, :-1, 0], + curve[:, 1:, 1], + curve[:, 1:, 0], + ) + else: + dx = jnp.diff(curve[:, :, 0], axis=1) + dy = jnp.diff(curve[:, :, 1], axis=1) + segment_dist = jnp.sqrt(dx**2 + dy**2) + + if travel_time is not None: + # Uniform time per segment — matches the post-hoc energy + # evaluation in cost.evaluate_route_energy which uses + # dt = passage_hours / n_seg for each segment. + n_seg = curve.shape[1] - 1 + segment_dt = jnp.broadcast_to( + jnp.array(travel_time / n_seg), segment_dist.shape + ) + else: + # Constant STW: t = distance / speed + segment_dt = segment_dist / travel_stw + + # Cumulative time at segment midpoints + cumulative_t = jnp.cumsum(segment_dt, axis=1) + t_mid = cumulative_t - segment_dt / 2 + time_offset + + return mid_lon, mid_lat, t_mid + + +# --------------------------------------------------------------------------- +# Core evaluation +# --------------------------------------------------------------------------- +def evaluate_weather( + curve: jnp.ndarray, + windfield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], + tuple[jnp.ndarray, jnp.ndarray], + ] + | None = None, + wavefield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], + tuple[jnp.ndarray, jnp.ndarray], + ] + | None = None, + tws_limit: float = DEFAULT_TWS_LIMIT, + hs_limit: float = DEFAULT_HS_LIMIT, + travel_stw: float | None = None, + travel_time: float | None = None, + spherical_correction: bool = True, + time_offset: float = 0.0, +) -> RouteWeatherStats: + """Evaluate weather conditions along routes and report statistics. + + Samples the windfield and wavefield at the **midpoints** of each + route segment (same convention as the cost function) and computes + per-route maxima. + + Parameters + ---------- + curve : jnp.ndarray + Batch of trajectories, shape ``(B, L, 2)`` with ``(lon, lat)`` + coordinates. + windfield : Callable, optional + ``(lon, lat, t) -> (u10, v10)`` closure. If ``None``, TWS stats + are returned as zeros. + wavefield : Callable, optional + ``(lon, lat, t) -> (hs, mwd)`` closure. If ``None``, Hs stats + are returned as zeros. + tws_limit : float + TWS threshold in m/s. + hs_limit : float + Hs threshold in m. + travel_stw : float, optional + Constant speed through water (m/s) for elapsed-time estimation. + travel_time : float, optional + Total travel time (seconds); distributed proportionally by distance. + spherical_correction : bool + Use haversine distances (default ``True``). + + Returns + ------- + RouteWeatherStats + Per-route maxima and exceedance flags. + """ + B = curve.shape[0] + + # Guard: curves with fewer than 2 points have no segments + if curve.shape[1] < 2: + return RouteWeatherStats( + max_tws=jnp.zeros(B), + max_hs=jnp.zeros(B), + tws_exceeded=jnp.zeros(B, dtype=bool), + hs_exceeded=jnp.zeros(B, dtype=bool), + ) + + # Midpoints of each segment + mid_lon, mid_lat, t_mid = _segment_midpoints( + curve, + travel_stw=travel_stw, + travel_time=travel_time, + spherical_correction=spherical_correction, + time_offset=time_offset, + ) + + # Wind + if windfield is not None: + u10, v10 = windfield(mid_lon, mid_lat, t_mid) + tws = jnp.sqrt(u10**2 + v10**2) + max_tws = jnp.max(tws, axis=1) + else: + max_tws = jnp.zeros(B) + + # Waves + if wavefield is not None: + hs, _ = wavefield(mid_lon, mid_lat, t_mid) + max_hs = jnp.max(hs, axis=1) + else: + max_hs = jnp.zeros(B) + + return RouteWeatherStats( + max_tws=max_tws, + max_hs=max_hs, + tws_exceeded=max_tws > tws_limit, + hs_exceeded=max_hs > hs_limit, + ) + + +# --------------------------------------------------------------------------- +# Hard penalty (step function — same pattern as Land.penalization) +# --------------------------------------------------------------------------- +def weather_penalty( + curve: jnp.ndarray, + windfield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], + tuple[jnp.ndarray, jnp.ndarray], + ] + | None = None, + wavefield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], + tuple[jnp.ndarray, jnp.ndarray], + ] + | None = None, + tws_limit: float = DEFAULT_TWS_LIMIT, + hs_limit: float = DEFAULT_HS_LIMIT, + penalty: float = 10.0, + travel_stw: float | None = None, + travel_time: float | None = None, + spherical_correction: bool = True, + time_offset: float = 0.0, +) -> jnp.ndarray: + """Compute a hard penalty for weather constraint violations. + + For each route in the batch, counts the number of **segments** where + TWS or Hs exceeds the threshold, multiplied by ``penalty``. This is + analogous to ``Land.penalization``. + + Parameters + ---------- + curve : jnp.ndarray + Batch of trajectories, shape ``(B, L, 2)``. + windfield : Callable, optional + ``(lon, lat, t) -> (u10, v10)``. + wavefield : Callable, optional + ``(lon, lat, t) -> (hs, mwd)``. + tws_limit : float + TWS threshold in m/s (default 20). + hs_limit : float + Hs threshold in m (default 7). + penalty : float + Penalty per violating segment (default 10). + travel_stw : float, optional + Constant speed through water (m/s) for elapsed-time estimation. + travel_time : float, optional + Total travel time (seconds); distributed proportionally by distance. + spherical_correction : bool + Use haversine distances (default ``True``). + + Returns + ------- + jnp.ndarray + Penalty per route, shape ``(B,)``. + """ + mid_lon, mid_lat, t_mid = _segment_midpoints( + curve, + travel_stw=travel_stw, + travel_time=travel_time, + spherical_correction=spherical_correction, + time_offset=time_offset, + ) + + violations = jnp.zeros(curve.shape[0]) + + if windfield is not None: + u10, v10 = windfield(mid_lon, mid_lat, t_mid) + tws = jnp.sqrt(u10**2 + v10**2) + violations = violations + jnp.sum(tws > tws_limit, axis=1) + + if wavefield is not None: + hs, _ = wavefield(mid_lon, mid_lat, t_mid) + violations = violations + jnp.sum(hs > hs_limit, axis=1) + + return violations * penalty + + +# --------------------------------------------------------------------------- +# Smooth penalty (differentiable — useful for gradient-based methods) +# --------------------------------------------------------------------------- +def weather_penalty_smooth( + curve: jnp.ndarray, + windfield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], + tuple[jnp.ndarray, jnp.ndarray], + ] + | None = None, + wavefield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], + tuple[jnp.ndarray, jnp.ndarray], + ] + | None = None, + tws_limit: float = DEFAULT_TWS_LIMIT, + hs_limit: float = DEFAULT_HS_LIMIT, + penalty: float = 10.0, + sharpness: float = 5.0, + travel_stw: float | None = None, + travel_time: float | None = None, + spherical_correction: bool = True, + time_offset: float = 0.0, +) -> jnp.ndarray: + """Compute a smooth (differentiable) penalty for weather violations. + + Uses a squared ReLU ramp so that the penalty increases continuously + as conditions worsen beyond the threshold. For each segment: + + ``penalty_i = sharpness · max(0, value - limit)²`` + + This is averaged over all segments per route and scaled by ``penalty``, + making the penalty independent of route resolution. + + Parameters + ---------- + curve : jnp.ndarray + Batch of trajectories, shape ``(B, L, 2)``. + windfield : Callable, optional + ``(lon, lat, t) -> (u10, v10)``. + wavefield : Callable, optional + ``(lon, lat, t) -> (hs, mwd)``. + tws_limit : float + TWS threshold in m/s (default 20). + hs_limit : float + Hs threshold in m (default 7). + penalty : float + Scaling factor (default 10). + sharpness : float + Linear multiplier on the squared excess (default 5). + travel_stw : float, optional + Constant speed through water (m/s) for elapsed-time estimation. + travel_time : float, optional + Total travel time (seconds); distributed proportionally by distance. + spherical_correction : bool + Use haversine distances (default ``True``). + + Returns + ------- + jnp.ndarray + Smooth penalty per route, shape ``(B,)``. + """ + mid_lon, mid_lat, t_mid = _segment_midpoints( + curve, + travel_stw=travel_stw, + travel_time=travel_time, + spherical_correction=spherical_correction, + time_offset=time_offset, + ) + + total = jnp.zeros(curve.shape[0]) + + if windfield is not None: + u10, v10 = windfield(mid_lon, mid_lat, t_mid) + tws = jnp.sqrt(u10**2 + v10**2) + excess = jnp.maximum(tws - tws_limit, 0.0) + total = total + _safe_max(excess**2, axis=1) * sharpness + + if wavefield is not None: + hs, _ = wavefield(mid_lon, mid_lat, t_mid) + excess = jnp.maximum(hs - hs_limit, 0.0) + total = total + _safe_max(excess**2, axis=1) * sharpness + + return total * penalty + + +# --------------------------------------------------------------------------- +# Split penalties — independent wind and wave smooth penalties +# --------------------------------------------------------------------------- +def wind_penalty_smooth( + curve: jnp.ndarray, + windfield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], + tuple[jnp.ndarray, jnp.ndarray], + ], + tws_limit: float = DEFAULT_TWS_LIMIT, + weight: float = 50.0, + travel_stw: float | None = None, + travel_time: float | None = None, + spherical_correction: bool = True, + time_offset: float = 0.0, +) -> jnp.ndarray: + """Smooth penalty for wind-speed constraint violations only. + + For each segment where TWS exceeds ``tws_limit``: + + ``penalty_i = max(0, TWS_i - tws_limit)²`` + + The per-segment penalties are averaged and scaled by ``weight``, + making the penalty independent of route resolution. + + Parameters + ---------- + curve : jnp.ndarray + Batch of trajectories, shape ``(B, L, 2)``. + windfield : Callable + ``(lon, lat, t) -> (u10, v10)``. + tws_limit : float + TWS threshold in m/s (default 20). + weight : float + Scaling factor applied to the mean squared-excess penalty + (default 50). + travel_stw : float, optional + Constant speed through water (m/s). + travel_time : float, optional + Total travel time (seconds). + spherical_correction : bool + Use haversine distances (default ``True``). + + Returns + ------- + jnp.ndarray + Smooth wind penalty per route, shape ``(B,)``. + """ + mid_lon, mid_lat, t_mid = _segment_midpoints( + curve, + travel_stw=travel_stw, + travel_time=travel_time, + spherical_correction=spherical_correction, + time_offset=time_offset, + ) + u10, v10 = windfield(mid_lon, mid_lat, t_mid) + tws = jnp.sqrt(u10**2 + v10**2) + excess = jnp.maximum(tws - tws_limit, 0.0) + return weight * _safe_max(excess**2, axis=1) + + +def wave_penalty_smooth( + curve: jnp.ndarray, + wavefield: Callable[ + [jnp.ndarray, jnp.ndarray, jnp.ndarray], + tuple[jnp.ndarray, jnp.ndarray], + ], + hs_limit: float = DEFAULT_HS_LIMIT, + weight: float = 50.0, + travel_stw: float | None = None, + travel_time: float | None = None, + spherical_correction: bool = True, + time_offset: float = 0.0, +) -> jnp.ndarray: + """Smooth penalty for wave-height constraint violations only. + + For each segment where Hs exceeds ``hs_limit``: + + ``penalty_i = max(0, Hs_i - hs_limit)²`` + + The per-segment penalties are averaged and scaled by ``weight``, + making the penalty independent of route resolution. + + Parameters + ---------- + curve : jnp.ndarray + Batch of trajectories, shape ``(B, L, 2)``. + wavefield : Callable + ``(lon, lat, t) -> (hs, mwd)``. + hs_limit : float + Hs threshold in m (default 7). + weight : float + Scaling factor applied to the mean squared-excess penalty + (default 50). + travel_stw : float, optional + Constant speed through water (m/s). + travel_time : float, optional + Total travel time (seconds). + spherical_correction : bool + Use haversine distances (default ``True``). + + Returns + ------- + jnp.ndarray + Smooth wave penalty per route, shape ``(B,)``. + """ + mid_lon, mid_lat, t_mid = _segment_midpoints( + curve, + travel_stw=travel_stw, + travel_time=travel_time, + spherical_correction=spherical_correction, + time_offset=time_offset, + ) + hs, _ = wavefield(mid_lon, mid_lat, t_mid) + excess = jnp.maximum(hs - hs_limit, 0.0) + return weight * _safe_max(excess**2, axis=1) diff --git a/routetools/wrr_bench/__init__.py b/routetools/wrr_bench/__init__.py new file mode 100644 index 00000000..173dda13 --- /dev/null +++ b/routetools/wrr_bench/__init__.py @@ -0,0 +1,23 @@ +"""Legacy ocean benchmark module. + +.. deprecated:: + ``wrr_bench`` is superseded by :mod:`routetools.era5` which provides + real-world ERA5 wind, wave, and current fields. ``wrr_bench`` will be + removed in a future release. Migrate to the ERA5-based pipeline:: + + from routetools.era5 import load_era5_windfield, load_era5_wavefield +""" + +import warnings + +warnings.warn( + "routetools.wrr_bench is deprecated and will be removed in a future release. " + "Use routetools.era5 instead for real-world weather data.", + DeprecationWarning, + stacklevel=2, +) + +from routetools.wrr_bench.load import load_real_instance # noqa: E402 +from routetools.wrr_bench.ocean import Ocean # noqa: E402 + +__all__ = ["load_real_instance", "Ocean"] diff --git a/routetools/wrr_bench/dataset.py b/routetools/wrr_bench/dataset.py new file mode 100644 index 00000000..edf4ec17 --- /dev/null +++ b/routetools/wrr_bench/dataset.py @@ -0,0 +1,97 @@ +import numpy as np +import xarray as xr + + +def get_data_chunk( + xarray_data: xr.Dataset, bottom: float, left: float, up: float, right: float +) -> xr.Dataset: + """Return the specified frame from the full dataset. + + Parameters + ---------- + xarray_data : xr.Dataset + The full dataset + + Returns + ------- + xr.Dataset + The set of data within the rectangle frame specified + """ + data_chunk = xarray_data.sel( + latitude=slice(min(bottom, up), max(bottom, up)), + longitude=slice(min(left, right), max(left, right)), + ) + return data_chunk + + +def correct_ds_coordinates(ds: xr.Dataset) -> xr.Dataset: + """Fix dataset coordinates and add spacing attributes. + + The function fixes coordinate names and ordering. It also adds + `grid_thickness` and `time_spacing` attributes to the dataset. + + Parameters + ---------- + ds : xr.Dataset + Dataset to fix. + + Returns + ------- + xr.Dataset + Dataset with fixed coordinates. + """ + # TODO: move this function and the calculation of the grid thickness to + # the data_client and store in s3 with these corrections + + if "lat" in ds.coords and "lon" in ds.coords: + # Fix the coordinates names + ds = ds.rename({"lat": "latitude", "lon": "longitude"}) + + # Fix the coordinates order + ds = ds.sortby(["latitude", "longitude", "time"]) + + # Add grid_thickness attribute + try: + ds.attrs["grid_thickness"] = ds.latitude.attrs["step"] + except KeyError: + # step is not defined in the attributes + differences = ds.latitude.values[1:] - ds.latitude.values[:-1] + + assert np.allclose(differences, differences[0]), "Dataset is not evenly spaced" + ds.attrs["grid_thickness"] = differences[0] + + # Add time_spacing attribute + time_values = ds.time.values + + if len(time_values) == 1: + ds.attrs["time_spacing"] = 24 + else: + differences = ( + (time_values[1:] - time_values[:-1]) / np.timedelta64(1, "h") + ).astype(int) + + assert np.allclose( + differences, differences[0] + ), "Dataset is not evenly spaced in time dimension" + + ds.attrs["time_spacing"] = differences[0] + + return ds + + +def date_from_week(week, year=2023) -> np.datetime64: + """Return the date for the first day of the given ISO week and year. + + Parameters + ---------- + week : int + Week number. It is consider that the count of weeks goes from 1 to 52. + year : int, optional + Year, by default 2023. + + Returns + ------- + np.datetime64 + Date of the first day of the week. + """ + return np.datetime64(f"{year}-01-01") + np.timedelta64((week - 1) * 7, "D") diff --git a/routetools/wrr_bench/interpolate.py b/routetools/wrr_bench/interpolate.py new file mode 100644 index 00000000..31336bc3 --- /dev/null +++ b/routetools/wrr_bench/interpolate.py @@ -0,0 +1,161 @@ +from abc import ABC, abstractmethod + +import numpy as np +import scipy +import xarray as xr + + +class Interpolator(ABC): + """Abstract base class for dataset interpolators.""" + + @abstractmethod + def __init__( + self, + ds: xr.Dataset, + vars: tuple[str] = ("vo", "uo"), + land_penalization: float = 0, + *kwargs, + ): + """Prepare an interpolator for the given dataset. + + Parameters + ---------- + ds : xr.Dataset + The dataset containing the data to interpolate + vars : Tuple[str] + The variables to be interpolated + land_penalization : float, optional + The value to be used to penalize land cells, by default 0 + If the value is 0, then the land cells are not penalized. + land_penalization can be used only if the dataset has the + `land` variable. + """ + pass + + @abstractmethod + def interpolate( + self, lat: np.ndarray, lon: np.ndarray, ts: np.ndarray + ) -> np.ndarray: + """ + Interpolate the data at the specified (lat, lon, ts) positions. + + Parameters + ---------- + lat : float + The specified latitude of the location of the ship + lon : float + The specified longitude of the location of the ship + ts: np.ndarray + An array of timestamps in of type np.datetime64 format + + Returns + ------- + Tuple[Tuple[np.ndarray]] + An array of length 3, with interpolated data at the specified (lat, lon) + and respective deriviatives in pairs. + """ + pass + + +class EvenLinearInterpolator(Interpolator): + """Even-spacing linear interpolator implementation.""" + + def __init__( + self, + ds: xr.Dataset, + vars: tuple[str] = ("vo", "uo"), + order: int = 0, + land_penalization: float = 0, + ): + """Prepare an even-spacing interpolator for the given dataset. + + Parameters + ---------- + ds : xr.Dataset + The dataset containing the data to interpolate + vars : Tuple[str] + The variables to be interpolated + order : int, optional + The order of the spline interpolation, by default 1 + This value must be between 0 and 5 + land_penalization : float, optional + The value to be used to penalize land cells, by default 0 + If the value is 0, then the land cells are not penalized. + land_penalization can be used only if the dataset has the + `land` variable. + """ + data = [] + for var in vars: + if land_penalization > 0: + ds[var] = ds[var].where(ds["land"] == 0, land_penalization) + + data.append(ds[var].values) + + self.vars = vars + + self.date_start = ds.coords["time"].values[0] + + axes = [ + (ds.coords["time"] - self.date_start) / np.timedelta64(1, "s"), + ds.coords["latitude"].values.astype(np.float32), + ds.coords["longitude"].values.astype(np.float32), + ] + + assert len(axes) == data[0].ndim # FIXME check all axes + + self.order = order + self.data = data + self.begin = np.zeros(len(axes)) + self.spacing = np.zeros(len(axes)) + for i, var in enumerate(axes): + if len(var) > 1 and not np.allclose(np.std(np.diff(var)), 0, atol=1e-4): + raise ValueError(f"{var} is not regularly spaced") + + self.begin[i] = var[0] + self.spacing[i] = (var[-1] - var[0]) / (len(var) - 1) + + if np.isnan(self.spacing[i]): + self.spacing[i] = self.begin[i] + + def interpolate( + self, lat: np.ndarray, lon: np.ndarray, ts: np.ndarray + ) -> np.ndarray: + """Interpolate the data at the specified (lat, lon, ts) positions. + + Parameters + ---------- + lat : float + The specified latitude of the location of the ship + lon : float + The specified longitude of the location of the ship + ts: np.ndarray + An array of timestamps in of type np.datetime64 format + + Returns + ------- + Tuple[Tuple[np.ndarray]] + An array of length 3, with interpolated data at the specified (lat, lon) + and respective deriviatives in pairs. + """ + # If ts is datetime, convert to seconds + if np.issubdtype(ts.dtype, np.datetime64): + ts = (ts - self.date_start) / np.timedelta64(1, "s") + else: + # If it is float, assume it is in seconds + ts = ts.astype(np.float32) + x = np.array([ts, lat, lon]).T + + assert x.ndim == 2 + assert x.shape[1] == self.data[0].ndim + + coords = (x - self.begin[None, :]) / self.spacing[None, :] + + output = [] + for d in self.data: + output.append( + scipy.ndimage.map_coordinates( + d, coords.T, order=self.order, mode="wrap" + ) + ) + + return np.array(output) diff --git a/routetools/wrr_bench/load.py b/routetools/wrr_bench/load.py new file mode 100644 index 00000000..cf44d2f1 --- /dev/null +++ b/routetools/wrr_bench/load.py @@ -0,0 +1,208 @@ +import datetime as dt +import re +from collections.abc import Iterable +from pathlib import Path + +import numpy as np +import pandas as pd +import xarray as xr + +from routetools._ports import DICT_INSTANCES, DICT_PORTS +from routetools.wrr_bench.ocean import Ocean + + +def load_files( + list_dates: Iterable[str], + data_path: str = "./data", + weather_variables: Iterable[str] = ["currents", "waves"], +) -> dict[str, xr.Dataset]: + """ + Load multiple files and return a xarray dataset. + + Parameters + ---------- + list_dates : Iterable[str] + List of dates to load in format "%Y-%m-%d". + data_path : str, optional + Path to the folder containing the files, by default "./data". + weather_variables : Iterable[str], optional + List of weather variables to load, by default ("currents"). + + Returns + ------- + dict[xr.Dataset] + Dataset dictionary containing the data from the files. + """ + ocean_datasets = dict() + data_Path = Path(data_path) + + # In the future we will have more than one ocean dataset + for string_ocean in weather_variables: + files = [] + for date in list_dates: + file_path = data_Path / f"{string_ocean}/{date}.nc" + files.append(file_path) + + ds = xr.open_mfdataset(files, concat_dim="time", combine="nested") + + ocean_datasets[string_ocean] = ds + + return ocean_datasets + + +def load_real_instance( + name_instance: str, + date_start: np.datetime64 | str | None = None, + data_path: str = "./data", + bounding_box: Iterable[float] | None = None, + bounding_border: float = 5.0, + land_resolution: str = "2km", + vel_ship: float | None = None, + use_currents: bool = True, + use_waves: bool = True, + route_days: int = 10, +) -> dict: + """ + Load instance configuration and prepare data and parameters to optimize. + + Parameters + ---------- + name_instance : str + Name of the instance used. + date_start : np.datetime64 | str, optional + Starting date. If not given, takes the one given by the instance. + Be careful, because the instance doesn't have the date by default. + data_path : str, optional + Path to the folder containing the files, by default "./data" + bounding_box : Optional[List[float]], optional + Bounding box of the area to optimize, by default None + It is a list of 4 elements: [bottom, left, up, right] + bounding_border : float, optional + Border to add to the bounding box around the route, by default 5.0 + land_resolution : float, optional + Resolution of the land polygons, by default "2km" + It can be "1km" or "2km". + vel_ship : float, optional + Speed of the ship in m/s, by default None + If it is None will try to use the one given by the instance, + if not it will use this value. + use_currents : bool, optional + Whether to use ocean currents data, by default True + use_waves : bool, optional + Whether to use ocean waves data, by default True + route_days : int, optional + Number of days worth of data. If the route goes for longer, + it will repeat the last day, by default 10 + + Returns + ------- + dict + Dictionary containing the instance configuration. + """ + # TODO: Add the valid land file to the data_path + if land_resolution == "1km": + land_file_name = "earth-seas-1km-valid.geo.json" + else: + land_file_name = "earth-seas-2km5-valid.geo.json" + + dict_instance = {} + + # Check if the instance name is a port-to-port code "XXXXX-YYYYY" + if re.match(r"^[A-Z]{5}-[A-Z]{5}$", name_instance): + # If it does, take the port information + port_start = name_instance[:5] + port_end = name_instance[6:] + # Reinitiliaze the dictionary with the port coordinates + dict_add = { + "lat_start": DICT_PORTS[port_start]["lat"], + "lon_start": DICT_PORTS[port_start]["lon"], + "lat_end": DICT_PORTS[port_end]["lat"], + "lon_end": DICT_PORTS[port_end]["lon"], + } + dict_instance.update(dict_add) + # In addition, make sure the ODP information exists in either direction + # This will be useful if we have, for instance, defined a limit or starting date + name_alt = f"{port_end}-{port_start}" + else: + name_alt = name_instance + + # Find if the instance is defined inside the dictionary, as is + if name_instance in DICT_INSTANCES: + dict_instance.update(DICT_INSTANCES[name_instance]) + # Else, find if the reverse name works + elif name_alt in DICT_INSTANCES: + dict_instance.update(DICT_INSTANCES[name_alt]) + + # If neither the direct name nor the alternate (reversed) name exist, + # and the provided name is not a port-to-port code, it's an error. + if not ( + name_instance in DICT_INSTANCES + or name_alt in DICT_INSTANCES + or re.match(r"^[A-Z]{5}-[A-Z]{5}$", name_instance) + ): + raise KeyError(f"Instance {name_instance} not found") + + # Initialize the dictionary containing the instance configuration + # Adds default parameters to avoid missing information + assert ( + vel_ship is not None or dict_instance.get("vel_ship") is not None + ), "Velocity of the ship not found. Must be defined on instance or as parameter." + + if vel_ship is not None: + dict_instance["vel_ship"] = vel_ship + + # Fill the date, if provided + if date_start is None: + # If date_start is not provided, take the one from the instance + date_start = np.datetime64(dict_instance["date_start"]) + else: + # If it is provided, update the dictionary + dict_instance["date_start"] = pd.to_datetime(str(date_start)).strftime( + "%Y-%m-%dT%H:%M:%S" + ) + + # Convert date_start to np.datetime64 if it is a string + if isinstance(date_start, str): + # If it is a string, convert to np.datetime64 + date_start = np.datetime64(date_start) + + string_date_start = date_start.astype(dt.datetime).strftime("%Y-%m-%d") + + list_string_date = [string_date_start] + for day in range(1, route_days): + current_date = date_start + np.timedelta64(day, "D") + list_string_date.append(current_date.astype(dt.datetime).strftime("%Y-%m-%d")) + + # Load the ocean data choosing the variables + weather_variables = [] + if use_currents: + weather_variables.append("currents") + if use_waves: + weather_variables.append("waves") + ocean_datasets = load_files( + list_string_date, data_path, weather_variables=weather_variables + ) + + if bounding_box is not None: + dict_instance["bounding_box"] = bounding_box + + # Slice the data chunk + if dict_instance.get("bounding_box") is None: + bb = bounding_border + bottom = min(dict_instance["lat_start"], dict_instance["lat_end"]) - bb + up = max(dict_instance["lat_start"], dict_instance["lat_end"]) + bb + left = min(dict_instance["lon_start"], dict_instance["lon_end"]) - bb + right = max(dict_instance["lon_start"], dict_instance["lon_end"]) + bb + dict_instance["bounding_box"] = [bottom, left, up, right] + + dict_instance["data"] = Ocean( + currents_data=ocean_datasets.get("currents", None), + waves_data=ocean_datasets.get("waves", None), + wind_data=ocean_datasets.get("wind", None), + bounding_box=dict_instance["bounding_box"], + land_file=data_path + "/" + land_file_name, + ) + + dict_instance["date_start"] = date_start + + return dict_instance diff --git a/routetools/wrr_bench/ocean.py b/routetools/wrr_bench/ocean.py new file mode 100644 index 00000000..01779f38 --- /dev/null +++ b/routetools/wrr_bench/ocean.py @@ -0,0 +1,561 @@ +import importlib +import json +from collections.abc import Iterable + +import cv2 +import numpy as np +import pandas as pd +import shapely +import xarray as xr +from shapely.geometry import LineString, MultiPolygon, Point, Polygon, shape +from shapely.ops import unary_union +from shapely.validation import make_valid + +from routetools._cost.waves import beaufort_scale +from routetools.wrr_bench.dataset import correct_ds_coordinates, get_data_chunk +from routetools.wrr_bench.interpolate import Interpolator +from routetools.wrr_bench.polygons import ( + crop_polygon, + invert_polygon, + relative_to_latlon, +) + +EARTH_RADIUS = 6378137 +DEG2M = np.deg2rad(1) * EARTH_RADIUS + + +def data_zero( + bounding_box: tuple | None = None, data_vars: tuple[str, str] = ("vo", "uo") +) -> xr.Dataset: + """Create a fake current dataset with zeros. + + Parameters + ---------- + bounding_box : Tuple + Bounding box of the region (lat_min, lon_min, lat_max, lon_max) + + Returns + ------- + xr.Dataset + Dataset with zeros. + """ + if bounding_box is None: + lat_min, lon_min, lat_max, lon_max = (-90, -180, 90, 180) + else: + lat_min, lon_min, lat_max, lon_max = bounding_box + # Space coordinates evenly across the bounding box + # The step does not matter because the interpolator always picks the closest + # point and here the data is always zero + lat_values = np.linspace(lat_min, lat_max, 10) + lon_values = np.linspace(lon_min, lon_max, 10) + # Choose two timestamps - again which ones does not matter for the interpolator + # as it will pick the closest one + timestamp_values = np.array(["2024-01-01", "2024-01-02"]).astype("datetime64[ns]") + + # Fill the current velocities with zeros + array_zero = np.zeros((len(lat_values), len(lon_values), len(timestamp_values))) + + # Create the fake current data in the format expected by the interpolator + dict_vars = {} + for k in data_vars: + dict_vars[k] = (["latitude", "longitude", "time"], array_zero) + data = xr.Dataset( + dict_vars, + coords={ + "latitude": lat_values, + "longitude": lon_values, + "time": timestamp_values, + }, + ) + + return data + + +class Ocean: + """Container for ocean, wave and wind data with interpolation helpers.""" + + def __init__( + self, + currents_data: xr.Dataset | None = None, + waves_data: xr.Dataset | None = None, + wind_data: xr.Dataset | None = None, + currents_interpolator: Interpolator | None = None, + waves_interpolator: Interpolator | None = None, + wind_interpolator: Interpolator | None = None, + radius: float = EARTH_RADIUS, + bounding_box: tuple[float, float, float, float] | None = None, + time_spacing: float = 8, + min_thickness: float = 0.08333, + land_file: str = "static_data/geojson/earth-seas-2km5-valid.geo.json", + interp_method: str = "EvenLinearInterpolator", + prepare_geom: bool = True, + use_ice: bool = True, + erode_ice: int = 1, + ): + """ + Create an Ocean object to manage meteo data. + + Parameters + ---------- + currents_data : xr.Dataset, optional + Currents dataset, by default None + waves_data : xr.Dataset, optional + Waves dataset, by default None + wind_data : xr.Dataset, optional + Wind dataset, by default None + currents_interpolator : Interpolator, optional + Interpolator object to gather currents data, by default None + If None, a new Interpolator object will be created + waves_interpolator : Interpolator, optional + Interpolator object to gather waves data, by default None + If None, a new Interpolator object will be created + wind_interpolator : Interpolator, optional + Interpolator object to gather wind data, by default None + If None, a new Interpolator object will be created + radius : float, optional + Earth radius in meters, by default 6378137 + bounding_box : Iterable, optional + Bounding box of the computation, by default None. + The order of the coordinates is: + (lat_bottom_left, lon_bottom_left, lat_up_right, lon_up_right). + If no data is provided, the bounding box will be used to create + synthetic data. + time_spacing : float, optional + Delta time between different values in hours, by default 8. + It is used to reduce memory usage. + min_thickness : float, optional + Minimum thickness of the grid in degrees, by default 0.08333. + This value is used to ensure that the data chunk is big enough. + land_file : str, optional + Path to the geojson file containing the land data, by default + "static_data/geojson/earth-seas-2km5-valid.geo.json" + If it is None, land will be always 0. + This is mainly for testing. + interp_method : str, optional + Interpolation method to use, if not defined by the interpolator objects. + By default, "EvenLinearInterpolator". + prepare_geom : bool, optional + If True, the geometry will be prepared to speed up the intersection and + contain checks, by default True. + A prepared geometry can't be pickled, so if you want to parallelize multiple + runs it won't work. + use_ice : bool, optional + If True, the ice data will be used, by default True. + Ice is consider and land and can be used only if waves_data is not None. + erode_ice : int, optional + Number of pixels to erode the ice mask, by default 1. + """ + self.time_spacing = time_spacing + self.min_thickness = min_thickness + self.land_file = land_file + self.interp_method = interp_method + self.radius = radius # in meters + + if bounding_box is not None: + self.bounding_box = bounding_box + else: + if currents_data is not None: + latitudes = currents_data.latitude + longitudes = currents_data.longitude + elif waves_data is not None: + latitudes = waves_data.latitude + longitudes = waves_data.longitude + elif wind_data is not None: + latitudes = wind_data.latitude + longitudes = wind_data.longitude + else: + raise AttributeError("No data provided and no bounding box defined.") + + self.bounding_box = ( + latitudes.min().values, + longitudes.min().values, + latitudes.max().values, + longitudes.max().values, + ) + + try: + interp_obj: Interpolator = getattr( + importlib.import_module("routetools.wrr_bench.interpolate"), + interp_method, + ) + if not isinstance(Interpolator, type): + raise AttributeError(f"{interp_method} is not a class.") + except (ImportError, AttributeError) as e: + raise ImportError(f"Failed to import {interp_method}: {e}") from e + + bottom, left, up, right = self.bounding_box + + ocean_datasets = [currents_data, waves_data, wind_data] + for i, ds in enumerate(ocean_datasets): + if ds is not None: + ds = correct_ds_coordinates(ds) + if ds.grid_thickness > min_thickness: + # This is made to ensure that when the interpolation is made all + # the latitudes and longitdes are inside the chunked data. + ocean_datasets[i] = get_data_chunk( + ds, + bottom - ds.grid_thickness, + left - ds.grid_thickness, + up + ds.grid_thickness, + right + ds.grid_thickness, + ) + + if ds.time_spacing < time_spacing: + # Go from start time to end time in the step defined by time_spacing + start_time = ocean_datasets[i].time.values[0] + end_time = ocean_datasets[i].time.values[-1] + time_index = pd.date_range( + start=start_time, end=end_time, freq=f"{time_spacing}h" + ) + ocean_datasets[i] = ocean_datasets[i].sel( + time=time_index, method="nearest" + ) + ocean_datasets[i] = ocean_datasets[i].assign_coords(time=time_index) + + ocean_datasets[i].attrs["time_spacing"] = time_spacing + + currents_data, waves_data, wind_data = ocean_datasets + + # Build the land/ice mask from available inputs. If `land_file` is + # None, `create_land_mask` will use only `waves_data` (NaN mask) when + # `use_ice` is True; otherwise it returns an empty MultiPolygon. + self.shapely_ocean = self.create_land_mask( + use_ice=use_ice, + erode_ice=erode_ice, + waves_data=waves_data, + land_file=land_file, + bounding_box=self.bounding_box, + ) + + if prepare_geom: + shapely.prepare(self.shapely_ocean) + + if currents_interpolator is None: + if currents_data is None: + currents_data = data_zero(self.bounding_box, data_vars=("vo", "uo")) + if "land" in list(currents_data.keys()): + currents_data = currents_data.drop_vars("land") + # Next line can go inside the interpolator + currents_data = currents_data.fillna(0) + self.currents_interpolator = interp_obj( + currents_data, vars=list(currents_data.keys()) + ) + else: + self.currents_interpolator = currents_interpolator + + if waves_interpolator is None: + if waves_data is None: + waves_data = data_zero( + self.bounding_box, data_vars=("height", "direction") + ) + if "land" in list(waves_data.keys()): + waves_data = waves_data.drop_vars("land") + waves_data = waves_data.fillna(0) + self.waves_interpolator = interp_obj( + waves_data, vars=list(waves_data.keys()) + ) + else: + self.waves_interpolator = waves_interpolator + + if wind_interpolator is None: + if wind_data is None: + wind_data = data_zero( + self.bounding_box, data_vars=("vgrd10m", "ugrd10m") + ) + wind_data = wind_data.fillna(0) + self.wind_interpolator = interp_obj(wind_data, vars=list(wind_data.keys())) + else: + self.wind_interpolator = wind_interpolator + + def create_land_mask( + self, + use_ice: bool, + erode_ice: int, + waves_data: xr.Dataset | None, + land_file: str | None, + bounding_box: Iterable[float], + ) -> MultiPolygon: + """ + Create a mask with the ice and land data. + + Parameters + ---------- + use_ice : bool + If True, the ice data will be used. + erode_ice : int + Number of pixels to erode the ice mask. + waves_data : xr.Dataset + Waves dataset. + land_file : str + Path to the geojson file containing the land data. + bounding_box : Iterable[float] + Bounding box of the computation. + The order of the coordinates is: + (lat_bottom_left, lon_bottom_left, lat_up_right, lon_up_right). + + Returns + ------- + np.ndarray + Mask with the land data. + """ + # If a geojson land file is provided, start from that geometry. + # Otherwise start with an empty polygon and (optionally) build land + # from the waves NaN mask when `use_ice` is True. + if land_file is not None: + with open(land_file) as f: + land_data = json.load(f) + + shapely_ocean = shape(land_data["geometries"][0]) + # This way it can be used inside A* + shapely_ocean = crop_polygon(shapely_ocean, bounding_box) + shapely_ocean = invert_polygon(shapely_ocean, bounding_box) + else: + shapely_ocean = Polygon() + + if use_ice and waves_data is not None: + # Extract waves NaN mask (Land + Ice) + # It will take only the first timestamp + waves_mask = np.max( + np.isnan(waves_data["height"].values).astype(np.uint8), axis=0 + ) + + height, width = waves_mask.shape + + if erode_ice > 0: + # Erode `erode-ice` pixels the mask to avoid losing land resolution + kernel = np.ones((2, 2), np.uint8) + waves_mask = cv2.erode(waves_mask, kernel, iterations=erode_ice) + + # Find contours + contours, _ = cv2.findContours( + waves_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) + + # Create polygons from contours + polygons = [] + for c in contours: + if c.shape[0] > 2: + coords = [ + relative_to_latlon(y, x, height, width, bounding_box) + for x, y in c.reshape(-1, 2) + ] + polygons.append(Polygon(coords)) + + multipolygon = MultiPolygon(polygons) + multipolygon = make_valid(multipolygon) + + # This step is not needed, but it is done to ensure that the + # multipolygon is inside the bounding box + ice_multipolygon = crop_polygon(multipolygon, bounding_box) + + shapely_ocean = unary_union([ice_multipolygon, shapely_ocean]) + + if isinstance(shapely_ocean, Polygon): + shapely_ocean = MultiPolygon([shapely_ocean]) + else: + # Clean the geometry + shapely_ocean = MultiPolygon( + [p for p in shapely_ocean.geoms if isinstance(p, Polygon)] + ) + + assert shapely_ocean.is_valid, "The geometry is not valid." + + return shapely_ocean + + def unprepare_geom(self): + """Unprepare the geometry to allow pickling the object.""" + if shapely.is_prepared(self.shapely_ocean): + shapely.destroy_prepared(self.shapely_ocean) + + def prepare_geom(self): + """Prepare the geometry to speed up intersection checks.""" + if not shapely.is_prepared(self.shapely_ocean): + shapely.prepare(self.shapely_ocean) + + def get_land_edge(self, lat: np.ndarray, lon: np.ndarray) -> np.ndarray[int]: + """Return an array indicating which route edges intersect land. + + A value of 1 means an adjacent edge crosses land. + + Parameters + ---------- + lat : np.ndarray + Latitude array in degrees. + lon : np.ndarray + Longitude array in degrees. + + Returns + ------- + np.ndarray[int] + Array of 0's and 1's, where 1 is land and 0 is sea. + """ + intersects = np.zeros(len(lat), dtype=int) + + for i in range(len(lat) - 1): + line = LineString([(lon[i], lat[i]), (lon[i + 1], lat[i + 1])]) + + if self.shapely_ocean.intersects(line): + intersects[i] = 1 + intersects[i + 1] = 1 + + return intersects + + def get_land(self, lat: np.ndarray, lon: np.ndarray) -> np.ndarray[int]: + """Return an array of 0's and 1's where 1 indicates land. + + Parameters + ---------- + lat : np.ndarray + Latitude array in degrees. + lon : np.ndarray + Longitude array in degrees. + + Returns + ------- + np.ndarray[int] + Array of 0's and 1's, where 1 is land and 0 is sea. + """ + lat = np.atleast_1d(lat) + lon = np.atleast_1d(lon) + + points = [Point(lon[i], lat[i]) for i in range(len(lat))] + + return self.shapely_ocean.contains(points).astype(int) + + def get_currents( + self, lat: np.ndarray, lon: np.ndarray, time: np.ndarray, derivate: bool = False + ) -> np.ndarray: + """Interpolate currents at the given positions and times.""" + assert self.currents_interpolator is not None, "No currents data available." + + assert len(lon) == len(lat) and len(lat) == len( + time + ), "The length of the longitude, latitude and time arrays must be the same." + + derivate = ( + hasattr(self.currents_interpolator, "interpolate_derivates") and derivate + ) + + if not derivate: + data = self.currents_interpolator.interpolate(lat=lat, lon=lon, ts=time) + else: + data = self.currents_interpolator.interpolate_derivates( + lat=lat, lon=lon, ts=time + ) + + return data + + def get_waves( + self, lat: np.ndarray, lon: np.ndarray, time: np.ndarray, derivate: bool = False + ) -> np.ndarray: + """Interpolate waves data at the given positions and times.""" + assert self.waves_interpolator is not None, "No waves data available." + + assert len(lon) == len(lat) and len(lat) == len( + time + ), "The length of the longitude, latitude and time arrays must be the same." + + derivate = ( + hasattr(self.waves_interpolator, "interpolate_derivates") and derivate + ) + + if not derivate: + data = self.waves_interpolator.interpolate(lat=lat, lon=lon, ts=time) + else: + data = self.waves_interpolator.interpolate_derivates( + lat=lat, lon=lon, ts=time + ) + + return data + + def get_wind( + self, lat: np.ndarray, lon: np.ndarray, time: np.ndarray, derivate: bool = False + ) -> np.ndarray: + """Interpolate wind data at the given positions and times.""" + assert self.wind_interpolator is not None, "No wind data available." + + assert len(lon) == len(lat) and len(lat) == len( + time + ), "The length of the longitude, latitude and time arrays must be the same." + + derivate = hasattr(self.wind_interpolator, "interpolate_derivates") and derivate + + if not derivate: + data = self.wind_interpolator.interpolate(lat=lat, lon=lon, ts=time) + else: + data = self.wind_interpolator.interpolate_derivates( + lat=lat, lon=lon, ts=time + ) + + return data + + def get_beaufort( + self, + lat: np.ndarray, + lon: np.ndarray, + time: np.ndarray, + use_wind: bool = True, + asfloat: bool = False, + ): + """Return the beaufort scale at positions using wind or waves.""" + if use_wind: + return beaufort_scale( + wind_speed=np.sqrt( + (self.get_wind(lat=lat, lon=lon, time=time) ** 2).sum(axis=0) + ), + asfloat=asfloat, + ) + else: + return beaufort_scale( + wave_height=self.get_waves(lat=lat, lon=lon, time=time)[0], + asfloat=asfloat, + ) + + def _to_dict( + self, + data: tuple[np.ndarray] | np.ndarray, + var_keys: Iterable[str], + derivate: bool, + ) -> dict: + """Convert interpolator output into a dict keyed by variable names.""" + dict_data = {} + if derivate: + for i, sub_name in enumerate(["", "_dlat", "_dlon"]): + for j, key in enumerate(var_keys): + dict_data[key + sub_name] = data[i][j, :] + else: + for i, key in enumerate(var_keys): + dict_data[key] = data[i, :] + + return dict_data + + def get_data( + self, lat: np.ndarray, lon: np.ndarray, time: np.ndarray, derivate: bool = False + ) -> np.ndarray: + """Return a dictionary with interpolated data for the given positions.""" + dict_data = {} + dict_data["land"] = self.get_land(lat=lat, lon=lon) + if self.currents_interpolator is not None: + currents_data = self.get_currents( + lat=lat, lon=lon, time=time, derivate=derivate + ) + dict_data.update( + self._to_dict( + currents_data, self.currents_interpolator.vars, derivate=derivate + ) + ) + if self.waves_interpolator is not None: + waves_data = self.get_waves(lat=lat, lon=lon, time=time, derivate=derivate) + dict_data.update( + self._to_dict( + waves_data, self.waves_interpolator.vars, derivate=derivate + ) + ) + if self.wind_interpolator is not None: + wind_data = self.get_wind(lat=lat, lon=lon, time=time, derivate=derivate) + dict_data.update( + self._to_dict(wind_data, self.wind_interpolator.vars, derivate=derivate) + ) + wind: np.ndarray = wind_data[0] if derivate else wind_data + dict_data["beaufort"] = beaufort_scale(np.sqrt((wind**2).sum(axis=0))) + return dict_data diff --git a/routetools/wrr_bench/polygons.py b/routetools/wrr_bench/polygons.py new file mode 100644 index 00000000..1c84bd5c --- /dev/null +++ b/routetools/wrr_bench/polygons.py @@ -0,0 +1,213 @@ +from collections.abc import Iterable + +import h3.api.basic_int as h3 +from h3 import Polygon as H3Polygon +from shapely.geometry import MultiPolygon, Polygon + + +def invert_polygon( + polygon: Polygon | MultiPolygon, + bounding_box: Iterable[float], +) -> Polygon | MultiPolygon: + """ + Invert a Polygon or MultiPolygon with a bounding box. + + Parameters + ---------- + polygon : Union[Polygon, MultiPolygon] + The input geometry. + bounding_box : Iterable[float] + The bounding box as a list of four floats: [min_lat, min_lon, max_lat, max_lon]. + + Returns + ------- + Union[Polygon, MultiPolygon] + The inverted geometry. + """ + bounding_polygon = Polygon( + [ + (bounding_box[1], bounding_box[0]), + (bounding_box[3], bounding_box[0]), + (bounding_box[3], bounding_box[2]), + (bounding_box[1], bounding_box[2]), + ] + ) + try: + polygon = bounding_polygon.difference(polygon) + except Exception as e: + raise Exception( + f"The ocean geometry is not valid. Please check the geojson file.{polygon}" + ) from e + + return polygon + + +def crop_polygon( + multipolygon: MultiPolygon, bounding_box: Iterable[float] +) -> MultiPolygon: + """ + Crop a MultiPolygon with a bounding box. + + Parameters + ---------- + multipolygon : MultiPolygon + The input geometries as a MultiPolygon. + bounding_box : Iterable[float] + The bounding box as a list of four floats: [min_lat, min_lon, max_lat, max_lon]. + + Returns + ------- + MultiPolygon + The cropped MultiPolygon. + """ + # Crear el polígono de la bounding box + bounding_polygon = Polygon( + [ + (bounding_box[1], bounding_box[0]), + (bounding_box[3], bounding_box[0]), + (bounding_box[3], bounding_box[2]), + (bounding_box[1], bounding_box[2]), + ] + ) + + return multipolygon.intersection(bounding_polygon) + + +def relative_to_latlon( + y: float, x: float, height: int, width: int, bounding_box: Iterable[float] +) -> Iterable[float]: + """ + Transform the relative coordinates of a pixel to latitude and longitude. + + Parameters + ---------- + y : float + The relative y coordinate. + x : float + The relative x coordinate. + height : int + The height of the image. + width : int + The width of the image. + bounding_box : Iterable[float] + The bounding box as a list of four floats: [min_lat, min_lon, max_lat, max_lon]. + + Returns + ------- + Iterable[float] + The latitude and longitude of the pixel. + """ + lat_min, lon_min, lat_max, lon_max = bounding_box + lat = lat_min + (lat_max - lat_min) * (y / height) + lon = lon_min + (lon_max - lon_min) * (x / width) + + return lon, lat + + +def get_h3_cells(polygons: list[dict], res: int = 5) -> set[int]: + """ + Get the h3 cells from a list of polygons. + + Parameters + ---------- + polygons : List[dict] + A list of polygons with the coordinates of the geometry. + res : int, optional + The resolution of the h3 cells, by default 5. + + Returns + ------- + set[int] + A set of h3 cells. + """ + cells = set() + + for polygon in polygons: + exterior = [(lat, lon) for lon, lat in polygon.exterior.coords] + interior = [ + [(lat, lon) for lon, lat in interior.coords[:]] + for interior in polygon.interiors + ] + polygon_h3 = H3Polygon(exterior, *interior) + cells_polygon = h3.polygon_to_cells(polygon_h3, res) + + # TODO: compact_cells is not working due to a bug from the library. + # Once it is fixed, we can use it to reduce the number of cells + # cells_compacted = h3.compact_cells(cells_polygon) + + cells.update(cells_polygon) + + return cells + + +def remove_border_cells(cells: set[int], num_cells: int = 1) -> set[int]: + """ + Remove the cells that are next to land. + + Parameters + ---------- + cells : set[int] + A set of h3 cells. + num_cells : int, optional + Distance to border in cells, by default 1 + + Returns + ------- + set[int] + A set of h3 cells. + """ + updated_cells = set() + + for cell in cells: + neighbours = h3.grid_disk(cell, num_cells) + + for n in neighbours: + if n not in cells: + # When a neighbor is not in the cell set, means that neighbor is land + # We then consider this node as land and not + # included it in the new cell set + break + else: + updated_cells.add(cell) + + return updated_cells + + +def multipolygon_to_h3_cells( + multipolygon: Polygon | MultiPolygon, + res: int = 5, + land_dilation: int = 1, +) -> set[int]: + """ + Get the h3 cells from a geojson file. + + Parameters + ---------- + multipolygon : Union[Polygon, MultiPolygon] + A shapely polygon or multipolygon. + res : int, optional + The resolution of the h3 cells, by default 5. + land_dilation : int, optional + Distance to land from cells border, by default 1 + + Returns + ------- + set[int] + A set of h3 cells. + """ + polygons = [] + + if isinstance(multipolygon, Polygon): + polygons.append(multipolygon) + else: + # If it is a multipolygon, we need to get only the polygons from it + for geom in multipolygon.geoms: + if isinstance(geom, Polygon): + polygons.append(geom) + + cells = get_h3_cells(polygons, res=res) + + if land_dilation > 0: + cells = remove_border_cells(cells, land_dilation) + + return cells diff --git a/scripts/compare_era5_sources.py b/scripts/compare_era5_sources.py new file mode 100755 index 00000000..5b65ec6e --- /dev/null +++ b/scripts/compare_era5_sources.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python +"""Compare ERA5 data downloaded from GCS vs CDS. + +Downloads a small subset from the public GCS archive and compares it +against the existing CDS-downloaded NetCDF files in ``data/era5/``. + +Usage +----- +Compare Atlantic wind for January 2024:: + + uv run scripts/compare_era5_sources.py + +Compare a specific corridor/month:: + + uv run scripts/compare_era5_sources.py --corridor pacific --months 6 + +Compare already-downloaded files without re-downloading:: + + uv run scripts/compare_era5_sources.py --gcs-dir data/era5_gcs --skip-download +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +import numpy as np +import xarray as xr + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + +# Variable name mapping: GCS (long) → CDS (short) +VAR_MAP = { + "10m_u_component_of_wind": "u10", + "10m_v_component_of_wind": "v10", + "significant_height_of_combined_wind_waves_and_swell": "swh", + "mean_wave_direction": "mwd", +} + + +def _normalise_ds(ds: xr.Dataset) -> xr.Dataset: + """Normalise coordinate and variable names for comparison. + + - Renames ``time`` → ``valid_time`` if needed. + - Renames long GCS variable names to short CDS names. + - Drops extra CDS coordinates (``number``, ``expver``). + - Ensures ascending latitude. + """ + # Time dimension + if "time" in ds.dims and "valid_time" not in ds.dims: + ds = ds.rename({"time": "valid_time"}) + + # Variable names + rename = {k: v for k, v in VAR_MAP.items() if k in ds.data_vars} + if rename: + ds = ds.rename(rename) + + # Drop extra coords that CDS adds + for coord in ("number", "expver"): + if coord in ds.coords: + ds = ds.drop_vars(coord) + + # Ensure ascending latitude + lat_name = "latitude" if "latitude" in ds.dims else "lat" + if ds[lat_name].values[0] > ds[lat_name].values[-1]: + ds = ds.isel({lat_name: slice(None, None, -1)}) + + return ds + + +def _compare_field( + gcs_ds: xr.Dataset, + cds_ds: xr.Dataset, + var_name: str, + field_label: str, +) -> dict: + """Compare a single variable between GCS and CDS datasets. + + Returns a dict with comparison statistics. + """ + gcs_vals = gcs_ds[var_name].values + cds_vals = cds_ds[var_name].values + + result: dict = {"variable": var_name, "field": field_label} + + # Shape check + if gcs_vals.shape != cds_vals.shape: + result["match"] = False + result["error"] = ( + f"Shape mismatch: GCS {gcs_vals.shape} vs CDS {cds_vals.shape}" + ) + return result + + # NaN pattern + gcs_nan = np.isnan(gcs_vals) + cds_nan = np.isnan(cds_vals) + nan_match = np.array_equal(gcs_nan, cds_nan) + result["nan_pattern_match"] = nan_match + result["gcs_nan_count"] = int(np.sum(gcs_nan)) + result["cds_nan_count"] = int(np.sum(cds_nan)) + + # Value comparison (ignoring NaN positions) + valid = ~(gcs_nan | cds_nan) + n_valid = int(np.sum(valid)) + result["n_valid_points"] = n_valid + + if n_valid == 0: + result["match"] = nan_match + return result + + gcs_valid = gcs_vals[valid] + cds_valid = cds_vals[valid] + + # Exact match + exact = np.array_equal(gcs_valid, cds_valid) + result["exact_match"] = exact + + # Numerical differences + diff = np.abs(gcs_valid - cds_valid) + result["max_abs_diff"] = float(np.max(diff)) + result["mean_abs_diff"] = float(np.mean(diff)) + result["median_abs_diff"] = float(np.median(diff)) + + # Relative differences (avoid division by zero) + denom = np.maximum(np.abs(cds_valid), 1e-10) + rel_diff = diff / denom + result["max_rel_diff"] = float(np.max(rel_diff)) + result["mean_rel_diff"] = float(np.mean(rel_diff)) + + # Tolerance checks + result["allclose_atol1e-5"] = bool(np.allclose(gcs_valid, cds_valid, atol=1e-5)) + result["allclose_atol1e-3"] = bool(np.allclose(gcs_valid, cds_valid, atol=1e-3)) + + result["match"] = exact + return result + + +def compare_datasets( + gcs_path: Path, + cds_path: Path, + field: str, + month_slice: slice | None = None, +) -> list[dict]: + """Load and compare GCS vs CDS NetCDF files. + + Parameters + ---------- + gcs_path, cds_path : Path + Paths to the GCS and CDS NetCDF files. + field : str + ``"wind"`` or ``"waves"``. + month_slice : slice, optional + Time slice to apply to the CDS file (which may cover a full year). + + Returns + ------- + list[dict] + Per-variable comparison results. + """ + logger.info("Loading GCS: %s", gcs_path) + with xr.open_dataset(gcs_path) as raw_gcs_ds: + gcs_ds = _normalise_ds(raw_gcs_ds).load() + + logger.info("Loading CDS: %s", cds_path) + with xr.open_dataset(cds_path) as raw_cds_ds: + cds_ds = _normalise_ds(raw_cds_ds).load() + + # Align time range: slice CDS to match GCS time range + time_name = "valid_time" + gcs_times = gcs_ds[time_name].values + t_start, t_end = gcs_times[0], gcs_times[-1] + logger.info( + "GCS time range: %s → %s (%d steps)", + np.datetime_as_string(t_start, unit="h"), + np.datetime_as_string(t_end, unit="h"), + len(gcs_times), + ) + + cds_ds = cds_ds.sel({time_name: slice(t_start, t_end)}) + cds_times = cds_ds[time_name].values + logger.info( + "CDS time range (sliced): %s → %s (%d steps)", + np.datetime_as_string(cds_times[0], unit="h"), + np.datetime_as_string(cds_times[-1], unit="h"), + len(cds_times), + ) + + # Report grid comparison + for dim in ("latitude", "longitude"): + gcs_v = gcs_ds[dim].values + cds_v = cds_ds[dim].values + if np.array_equal(gcs_v, cds_v): + logger.info(" %s: EXACT match (%d points)", dim, len(gcs_v)) + else: + logger.warning( + " %s: MISMATCH — GCS %d pts [%.4f, %.4f] vs CDS %d pts [%.4f, %.4f]", + dim, + len(gcs_v), + gcs_v[0], + gcs_v[-1], + len(cds_v), + cds_v[0], + cds_v[-1], + ) + + # Time comparison + if np.array_equal(gcs_times, cds_times): + logger.info(" valid_time: EXACT match (%d steps)", len(gcs_times)) + else: + logger.warning( + " valid_time: MISMATCH — GCS %d steps vs CDS %d steps", + len(gcs_times), + len(cds_times), + ) + + # Compare variables + if field == "wind": + var_pairs = [("u10", "u-wind"), ("v10", "v-wind")] + else: + var_pairs = [("swh", "wave height"), ("mwd", "wave direction")] + + results = [] + for var_name, label in var_pairs: + if var_name not in gcs_ds.data_vars: + logger.error("Variable %s not found in GCS dataset", var_name) + continue + if var_name not in cds_ds.data_vars: + logger.error("Variable %s not found in CDS dataset", var_name) + continue + + r = _compare_field(gcs_ds, cds_ds, var_name, label) + results.append(r) + + return results + + +def print_results(results: list[dict]) -> None: + """Print comparison results in a readable table.""" + print("\n" + "=" * 72) + print("ERA5 GCS vs CDS Comparison Results") + print("=" * 72) + + all_match = True + for r in results: + print(f"\n--- {r['field']} ({r['variable']}) ---") + + if "error" in r: + print(f" ERROR: {r['error']}") + all_match = False + continue + + print(f" Valid points: {r['n_valid_points']:,}") + print(f" NaN pattern match: {r['nan_pattern_match']}") + print(f" GCS NaN count: {r['gcs_nan_count']:,}") + print(f" CDS NaN count: {r['cds_nan_count']:,}") + print(f" Exact match: {r['exact_match']}") + print(f" Max abs diff: {r['max_abs_diff']:.2e}") + print(f" Mean abs diff: {r['mean_abs_diff']:.2e}") + print(f" Median abs diff: {r['median_abs_diff']:.2e}") + print(f" Max rel diff: {r['max_rel_diff']:.2e}") + print(f" Mean rel diff: {r['mean_rel_diff']:.2e}") + print(f" allclose(atol=1e-5): {r['allclose_atol1e-5']}") + print(f" allclose(atol=1e-3): {r['allclose_atol1e-3']}") + + if not r["match"]: + all_match = False + + print("\n" + "=" * 72) + if all_match: + print("RESULT: All variables are EXACT MATCHES between GCS and CDS.") + else: + print("RESULT: Differences found between GCS and CDS (see above).") + print("=" * 72 + "\n") + + +def main() -> None: + """Download from GCS and compare against existing CDS files.""" + parser = argparse.ArgumentParser( + description="Compare ERA5 data from GCS vs CDS.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--corridor", + choices=("atlantic", "pacific"), + default="atlantic", + help="Corridor to compare (default: atlantic).", + ) + parser.add_argument( + "--months", + type=int, + nargs="+", + default=[1], + help="Month(s) to download from GCS (default: 1 = January).", + ) + parser.add_argument( + "--year", + default="2024", + help="Year (default: 2024).", + ) + parser.add_argument( + "--cds-dir", + default="data/era5", + help="Directory with existing CDS files (default: data/era5).", + ) + parser.add_argument( + "--gcs-dir", + default="data/era5_gcs_compare", + help="Directory for GCS downloads (default: data/era5_gcs_compare).", + ) + parser.add_argument( + "--skip-download", + action="store_true", + help="Skip GCS download, use existing files in --gcs-dir.", + ) + parser.add_argument( + "--fields", + nargs="+", + choices=("wind", "waves"), + default=["wind", "waves"], + help="Fields to compare (default: both).", + ) + args = parser.parse_args() + + cds_dir = Path(args.cds_dir) + gcs_dir = Path(args.gcs_dir) + + # Check CDS files exist + for field in args.fields: + cds_file = cds_dir / f"era5_{field}_{args.corridor}_{args.year}.nc" + if not cds_file.exists(): + print( + f"CDS file not found: {cds_file}\n" + f"Download it first with: uv run scripts/download_era5.py " + f"--backend cds --corridor {args.corridor} --year {args.year}", + file=sys.stderr, + ) + sys.exit(1) + + # Download from GCS + if not args.skip_download: + from routetools.era5.download_gcs import ( + download_era5_waves_gcs, + download_era5_wind_gcs, + ) + + gcs_dir.mkdir(parents=True, exist_ok=True) + + for field in args.fields: + logger.info( + "Downloading %s/%s months=%s from GCS ...", + args.corridor, + field, + args.months, + ) + if field == "wind": + download_era5_wind_gcs( + output_dir=gcs_dir, + corridor=args.corridor, + year=args.year, + months=args.months, + time_step=1, + ) + else: + download_era5_waves_gcs( + output_dir=gcs_dir, + corridor=args.corridor, + year=args.year, + months=args.months, + time_step=1, + ) + + # Compare each field + from routetools.era5.download_gcs import _output_filename + + all_results: list[dict] = [] + for field in args.fields: + gcs_file = _output_filename( + gcs_dir, field, args.corridor, args.year, args.months + ) + cds_file = cds_dir / f"era5_{field}_{args.corridor}_{args.year}.nc" + + if not gcs_file.exists(): + logger.error("GCS file not found: %s", gcs_file) + continue + + results = compare_datasets(gcs_file, cds_file, field) + all_results.extend(results) + + print_results(all_results) + + # Exit with non-zero if any mismatch + if not all(r.get("match", False) for r in all_results): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/compare_scorers.py b/scripts/compare_scorers.py new file mode 100755 index 00000000..30f0b3e0 --- /dev/null +++ b/scripts/compare_scorers.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python +"""Compare routetools JAX scorer vs self-contained numpy scorer on one route. + +Usage: + python scripts/compare_scorers.py [--case AO_noWPS] [--dep 2024-01-01] + +Loads Atlantic ERA5 data with both routetools (JAX) and the numpy scorer, +evaluates one route from the output tracks, and prints intermediate values +side-by-side to find the source of divergence. +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from datetime import datetime +from pathlib import Path + +import jax.numpy as jnp +import numpy as np + +from routetools.cost import cost_function_rise +from routetools.era5 import load_era5_wavefield, load_era5_windfield + +BASE_DIR = Path(__file__).resolve().parent.parent +SCORING_PROGRAM_DIR = BASE_DIR / "codabench" / "scoring_program" +DATA_DIR = BASE_DIR / "data" / "era5" +CODABENCH_REF = BASE_DIR / "codabench" / "reference_data" +TRACKS_DIR = BASE_DIR / "output" / "swopp3_0125_rust" / "tracks" + +# Import RISE constants and helpers from the scoring program +sys.path.insert(0, str(SCORING_PROGRAM_DIR)) +from scoring import ( # noqa: E402 + CASE_DEFS, + _forward_bearing_deg, + _haversine_m, + _interp_era5, + _load_era5_grid, + _rise_power, +) + +DTFMT = "%Y-%m-%d %H:%M:%S" + + +def load_track(case_id: str, dep_date: str) -> list[tuple[datetime, float, float]]: + """Load waypoints from a track file.""" + dep_str = dep_date.replace("-", "") + fname = f"IEUniversity-1-{case_id}-{dep_str}.csv" + path = TRACKS_DIR / fname + if not path.exists(): + raise FileNotFoundError(f"Track file not found: {path}") + waypoints = [] + with path.open() as f: + reader = csv.DictReader(f) + for row in reader: + t = datetime.strptime(row["time_utc"], DTFMT) + lat = float(row["lat_deg"]) + lon = float(row["lon_deg"]) + waypoints.append((t, lat, lon)) + return waypoints + + +def main(): + """Compare one routetools route evaluation against the numpy scorer.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", default="AO_noWPS", help="Case ID") + parser.add_argument("--dep", default="2024-01-01", help="Departure date YYYY-MM-DD") + args = parser.parse_args() + + case_id = args.case + dep_date = args.dep + case_def = CASE_DEFS[case_id] + corridor = case_def["route"] + passage_h = float(case_def["passage_h"]) + wps = case_def["wps"] + + print(f"Case: {case_id}, Corridor: {corridor}, Passage: {passage_h}h, WPS: {wps}") + print(f"Departure: {dep_date}") + print() + + # Load track + waypoints = load_track(case_id, dep_date) + n_wp = len(waypoints) + n_seg = n_wp - 1 + dt_h = passage_h / n_seg + print(f"Track: {n_wp} waypoints, {n_seg} segments, dt_h={dt_h:.4f}") + + lats = np.array([wp[1] for wp in waypoints]) + lons = np.array([wp[2] for wp in waypoints]) + dep_dt = waypoints[0][0] + dep_str = dep_dt.strftime(DTFMT) + + # ================================================================ + # ROUTETOOLS JAX evaluation + # ================================================================ + print("\n" + "=" * 70) + print("ROUTETOOLS (JAX) EVALUATION") + print("=" * 70) + + # Find ERA5 files (prefer 6-hourly from codabench/reference_data) + wind_files = [] + wave_files = [] + for d in (CODABENCH_REF, DATA_DIR): + wf = d / f"era5_wind_{corridor}_2024.nc" + vf = d / f"era5_waves_{corridor}_2024.nc" + if wf.exists() and vf.exists(): + wind_files = [str(wf)] + wave_files = [str(vf)] + for suf in ( + f"era5_wind_{corridor}_2025_01.nc", + f"era5_wind_{corridor}_2025.nc", + ): + p = d / suf + if p.exists(): + wind_files.append(str(p)) + break + for suf in ( + f"era5_waves_{corridor}_2025_01.nc", + f"era5_waves_{corridor}_2025.nc", + ): + p = d / suf + if p.exists(): + wave_files.append(str(p)) + break + print(f"Using ERA5 data from: {d}") + break + else: + print("ERROR: No ERA5 data found!") + return + + # Build curve array: shape (1, L, 2) with (lon, lat) per routetools convention + curve_jax = jnp.array( + [[lon, lat] for lat, lon in zip(lats, lons, strict=False)], dtype=jnp.float32 + )[None, :, :] # (1, L, 2) + + # Load fields with departure_time + windfield = load_era5_windfield(wind_files, departure_time=dep_str) + wavefield = load_era5_wavefield(wave_files, departure_time=dep_str) + + # Full energy via cost_function_rise + energy_jax = float( + cost_function_rise( + windfield=windfield, + curve=curve_jax, + travel_time=passage_h, + wavefield=wavefield, + wps=wps, + time_offset=0.0, + )[0] + ) + print(f"\nJAX energy: {energy_jax:.6f} MWh") + + # Now compute intermediate values for comparison + mid_lon_jax = (curve_jax[0, :-1, 0] + curve_jax[0, 1:, 0]) / 2 + mid_lat_jax = (curve_jax[0, :-1, 1] + curve_jax[0, 1:, 1]) / 2 + seg_times_jax = (jnp.arange(n_seg) + 0.5) * dt_h # relative to departure + + # Interpolate weather + u10_jax, v10_jax = windfield(mid_lon_jax, mid_lat_jax, seg_times_jax) + hs_jax, mwd_jax = wavefield(mid_lon_jax, mid_lat_jax, seg_times_jax) + + # Bearing + lon1 = jnp.radians(curve_jax[0, :-1, 0]) + lon2 = jnp.radians(curve_jax[0, 1:, 0]) + lat1 = jnp.radians(curve_jax[0, :-1, 1]) + lat2 = jnp.radians(curve_jax[0, 1:, 1]) + dlon = lon2 - lon1 + x_b = jnp.sin(dlon) * jnp.cos(lat2) + y_b = jnp.cos(lat1) * jnp.sin(lat2) - jnp.sin(lat1) * jnp.cos(lat2) * jnp.cos(dlon) + bearing_jax = jnp.mod(jnp.degrees(jnp.arctan2(x_b, y_b)), 360.0) + + # Wind angles + tws_jax = jnp.sqrt(u10_jax**2 + v10_jax**2) + wind_from_jax = jnp.mod(180.0 + jnp.degrees(jnp.arctan2(u10_jax, v10_jax)), 360.0) + twa_jax = jnp.mod(wind_from_jax - bearing_jax, 360.0) + mwa_jax = jnp.mod(mwd_jax - bearing_jax, 360.0) + + # Distance and speed (routetools uses equirectangular approx) + from routetools._cost.haversine import haversine_meters_components + + dx_m, dy_m = haversine_meters_components( + curve_jax[0, :-1, 1], + curve_jax[0, :-1, 0], + curve_jax[0, 1:, 1], + curve_jax[0, 1:, 0], + ) + dist_jax = jnp.sqrt(dx_m**2 + dy_m**2) + v_mps_jax = dist_jax / (dt_h * 3600.0) + + # RISE power + from routetools.performance import predict_power_jax + + power_jax = predict_power_jax(tws_jax, twa_jax, hs_jax, mwa_jax, v_mps_jax, wps=wps) + + # ================================================================ + # NUMPY SCORER evaluation + # ================================================================ + print("\n" + "=" * 70) + print("NUMPY SCORER EVALUATION") + print("=" * 70) + + wind_grid = _load_era5_grid(wind_files) + wave_grid = _load_era5_grid(wave_files) + + mid_lat_np = (lats[:-1] + lats[1:]) / 2 + mid_lon_np = (lons[:-1] + lons[1:]) / 2 + + dep_dt64 = np.datetime64(dep_str) + dep_offset_h = float((dep_dt64 - wind_grid["t0"]) / np.timedelta64(1, "h")) + seg_times_np = dep_offset_h + (np.arange(n_seg) + 0.5) * dt_h + + u10_np = _interp_era5(wind_grid, "u10", mid_lat_np, mid_lon_np, seg_times_np) + v10_np = _interp_era5(wind_grid, "v10", mid_lat_np, mid_lon_np, seg_times_np) + swh_np = _interp_era5(wave_grid, "swh", mid_lat_np, mid_lon_np, seg_times_np) + mwd_np = _interp_era5(wave_grid, "mwd", mid_lat_np, mid_lon_np, seg_times_np) + np.nan_to_num(swh_np, copy=False, nan=0.0) + np.nan_to_num(mwd_np, copy=False, nan=0.0) + + dist_np = _haversine_m(lats[:-1], lons[:-1], lats[1:], lons[1:]) + v_mps_np = dist_np / (dt_h * 3600.0) + + bearing_np = _forward_bearing_deg(lats[:-1], lons[:-1], lats[1:], lons[1:]) + + tws_np = np.sqrt(u10_np**2 + v10_np**2) + wind_from_np = np.mod(180.0 + np.degrees(np.arctan2(u10_np, v10_np)), 360.0) + twa_np = np.mod(wind_from_np - bearing_np, 360.0) + mwa_np = np.mod(mwd_np - bearing_np, 360.0) + + power_np = _rise_power(tws_np, twa_np, swh_np, mwa_np, v_mps_np, wps) + energy_np = float(np.sum(power_np) * dt_h / 1000.0) + + print(f"\nNumpy energy: {energy_np:.6f} MWh") + + # ================================================================ + # COMPARISON + # ================================================================ + print("\n" + "=" * 70) + print("COMPARISON") + print("=" * 70) + + print( + f"\nEnergy — JAX: {energy_jax:.6f} Numpy: {energy_np:.6f} " + f"diff: {abs(energy_jax - energy_np):.6f} MWh " + f"({100 * abs(energy_jax - energy_np) / energy_jax:.2f}%)" + ) + + # Show first 5 and last 5 segments + segments_to_show = list(range(min(5, n_seg))) + list( + range(max(0, n_seg - 5), n_seg) + ) + segments_to_show = sorted(set(segments_to_show)) + + def pct(a, b): + if a == 0: + return 0.0 + return 100 * abs(a - b) / abs(a) + + print( + f"\n{'seg':>4} | {'u10_jax':>10} {'u10_np':>10} {'err%':>6} | " + f"{'v10_jax':>10} {'v10_np':>10} {'err%':>6}" + ) + print("-" * 80) + for i in segments_to_show: + print( + f"{i:4d} | {float(u10_jax[i]):10.4f} {float(u10_np[i]):10.4f} " + f"{pct(float(u10_jax[i]), float(u10_np[i])):6.2f} | " + f"{float(v10_jax[i]):10.4f} {float(v10_np[i]):10.4f} " + f"{pct(float(v10_jax[i]), float(v10_np[i])):6.2f}" + ) + + print( + f"\n{'seg':>4} | {'bear_jax':>10} {'bear_np':>10} {'err%':>6} | " + f"{'dist_jax':>12} {'dist_np':>12} {'err%':>6}" + ) + print("-" * 80) + for i in segments_to_show: + print( + f"{i:4d} | {float(bearing_jax[i]):10.4f} {float(bearing_np[i]):10.4f} " + f"{pct(float(bearing_jax[i]), float(bearing_np[i])):6.2f} | " + f"{float(dist_jax[i]):12.2f} {float(dist_np[i]):12.2f} " + f"{pct(float(dist_jax[i]), float(dist_np[i])):6.2f}" + ) + + print( + f"\n{'seg':>4} | {'tws_jax':>10} {'tws_np':>10} {'err%':>6} | " + f"{'twa_jax':>10} {'twa_np':>10} {'err%':>6}" + ) + print("-" * 80) + for i in segments_to_show: + print( + f"{i:4d} | {float(tws_jax[i]):10.4f} {float(tws_np[i]):10.4f} " + f"{pct(float(tws_jax[i]), float(tws_np[i])):6.2f} | " + f"{float(twa_jax[i]):10.4f} {float(twa_np[i]):10.4f} " + f"{pct(float(twa_jax[i]), float(twa_np[i])):6.2f}" + ) + + print( + f"\n{'seg':>4} | {'hs_jax':>10} {'hs_np':>10} {'err%':>6} | " + f"{'mwa_jax':>10} {'mwa_np':>10} {'err%':>6}" + ) + print("-" * 80) + for i in segments_to_show: + print( + f"{i:4d} | {float(hs_jax[i]):10.4f} {float(swh_np[i]):10.4f} " + f"{pct(float(hs_jax[i]), float(swh_np[i])):6.2f} | " + f"{float(mwa_jax[i]):10.4f} {float(mwa_np[i]):10.4f} " + f"{pct(float(mwa_jax[i]), float(mwa_np[i])):6.2f}" + ) + + print( + f"\n{'seg':>4} | {'v_jax':>10} {'v_np':>10} {'err%':>6} | " + f"{'pow_jax':>12} {'pow_np':>12} {'err%':>6}" + ) + print("-" * 80) + for i in segments_to_show: + print( + f"{i:4d} | {float(v_mps_jax[i]):10.4f} {float(v_mps_np[i]):10.4f} " + f"{pct(float(v_mps_jax[i]), float(v_mps_np[i])):6.2f} | " + f"{float(power_jax[i]):12.4f} {float(power_np[i]):12.4f} " + f"{pct(float(power_jax[i]), float(power_np[i])):6.2f}" + ) + + # Aggregate comparison + print("\nAggregate diffs (RMS):") + print(f" u10: {np.sqrt(np.mean((np.array(u10_jax) - u10_np) ** 2)):.6f}") + print(f" v10: {np.sqrt(np.mean((np.array(v10_jax) - v10_np) ** 2)):.6f}") + print(f" swh: {np.sqrt(np.mean((np.array(hs_jax) - swh_np) ** 2)):.6f}") + print(f" mwd: {np.sqrt(np.mean((np.array(mwd_jax) - mwd_np) ** 2)):.6f}") + print( + f" bearing: {np.sqrt(np.mean((np.array(bearing_jax) - bearing_np) ** 2)):.6f}" + ) + print(f" dist: {np.sqrt(np.mean((np.array(dist_jax) - dist_np) ** 2)):.6f}") + print(f" v_mps: {np.sqrt(np.mean((np.array(v_mps_jax) - v_mps_np) ** 2)):.6f}") + print(f" tws: {np.sqrt(np.mean((np.array(tws_jax) - tws_np) ** 2)):.6f}") + print(f" twa: {np.sqrt(np.mean((np.array(twa_jax) - twa_np) ** 2)):.6f}") + print(f" mwa: {np.sqrt(np.mean((np.array(mwa_jax) - mwa_np) ** 2)):.6f}") + print(f" power: {np.sqrt(np.mean((np.array(power_jax) - power_np) ** 2)):.6f}") + + +if __name__ == "__main__": + main() diff --git a/scripts/download_era5.py b/scripts/download_era5.py new file mode 100755 index 00000000..ed52b4fd --- /dev/null +++ b/scripts/download_era5.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python +"""Download ERA5 weather data for SWOPP3 route corridors. + +Provides a single entry point for downloading ERA5 wind and wave data +needed by the routetools weather-routing pipeline. Two backends are +supported: + +- **GCS** (default): Downloads from the public Google Cloud archive + (WeatherBench2 / Pangeo). No API key required. +- **CDS**: Downloads from the Copernicus Climate Data Store. + Requires ``cdsapi`` and a valid CDS API key. + +Usage +----- +Download all data for 2024 (both Atlantic and Pacific corridors):: + + uv run scripts/download_era5.py + +Download only the Atlantic corridor for 2023 via GCS:: + + uv run scripts/download_era5.py --corridor atlantic --year 2023 + +Download 6-hourly data instead of hourly (smaller files):: + + uv run scripts/download_era5.py --time-step 6 + +Download via the CDS API instead:: + + uv run scripts/download_era5.py --backend cds + +Output +------ +Downloaded files are stored in ``data/era5/`` by default:: + + data/era5/ + ├── era5_wind_atlantic_2024.nc + ├── era5_waves_atlantic_2024.nc + ├── era5_wind_pacific_2024.nc + └── era5_waves_pacific_2024.nc + +These filenames are the defaults consumed by ``scripts/swopp3_run.py``. A new +user can therefore run the full SWOPP3 pipeline without overriding any paths:: + + uv run scripts/download_era5.py + uv run scripts/swopp3_run.py + +If you download a different year or only one corridor, ``scripts/swopp3_run.py`` +must be given matching ``--wind-path*`` and ``--wave-path*`` options. + +The downloaded files can also be loaded programmatically via:: + + from routetools.era5 import load_era5_windfield, load_era5_wavefield + + windfield = load_era5_windfield("data/era5/era5_wind_atlantic_2024.nc") + wavefield = load_era5_wavefield("data/era5/era5_waves_atlantic_2024.nc") +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + +BACKENDS = ("gcs", "cds") +CORRIDORS = ("atlantic", "pacific") + + +def main() -> None: + """Parse CLI arguments and download ERA5 files for selected corridors.""" + parser = argparse.ArgumentParser( + description="Download ERA5 wind and wave data for SWOPP3 corridors.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--backend", + choices=BACKENDS, + default="gcs", + help="Download backend: 'gcs' (default, no API key) or 'cds'.", + ) + parser.add_argument( + "--corridor", + choices=CORRIDORS, + default=None, + help="Download a single corridor. Default: both.", + ) + parser.add_argument( + "--year", + default="2024", + help="Year to download (default: 2024).", + ) + parser.add_argument( + "--output-dir", + default="data/era5", + help="Output directory (default: data/era5).", + ) + parser.add_argument( + "--months", + type=int, + nargs="+", + default=None, + help="Month(s) to download, e.g. --months 1 2 for Jan-Feb. " + "Default: all months.", + ) + parser.add_argument( + "--time-step", + type=int, + default=1, + help="Hours between time steps (default: 1). GCS backend only.", + ) + args = parser.parse_args() + + corridors = [args.corridor] if args.corridor else list(CORRIDORS) + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + logger.info( + "Backend=%s Year=%s Corridors=%s Months=%s Output=%s", + args.backend, + args.year, + corridors, + args.months or "all", + output_dir, + ) + + if args.backend == "gcs": + from routetools.era5.download_gcs import download_all_gcs + + files = download_all_gcs( + output_dir=output_dir, + year=args.year, + corridors=corridors, + time_step=args.time_step, + months=args.months, + ) + elif args.backend == "cds": + from routetools.era5.download_cds import download_all + + files = download_all( + output_dir=output_dir, + year=args.year, + corridors=corridors, + months=args.months, + ) + else: + print(f"Unknown backend: {args.backend}", file=sys.stderr) + sys.exit(1) + + logger.info("Download complete. %d files:", len(files)) + for f in files: + logger.info(" %s", f) + + +if __name__ == "__main__": + main() diff --git a/scripts/era5_benchmark.py b/scripts/era5_benchmark.py new file mode 100755 index 00000000..4384b7a5 --- /dev/null +++ b/scripts/era5_benchmark.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python +"""Real-world benchmark: USNYC → DEHAM using ERA5 weather data. + +This script demonstrates the full ERA5-based weather routing pipeline: + +1. Load ERA5 wind and wave fields from downloaded NetCDF files. +2. Create a Natural Earth high-resolution land mask. +3. Optimize a route from New York (USNYC) to Hamburg (DEHAM) departing + on 2023-01-08 00:00 UTC, at 12 knots through water. +4. Refine the route with the FMS smoother. +5. Plot and save the result. + +Prerequisites +------------- +Download ERA5 data for the Atlantic corridor first:: + + uv run scripts/download_era5.py --corridor atlantic --year 2023 + +This creates ``data/era5/era5_wind_atlantic_2023.nc`` and +``data/era5/era5_waves_atlantic_2023.nc``. + +Usage +----- +:: + + uv run scripts/era5_benchmark.py + +To change the departure date:: + + uv run scripts/era5_benchmark.py --departure 2023-02-15 + +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import jax.numpy as jnp +import matplotlib.pyplot as plt + +from routetools._ports import DICT_PORTS +from routetools.cmaes import optimize +from routetools.cost import haversine_distance_from_curve +from routetools.era5 import ( + load_era5_wavefield, + load_era5_windfield, + load_natural_earth_land_mask, +) +from routetools.fms import optimize_fms +from routetools.plot import plot_curve + + +def main() -> None: + """Run an end-to-end ERA5 benchmark from USNYC to DEHAM.""" + parser = argparse.ArgumentParser( + description="ERA5-based benchmark: USNYC → DEHAM.", + ) + parser.add_argument( + "--departure", + default="2023-01-08T00:00:00", + help="Departure datetime (default: 2023-01-08T00:00:00).", + ) + parser.add_argument( + "--vel-ship", + type=float, + default=6.0, + help="Speed through water in m/s (≈12 kn). Default: 6.0.", + ) + parser.add_argument( + "--data-dir", + default="data/era5", + help="Directory containing ERA5 NetCDF files.", + ) + parser.add_argument( + "--output-dir", + default="output", + help="Directory for output plots.", + ) + parser.add_argument( + "--K", type=int, default=10, help="Free Bézier control points (default: 10)." + ) + parser.add_argument( + "--L", type=int, default=320, help="Curve discretisation points (default: 320)." + ) + parser.add_argument( + "--popsize", type=int, default=500, help="CMA-ES population (default: 500)." + ) + parser.add_argument( + "--maxfevals", + type=int, + default=int(1e8), + help="Max CMA-ES evaluations (default: 1e8).", + ) + parser.add_argument("--seed", type=int, default=42, help="Random seed.") + args = parser.parse_args() + + data_dir = Path(args.data_dir) + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + wind_file = data_dir / "era5_wind_atlantic_2023.nc" + wave_file = data_dir / "era5_waves_atlantic_2023.nc" + + for f in (wind_file, wave_file): + if not f.exists(): + raise FileNotFoundError( + f"Missing {f}. Download first with:\n" + " uv run scripts/download_era5.py --corridor atlantic --year 2023" + ) + + # ── Load ERA5 fields ────────────────────────────────────────────── + print(f"[ERA5] Loading wind from {wind_file}") + windfield = load_era5_windfield(wind_file, departure_time=args.departure) + + print(f"[ERA5] Loading waves from {wave_file}") + wavefield = load_era5_wavefield(wave_file, departure_time=args.departure) + + # No ocean current data — use a zero vectorfield (SWOPP uses wind + # via the performance model, not as current in the cost function). + def vectorfield(lon, lat, t): + return jnp.zeros_like(lon), jnp.zeros_like(lon) + + # ── Build land mask ─────────────────────────────────────────────── + # Atlantic corridor bounds (must cover Hamburg at ~54°N going around UK) + lon_range = (-80.0, 10.0) + lat_range = (25.0, 60.0) + + print("[ERA5] Building Natural Earth land mask...") + land = load_natural_earth_land_mask(lon_range=lon_range, lat_range=lat_range) + + # ── Source / destination ────────────────────────────────────────── + src = jnp.array([DICT_PORTS["USNYC"]["lon"], DICT_PORTS["USNYC"]["lat"]]) + dst = jnp.array([DICT_PORTS["DEHAM"]["lon"], DICT_PORTS["DEHAM"]["lat"]]) + + # ── CMA-ES optimisation ────────────────────────────────────────── + print("[CMA-ES] Optimising route USNYC → DEHAM ...") + curve_cmaes, dict_cmaes = optimize( + vectorfield=vectorfield, + src=src, + dst=dst, + land=land, + wavefield=wavefield, + windfield=windfield, + travel_stw=args.vel_ship, + travel_time=None, + penalty=1e10, + K=args.K, + L=args.L, + num_pieces=1, + popsize=args.popsize, + sigma0=1.0, + tolfun=60.0, + damping=1.0, + maxfevals=args.maxfevals, + weight_l1=1.0, + weight_l2=0.0, + spherical_correction=True, + keep_top=0.002, + seed=args.seed, + verbose=True, + ) + print(f"[CMA-ES] Done — cost = {dict_cmaes['cost'] / 3600:.1f} hours") + + # ── FMS refinement ─────────────────────────────────────────────── + print("[FMS] Refining route ...") + curve_fms, dict_fms = optimize_fms( + vectorfield=vectorfield, + curve=curve_cmaes, + land=land, + travel_stw=args.vel_ship, + travel_time=None, + patience=100, + damping=0.9, + maxfevals=int(1e6), + weight_l1=1.0, + weight_l2=0.0, + spherical_correction=True, + seed=args.seed, + verbose=True, + ) + print(f"[FMS] Done — cost = {sum(dict_fms['cost']) / 3600:.1f} hours") + + # ── Metrics ────────────────────────────────────────────────────── + dist_cmaes = float(jnp.sum(haversine_distance_from_curve(curve_cmaes))) / 1000 + dist_fms = float(jnp.sum(haversine_distance_from_curve(curve_fms[0]))) / 1000 + + print(f"\n{'Route':<12} {'Cost (h)':>10} {'Distance (km)':>14}") + print("-" * 38) + print(f"{'CMA-ES':<12} {dict_cmaes['cost'] / 3600:>10.1f} {dist_cmaes:>14.0f}") + print(f"{'FMS':<12} {sum(dict_fms['cost']) / 3600:>10.1f} {dist_fms:>14.0f}") + + # ── Plot ───────────────────────────────────────────────────────── + fig, ax = plot_curve( + vectorfield=vectorfield, + ls_curve=[curve_cmaes, curve_fms[0]], + ls_name=[ + f"CMA-ES ({dict_cmaes['cost'] / 3600:.0f} h, {dist_cmaes:.0f} km)", + f"FMS ({sum(dict_fms['cost']) / 3600:.0f} h, {dist_fms:.0f} km)", + ], + land=land, + gridstep=1 / 12, + figsize=(10, 6), + xlim=(lon_range[0], lon_range[1]), + ylim=(lat_range[0], lat_range[1]), + color_currents=True, + ) + ax.set_title(f"USNYC → DEHAM | {args.departure} | {2 * args.vel_ship:.0f} kn") + fig.tight_layout() + + outfile = output_dir / "era5_benchmark_usnyc_deham.jpg" + fig.savefig(outfile, dpi=300) + plt.close() + print(f"\n[SAVED] {outfile}") + + +if __name__ == "__main__": + main() diff --git a/scripts/parametric_benchmark.py b/scripts/parametric_benchmark.py new file mode 100755 index 00000000..5936b558 --- /dev/null +++ b/scripts/parametric_benchmark.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Standalone benchmark: routetools.performance vs SWOPP3 reference. + +Runs comprehensive comparisons and prints detailed statistics, error +distributions, and per-component breakdowns. Not a pytest file — run +directly: + + python scripts/parametric_benchmark.py + +Requires: swopp3_performance_model, numpy, routetools +""" + +from __future__ import annotations + +import sys +import time + +import numpy as np + +try: + from swopp3_performance_model import predict_no_wps, predict_with_wps +except ImportError: + print("ERROR: swopp3_performance_model wheel is not installed.", file=sys.stderr) + sys.exit(1) + +from routetools.performance import ( + A_W, + K_A, + K_H, + K_W, +) +from routetools.performance import ( + predict_power_no_wps as parametric_no_wps, +) +from routetools.performance import ( + predict_power_with_wps as parametric_with_wps, +) + + +# ----------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------- +def error_stats(errs: np.ndarray) -> dict[str, float]: + """Return aggregate statistics for an array of absolute or relative errors.""" + return { + "mean": float(np.mean(errs)), + "std": float(np.std(errs)), + "p50": float(np.median(errs)), + "p95": float(np.percentile(errs, 95)), + "p99": float(np.percentile(errs, 99)), + "max": float(np.max(errs)), + } + + +def print_stats(label: str, stats: dict[str, float]) -> None: + """Print a compact one-line summary of precomputed error statistics.""" + print(f" {label}:") + print( + f" mean={stats['mean']:.6f} std={stats['std']:.6f} " + f"p50={stats['p50']:.6f} p95={stats['p95']:.6f} " + f"p99={stats['p99']:.6f} max={stats['max']:.6f}" + ) + + +# ----------------------------------------------------------------------- +# Benchmark routines +# ----------------------------------------------------------------------- +def benchmark_no_wps(n: int = 10_000, seed: int = 42) -> None: + """Benchmark no-WPS parametric predictions against SWOPP3 reference.""" + print(f"\n{'=' * 72}") + print(f" predict_no_wps — {n:,} random samples (seed={seed})") + print(f"{'=' * 72}") + + rng = np.random.default_rng(seed) + tws = rng.uniform(0, 30, n) + twa = rng.uniform(0, 180, n) + swh = rng.uniform(0, 10, n) + mwa = rng.uniform(0, 180, n) + v = rng.uniform(0, 14.5, n) + + refs = np.empty(n) + pars = np.empty(n) + + t0 = time.perf_counter() + for i in range(n): + refs[i] = predict_no_wps(tws[i], twa[i], swh[i], mwa[i], v[i]) + t_ref = time.perf_counter() - t0 + + t0 = time.perf_counter() + for i in range(n): + pars[i] = parametric_no_wps(tws[i], twa[i], swh[i], mwa[i], v[i]) + t_par = time.perf_counter() - t0 + + abs_errs = np.abs(pars - refs) + # Relative error (only where ref > 1 kW to avoid div-by-zero noise) + mask = refs > 1.0 + rel_errs = np.full(n, np.nan) + rel_errs[mask] = abs_errs[mask] / refs[mask] * 100 # percent + + print( + f"\n Timing: reference {t_ref:.3f}s ({t_ref / n * 1e6:.1f} µs/call) | " + f"parametric {t_par:.3f}s ({t_par / n * 1e6:.1f} µs/call)" + ) + print("\n Absolute error (kW):") + print_stats("all", error_stats(abs_errs)) + if mask.sum() > 0: + print(f"\n Relative error (%, where ref > 1 kW, n={mask.sum():,}):") + print_stats("filtered", error_stats(rel_errs[mask])) + + # Worst cases + worst_idx = np.argsort(abs_errs)[-5:][::-1] + print("\n Top-5 worst absolute errors:") + print( + f" {'TWS':>6} {'TWA':>6} {'SWH':>6} {'MWA':>6} {'V':>6} " + f"{'Ref':>10} {'Par':>10} {'Err':>10}" + ) + for idx in worst_idx: + print( + f" {tws[idx]:6.2f} {twa[idx]:6.1f} {swh[idx]:6.2f} " + f"{mwa[idx]:6.1f} {v[idx]:6.2f} " + f"{refs[idx]:10.4f} {pars[idx]:10.4f} {abs_errs[idx]:10.4f}" + ) + + n_exact = np.sum(abs_errs < 0.001) + n_good = np.sum(abs_errs < 0.01) + n_ok = np.sum(abs_errs < 0.1) + print("\n Error distribution:") + print(f" < 0.001 kW : {n_exact:6,} / {n:,} ({n_exact / n * 100:.1f}%)") + print(f" < 0.01 kW : {n_good:6,} / {n:,} ({n_good / n * 100:.1f}%)") + print(f" < 0.1 kW : {n_ok:6,} / {n:,} ({n_ok / n * 100:.1f}%)") + print(f" ≥ 0.1 kW : {n - n_ok:6,} / {n:,} ({(n - n_ok) / n * 100:.1f}%)") + + +def benchmark_with_wps( + n: int = 10_000, + seed: int = 99, +) -> None: + """Benchmark with-WPS parametric predictions against SWOPP3 reference.""" + print(f"\n{'=' * 72}") + print(f" predict_with_wps — {n:,} random samples (seed={seed})") + print(f"{'=' * 72}") + + rng = np.random.default_rng(seed) + tws = rng.uniform(0, 30, n) + twa = rng.uniform(0, 180, n) + swh = rng.uniform(0, 10, n) + mwa = rng.uniform(0, 180, n) + v = rng.uniform(0, 14.5, n) + + refs = np.empty(n) + pars = np.empty(n) + + t0 = time.perf_counter() + for i in range(n): + refs[i] = predict_with_wps(tws[i], twa[i], swh[i], mwa[i], v[i]) + t_ref = time.perf_counter() - t0 + + t0 = time.perf_counter() + for i in range(n): + pars[i] = parametric_with_wps( + tws[i], + twa[i], + swh[i], + mwa[i], + v[i], + ) + t_par = time.perf_counter() - t0 + + abs_errs = np.abs(pars - refs) + mask = refs > 1.0 + rel_errs = np.full(n, np.nan) + rel_errs[mask] = abs_errs[mask] / refs[mask] * 100 + + print( + f"\n Timing: reference {t_ref:.3f}s ({t_ref / n * 1e6:.1f} µs/call) | " + f"parametric {t_par:.3f}s ({t_par / n * 1e6:.1f} µs/call)" + ) + print("\n Absolute error (kW):") + print_stats("all", error_stats(abs_errs)) + if mask.sum() > 0: + print(f"\n Relative error (%, where ref > 1 kW, n={mask.sum():,}):") + print_stats("filtered", error_stats(rel_errs[mask])) + + worst_idx = np.argsort(abs_errs)[-5:][::-1] + print("\n Top-5 worst absolute errors:") + print( + f" {'TWS':>6} {'TWA':>6} {'SWH':>6} {'MWA':>6} {'V':>6} " + f"{'Ref':>10} {'Par':>10} {'Err':>10}" + ) + for idx in worst_idx: + print( + f" {tws[idx]:6.2f} {twa[idx]:6.1f} {swh[idx]:6.2f} " + f"{mwa[idx]:6.1f} {v[idx]:6.2f} " + f"{refs[idx]:10.4f} {pars[idx]:10.4f} {abs_errs[idx]:10.4f}" + ) + + n_exact = np.sum(abs_errs < 0.01) + n_good = np.sum(abs_errs < 0.1) + n_ok = np.sum(abs_errs < 1.0) + print("\n Error distribution:") + print(f" < 0.01 kW : {n_exact:6,} / {n:,} ({n_exact / n * 100:.1f}%)") + print(f" < 0.1 kW : {n_good:6,} / {n:,} ({n_good / n * 100:.1f}%)") + print(f" < 1.0 kW : {n_ok:6,} / {n:,} ({n_ok / n * 100:.1f}%)") + print(f" ≥ 1.0 kW : {n - n_ok:6,} / {n:,} ({(n - n_ok) / n * 100:.1f}%)") + + +def benchmark_component_breakdown() -> None: + """Show per-component accuracy vs reference decomposition.""" + print(f"\n{'=' * 72}") + print(" Component breakdown (hull / wind / wave)") + print(f"{'=' * 72}") + + speeds = [2, 4, 6, 8, 10, 12, 14] + + # Hull + print(f"\n Hull: P_hull = K_h · v³ (K_h = {K_H:.6f})") + print(f" {'v':>5} {'Ref':>10} {'Par':>10} {'Err':>10} {'Ratio':>10}") + for v in speeds: + ref = predict_no_wps(0, 0, 0, 0, v) + par = K_H * v**3 + print( + f" {v:5.1f} {ref:10.4f} {par:10.4f} {abs(par - ref):10.6f} " + f"{ref / v**3 if v > 0 else 0:10.6f}" + ) + + # Wind (TWA sweep at fixed tws=15, v=8) + print(f"\n Wind: K_a · v · (VR·ux − v²) (K_a = {K_A:.6f})") + print( + f" {'TWA':>5} {'TWS':>5} {'V':>5} " + f"{'Ref_wind':>10} {'Par_wind':>10} {'Err':>10}" + ) + tws_test, v_test = 15.0, 8.0 + p_hull = predict_no_wps(0, 0, 0, 0, v_test) + for twa in [0, 30, 60, 90, 120, 150, 180]: + twa_rad = np.radians(twa) + ux = tws_test * np.cos(twa_rad) + v_test + uy = tws_test * np.sin(twa_rad) + vr = np.sqrt(ux**2 + uy**2) + total_ref = predict_no_wps(tws_test, twa, 0, 0, v_test) + # If the model clamps total power at zero (strong tailwind), + # the decomposition total = hull + wind is invalid. + ref_wind = total_ref - p_hull if total_ref > 0.0 else float("nan") + par_wind = K_A * v_test * (vr * ux - v_test**2) + print( + f" {twa:5} {tws_test:5.0f} {v_test:5.0f} {ref_wind:10.4f} " + f"{par_wind:10.4f} {abs(par_wind - ref_wind):10.6f}" + ) + + # Wave (MWA sweep at fixed swh=3, v=8) + print("\n Wave: A_w·swh²·v^1.5·exp(−k_w·|mwa|³)") + print(f" A_w = {A_W:.4f}, k_w = {K_W:.5f}") + print( + f" {'MWA':>5} {'SWH':>5} {'V':>5} " + f"{'Ref_wave':>10} {'Par_wave':>10} {'Err':>10}" + ) + swh_test, v_test2 = 3.0, 8.0 + p_hull2 = predict_no_wps(0, 0, 0, 0, v_test2) + for mwa in [0, 20, 40, 60, 80, 100, 120, 140, 160, 180]: + mwa_rad = np.radians(mwa) + ref_wave = predict_no_wps(0, 0, swh_test, mwa, v_test2) - p_hull2 + par_wave = A_W * swh_test**2 * v_test2**1.5 * np.exp(-K_W * abs(mwa_rad) ** 3) + print( + f" {mwa:5} {swh_test:5.0f} {v_test2:5.0f} {ref_wave:10.4f} " + f"{par_wave:10.4f} {abs(par_wave - ref_wave):10.6f}" + ) + + +def benchmark_sail_savings() -> None: + """Show sail power savings across conditions.""" + print(f"\n{'=' * 72}") + print(" Sail power savings P_sail = P_no_wps − P_with_wps") + print(f"{'=' * 72}") + + print("\n P_sail at v=8 m/s, varying TWS and TWA:") + print(f" {'TWS':>5} " + " ".join(f"TWA={t:>3}°" for t in range(0, 181, 30))) + for tws in [0, 5, 10, 15, 20, 25, 30]: + row = f" {tws:5}" + for twa in range(0, 181, 30): + p_no = predict_no_wps(tws, twa, 0, 0, 8) + p_wp = predict_with_wps(tws, twa, 0, 0, 8) + p_sail = p_no - p_wp + row += f" {p_sail:8.1f}" + print(row) + + print("\n Savings % at v=8 m/s (where P_no_wps > 0):") + print(f" {'TWS':>5} " + " ".join(f"TWA={t:>3}°" for t in range(0, 181, 30))) + for tws in [5, 10, 15, 20, 25, 30]: + row = f" {tws:5}" + for twa in range(0, 181, 30): + p_no = predict_no_wps(tws, twa, 0, 0, 8) + p_wp = predict_with_wps(tws, twa, 0, 0, 8) + if p_no > 0: + pct = (p_no - p_wp) / p_no * 100 + row += f" {pct:7.1f}%" + else: + row += " n/a" + print(row) + + +# ----------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------- +def main() -> None: + """Execute all benchmark sections and print final summary output.""" + print("=" * 72) + print(" PARAMETRIC MODEL vs SWOPP3 REFERENCE — BENCHMARK") + print(" (Fully closed-form — no lookup tables)") + print("=" * 72) + + # 1. Component breakdown + benchmark_component_breakdown() + + # 2. predict_no_wps random + benchmark_no_wps(n=10_000, seed=42) + + # 3. Sail savings overview + benchmark_sail_savings() + + # 4. predict_with_wps random (fully closed-form) + benchmark_with_wps(n=10_000, seed=99) + + print(f"\n{'=' * 72}") + print(" BENCHMARK COMPLETE") + print(f"{'=' * 72}\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/realworld/figures.py b/scripts/realworld/figures.py index fe731ee0..d05d4276 100644 --- a/scripts/realworld/figures.py +++ b/scripts/realworld/figures.py @@ -439,7 +439,7 @@ def main( vel_ship: float = 3.0, path_output: str = "output", subfolder: str = "json_benchmark", - data_path: str = "../weather-routing-benchmarks/data", + data_path: str = "./data", ): """Generate the figures for the paper from benchmark results. diff --git a/scripts/realworld/results.py b/scripts/realworld/results.py index 4694c49d..aa8b16f6 100644 --- a/scripts/realworld/results.py +++ b/scripts/realworld/results.py @@ -38,8 +38,8 @@ def single_run( instance_name: str, date_start: str = "2023-01-08", vel_ship: int = 6, - data_path: str = "../weather-routing-benchmarks/data", - penalty: float = 1e6, + data_path: str = "./data", + penalty: float = 1e10, K: int = 10, num_pieces: int = 3, popsize: int = 500, diff --git a/scripts/results_land_avoidance.py b/scripts/results_land_avoidance.py index 68baff08..72edc2a8 100644 --- a/scripts/results_land_avoidance.py +++ b/scripts/results_land_avoidance.py @@ -16,7 +16,7 @@ def run_single_simulation( land_waterlevel: float = 0.7, land_resolution: int = 20, land_seed: int = 0, - land_penalty: float = 100, + land_penalty: float = 1e10, outbounds_is_land: bool = False, cmaes_K: int = 7, cmaes_L: int = 211, diff --git a/scripts/run_fms_sweep_combined.sh b/scripts/run_fms_sweep_combined.sh new file mode 100755 index 00000000..269ed51d --- /dev/null +++ b/scripts/run_fms_sweep_combined.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# ── FMS refinement for sweep_combined output ── +# +# Applies FMS to output/sweep_combined and writes output/sweep_combined_fms. +# Automatically restarts if the process exits unexpectedly (non-zero exit code). +# Resume is built into swopp3_apply_fms.py: completed routes are skipped. +# +# Run in the background with: +# nohup bash scripts/run_fms_sweep_combined.sh > output/sweep_combined_fms.log 2>&1 & +# +# Monitor: +# tail -f output/sweep_combined_fms.log +# cat output/fms_sweep_combined.pid + +set -euo pipefail # strict, but we trap errors below + +ROOTDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOTDIR" + +LOGFILE="output/sweep_combined_fms.log" +PIDFILE="output/fms_sweep_combined.pid" +INPUT_DIR="output/sweep_combined" +OUTPUT_DIR="output/sweep_combined_fms" + +# FMS parameters (matching RISE cost used in CMA-ES sweep) +# Both corridors used wind_pw=50, wave_pw=50 +WIND_PW=50 +WAVE_PW=50 + +# ERA5 data paths +WIND_ATL="data/era5/era5_wind_atlantic_2024.nc" +WAVE_ATL="data/era5/era5_waves_atlantic_2024.nc" +WIND_PAC="data/era5/era5_wind_pacific_2024.nc" +WAVE_PAC="data/era5/era5_waves_pacific_2024.nc" + +# Auto-restart configuration +MAX_RETRIES=20 +RETRY_DELAY=60 # seconds between retries + +echo "$BASHPID" > "$PIDFILE" + +echo "============================================" +echo "FMS sweep_combined runner started" +echo "Date: $(date)" +echo "Host: $(hostname)" +echo "PID: $BASHPID" +echo "PID file: $PIDFILE" +echo "Input: $INPUT_DIR" +echo "Output: $OUTPUT_DIR" +echo "Wind PW: $WIND_PW" +echo "Wave PW: $WAVE_PW" +echo "Max retries: $MAX_RETRIES" +echo "============================================" + +# Validate data files +for f in "$WIND_ATL" "$WAVE_ATL" "$WIND_PAC" "$WAVE_PAC"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All ERA5 data files present." + +mkdir -p "$OUTPUT_DIR" + +attempt=0 +while (( attempt < MAX_RETRIES )); do + attempt=$(( attempt + 1 )) + echo "" + echo "--- Attempt ${attempt}/${MAX_RETRIES}: $(date) ---" + + # Run FMS; allow non-zero exit so we can retry + set +e + uv run scripts/swopp3_apply_fms.py \ + "$INPUT_DIR" \ + --output-dir "$OUTPUT_DIR" \ + --wind-path-atlantic "$WIND_ATL" \ + --wave-path-atlantic "$WAVE_ATL" \ + --wind-path-pacific "$WIND_PAC" \ + --wave-path-pacific "$WAVE_PAC" \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" + exit_code=$? + set -e + + if (( exit_code == 0 )); then + echo "" + echo "============================================" + echo "FMS completed successfully: $(date)" + echo "Output: $OUTPUT_DIR" + echo "============================================" + rm -f "$PIDFILE" + exit 0 + fi + + echo "" + echo "WARNING: FMS exited with code ${exit_code} at $(date)" + if (( attempt < MAX_RETRIES )); then + echo "Retrying in ${RETRY_DELAY}s (attempt ${attempt}/${MAX_RETRIES})..." + sleep "$RETRY_DELAY" + fi +done + +echo "" +echo "ERROR: FMS failed after ${MAX_RETRIES} attempts. Giving up." +rm -f "$PIDFILE" +exit 1 diff --git a/scripts/run_fms_sweep_combined_strict.sh b/scripts/run_fms_sweep_combined_strict.sh new file mode 100755 index 00000000..d9f65cf6 --- /dev/null +++ b/scripts/run_fms_sweep_combined_strict.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# ── Strict FMS refinement for sweep_combined output ── +# +# Applies FMS to output/sweep_combined and writes output/sweep_combined_fms_strict. +# Automatically restarts if the process exits unexpectedly (non-zero exit code). +# Resume is built into swopp3_apply_fms.py: completed routes are skipped. +# +# Run in the background with: +# nohup bash scripts/run_fms_sweep_combined_strict.sh > output/sweep_combined_fms_strict.log 2>&1 & +# +# Monitor: +# tail -f output/sweep_combined_fms_strict.log +# cat output/fms_sweep_combined_strict.pid + +set -euo pipefail + +ROOTDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOTDIR" + +LOGFILE="output/sweep_combined_fms_strict.log" +PIDFILE="output/fms_sweep_combined_strict.pid" +INPUT_DIR="output/sweep_combined" +OUTPUT_DIR="output/sweep_combined_fms_strict" + +# FMS parameters (matching RISE cost used in CMA-ES sweep) +# Both corridors used wind_pw=50, wave_pw=50 +WIND_PW=50 +WAVE_PW=50 +TWS_LIMIT=19.9 +HS_LIMIT=6.9 + +# ERA5 data paths +WIND_ATL="data/era5/era5_wind_atlantic_2024.nc" +WAVE_ATL="data/era5/era5_waves_atlantic_2024.nc" +WIND_PAC="data/era5/era5_wind_pacific_2024.nc" +WAVE_PAC="data/era5/era5_waves_pacific_2024.nc" + +# Auto-restart configuration +MAX_RETRIES=20 +RETRY_DELAY=60 + +echo "$BASHPID" > "$PIDFILE" + +echo "============================================" +echo "Strict FMS sweep_combined runner started" +echo "Date: $(date)" +echo "Host: $(hostname)" +echo "PID: $BASHPID" +echo "PID file: $PIDFILE" +echo "Input: $INPUT_DIR" +echo "Output: $OUTPUT_DIR" +echo "Wind PW: $WIND_PW" +echo "Wave PW: $WAVE_PW" +echo "Wind limit: $TWS_LIMIT" +echo "Wave limit: $HS_LIMIT" +echo "Max retries: $MAX_RETRIES" +echo "============================================" + +# Validate data files +for f in "$WIND_ATL" "$WAVE_ATL" "$WIND_PAC" "$WAVE_PAC"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All ERA5 data files present." + +mkdir -p "$OUTPUT_DIR" + +attempt=0 +while (( attempt < MAX_RETRIES )); do + attempt=$(( attempt + 1 )) + echo "" + echo "--- Attempt ${attempt}/${MAX_RETRIES}: $(date) ---" + + set +e + uv run scripts/swopp3_apply_fms.py \ + "$INPUT_DIR" \ + --output-dir "$OUTPUT_DIR" \ + --wind-path-atlantic "$WIND_ATL" \ + --wave-path-atlantic "$WAVE_ATL" \ + --wind-path-pacific "$WIND_PAC" \ + --wave-path-pacific "$WAVE_PAC" \ + --tws-limit "$TWS_LIMIT" \ + --hs-limit "$HS_LIMIT" \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" + exit_code=$? + set -e + + if (( exit_code == 0 )); then + echo "" + echo "============================================" + echo "Strict FMS completed successfully: $(date)" + echo "Output: $OUTPUT_DIR" + echo "============================================" + rm -f "$PIDFILE" + exit 0 + fi + + echo "" + echo "WARNING: Strict FMS exited with code ${exit_code} at $(date)" + if (( attempt < MAX_RETRIES )); then + echo "Retrying in ${RETRY_DELAY}s (attempt ${attempt}/${MAX_RETRIES})..." + sleep "$RETRY_DELAY" + fi +done + +echo "" +echo "ERROR: Strict FMS failed after ${MAX_RETRIES} attempts. Giving up." +rm -f "$PIDFILE" +exit 1 \ No newline at end of file diff --git a/scripts/single_run.py b/scripts/single_run.py index 1c3b660e..1d600623 100644 --- a/scripts/single_run.py +++ b/scripts/single_run.py @@ -15,7 +15,7 @@ def run_single_simulation( land_waterlevel: float = 1.0, land_resolution: int = 5, land_seed: int = 0, - land_penalty: float = 100, + land_penalty: float = 1e10, outbounds_is_land: bool = False, cmaes_K: int = 6, cmaes_L: int = 200, diff --git a/scripts/sweep_ww_analysis.py b/scripts/sweep_ww_analysis.py new file mode 100755 index 00000000..e17ea3fb --- /dev/null +++ b/scripts/sweep_ww_analysis.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python +"""Analyse wind×wave sweep results with per-evaluation-point violations. + +Reads the track CSVs from each completed sweep directory, resamples them +to Δt₂ = 30 min, queries wind and wave fields at each evaluation point, +and reports violations as a percentage of total evaluation points. + +Usage +----- + python scripts/sweep_ww_analysis.py \ + --data-dir /data/fjsuarez/era5 \ + --output-root output + +Produces a summary CSV at ``output/sweep_ww_summary.csv``. +""" + +from __future__ import annotations + +import csv +import os +import re +import statistics +from datetime import datetime +from pathlib import Path + +import numpy as np +import typer + +# Thresholds +TWS_LIMIT = 20.0 # m/s +HS_LIMIT = 7.0 # m + +# Evaluation resolution +DT2_MINUTES = 30.0 + +CORRIDOR_CASES = { + "atlantic": ["AO_WPS", "AO_noWPS", "AGC_WPS", "AGC_noWPS"], + "pacific": ["PO_WPS", "PO_noWPS", "PGC_WPS", "PGC_noWPS"], +} + +PASSAGE_HOURS = { + "atlantic": 354, + "pacific": 583, +} + + +def _load_fields(data_dir: Path, corridor: str): + """Load wind and wave field closures for a corridor. + + Returns (windfield, wavefield, epoch) where epoch is the dataset + time origin as a datetime. + """ + from routetools.era5.loader import ( + load_dataset_epoch, + load_era5_wavefield, + load_era5_windfield, + ) + + wind_base = data_dir / f"era5_wind_{corridor}_2024.nc" + wave_base = data_dir / f"era5_waves_{corridor}_2024.nc" + wind_cont = data_dir / f"era5_wind_{corridor}_2025_01.nc" + wave_cont = data_dir / f"era5_waves_{corridor}_2025_01.nc" + + wind_paths = [wind_base] + if wind_cont.exists(): + wind_paths.append(wind_cont) + wave_paths = [wave_base] + if wave_cont.exists(): + wave_paths.append(wave_cont) + + wind_target = wind_paths if len(wind_paths) > 1 else wind_paths[0] + wave_target = wave_paths if len(wave_paths) > 1 else wave_paths[0] + + epoch = load_dataset_epoch(wind_target) + windfield = load_era5_windfield(wind_target) + wavefield = load_era5_wavefield(wave_target) + return windfield, wavefield, epoch + + +def _parse_departure_time(filename: str) -> datetime: + """Extract departure date from track filename like ...-20240315.csv.""" + m = re.search(r"-(\d{8})\.csv$", filename) + if m is None: + raise ValueError(f"Cannot parse departure date from {filename}") + d = m.group(1) + # Return naive datetime matching the epoch format from load_dataset_epoch + return datetime(int(d[:4]), int(d[4:6]), int(d[6:8]), 12) + + +def _corridor_for_case(case_id: str) -> str: + for corridor, cases in CORRIDOR_CASES.items(): + if case_id in cases: + return corridor + raise ValueError(f"Unknown case: {case_id}") + + +def _analyse_tracks( + track_dir: Path, + case_id: str, + windfield, + wavefield, + field_t0: datetime, +) -> dict: + """Analyse all departure tracks for one case. + + Returns + ------- + dict with keys: case, n_departures, n_total_points, + wind_violations, wave_violations, wind_pct, wave_pct, + mean_energy_mwh, mean_dist_nm + """ + import jax.numpy as jnp + + from routetools.resample import resample_track + + pattern = re.compile(rf"IEUniversity-1-{re.escape(case_id)}-\d{{8}}\.csv$") + track_files = sorted(f for f in os.listdir(track_dir) if pattern.match(f)) + + total_points = 0 + wind_viol_points = 0 + wave_viol_points = 0 + + for tf in track_files: + # Read track waypoints + with open(track_dir / tf) as fh: + reader = csv.DictReader(fh) + rows = list(reader) + + if len(rows) < 2: + continue + + # Parse track columns + times_str = [r["time_utc"] for r in rows] + lats = np.array([float(r["lat_deg"]) for r in rows]) + lons = np.array([float(r["lon_deg"]) for r in rows]) + times = np.array( + [np.datetime64(t.replace(" ", "T")) for t in times_str], + dtype="datetime64[ns]", + ) + + # Resample to Δt₂ + t_eval, lat_eval, lon_eval = resample_track( + times, lats, lons, dt_minutes=DT2_MINUTES + ) + + n_pts = len(lat_eval) + + # Compute time offsets from field origin + t0_ns = np.datetime64(field_t0.strftime("%Y-%m-%dT%H:%M:%S"), "ns") + t_offset_h = (t_eval - t0_ns).astype("timedelta64[ns]").astype(np.float64) / ( + 3600.0 * 1e9 + ) + + # Query fields at evaluation points + lon_j = jnp.array(np.asarray(lon_eval)) + lat_j = jnp.array(np.asarray(lat_eval)) + t_j = jnp.array(np.asarray(t_offset_h)) + + if windfield is not None: + u10, v10 = windfield(lon_j, lat_j, t_j) + tws = np.sqrt(np.asarray(u10) ** 2 + np.asarray(v10) ** 2) + wind_viol_points += int(np.sum(tws > TWS_LIMIT)) + if wavefield is not None: + hs, _ = wavefield(lon_j, lat_j, t_j) + hs = np.asarray(hs) + wave_viol_points += int(np.sum(hs > HS_LIMIT)) + + total_points += n_pts + + return { + "n_departures": len(track_files), + "n_total_points": total_points, + "wind_violations": wind_viol_points, + "wave_violations": wave_viol_points, + "wind_pct": 100.0 * wind_viol_points / total_points if total_points else 0.0, + "wave_pct": 100.0 * wave_viol_points / total_points if total_points else 0.0, + } + + +def _read_summary_csv(fpath: Path) -> dict: + """Read energy and distance from the per-case summary CSV.""" + with open(fpath) as fh: + rows = list(csv.DictReader(fh)) + energies = [float(r["energy_cons_mwh"]) for r in rows] + dists = [float(r["sailed_distance_nm"]) for r in rows] + return { + "mean_energy_mwh": statistics.mean(energies), + "mean_dist_nm": statistics.mean(dists), + } + + +app = typer.Typer() + + +@app.command() +def main( + data_dir: Path = typer.Option( # noqa: B008 + "data/era5", "--data-dir", help="Directory with ERA5 NetCDF files." + ), + output_root: Path = typer.Option( # noqa: B008 + "output", "--output-root", help="Root output directory." + ), + csv_out: Path = typer.Option( # noqa: B008 + "output/sweep_ww_summary.csv", + "--csv-out", + help="Path for summary CSV.", + ), +): + """Analyse wind×wave sweep results.""" + sweep_dirs = sorted( + d + for d in os.listdir(output_root) + if d.startswith("swopp3_ww_") and (output_root / d / "tracks").is_dir() + ) + + if not sweep_dirs: + print("No completed sweep directories found.") + raise typer.Exit(1) + + print(f"Found {len(sweep_dirs)} sweep directories.") + + # Parse wind/wave identifiers + dir_re = re.compile(r"swopp3_ww_w(\d+)_v(\d+)") + + # Load fields per corridor (reuse across configs) + fields: dict[str, tuple] = {} + field_t0s: dict[str, datetime] = {} + for corridor in ("atlantic", "pacific"): + print(f"Loading {corridor} fields from {data_dir} ...") + wf, vf, epoch = _load_fields(data_dir, corridor) + fields[corridor] = (wf, vf) + field_t0s[corridor] = epoch + + all_cases = [ + "AO_WPS", + "AO_noWPS", + "AGC_WPS", + "AGC_noWPS", + "PO_WPS", + "PO_noWPS", + "PGC_WPS", + "PGC_noWPS", + ] + + header = [ + "wind_pw", + "wave_pw", + "case", + "mean_energy_mwh", + "mean_dist_nm", + "n_departures", + "n_eval_points", + "wind_violations", + "wave_violations", + "wind_pct", + "wave_pct", + ] + + results = [] + + for d in sweep_dirs: + m = dir_re.match(d) + if m is None: + continue + wind_pw = int(m.group(1)) + wave_pw = int(m.group(2)) + print(f"\n=== {d} (wind={wind_pw}, wave={wave_pw}) ===") + + for case_id in all_cases: + summary_path = output_root / d / f"IEUniversity-1-{case_id}.csv" + track_dir = output_root / d / "tracks" + + if not summary_path.exists(): + continue + + corridor = _corridor_for_case(case_id) + windfield, wavefield = fields[corridor] + field_t0 = field_t0s[corridor] + + # Energy/distance from summary + summary = _read_summary_csv(summary_path) + + # Per-point violations from tracks + print(f" Analysing {case_id} tracks ...") + stats = _analyse_tracks(track_dir, case_id, windfield, wavefield, field_t0) + + row = { + "wind_pw": wind_pw, + "wave_pw": wave_pw, + "case": case_id, + "mean_energy_mwh": f"{summary['mean_energy_mwh']:.2f}", + "mean_dist_nm": f"{summary['mean_dist_nm']:.2f}", + "n_departures": stats["n_departures"], + "n_eval_points": stats["n_total_points"], + "wind_violations": stats["wind_violations"], + "wave_violations": stats["wave_violations"], + "wind_pct": f"{stats['wind_pct']:.3f}", + "wave_pct": f"{stats['wave_pct']:.3f}", + } + results.append(row) + print( + f" E={summary['mean_energy_mwh']:.1f} MWh " + f"wind={stats['wind_pct']:.2f}% " + f"wave={stats['wave_pct']:.2f}%" + ) + + # Write summary CSV + csv_out.parent.mkdir(parents=True, exist_ok=True) + with open(csv_out, "w", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=header) + writer.writeheader() + writer.writerows(results) + print(f"\nSummary written to {csv_out}") + + +if __name__ == "__main__": + app() diff --git a/scripts/swopp3_analysis.py b/scripts/swopp3_analysis.py new file mode 100755 index 00000000..8d505be6 --- /dev/null +++ b/scripts/swopp3_analysis.py @@ -0,0 +1,2525 @@ +#!/usr/bin/env python +"""Weather-routing optimization: comprehensive comparative analysis. + +Generates the full SWOPP3 comparison figure set plus a summary table for +four experimental conditions across the SWOPP3 2024 competition: + + - CMA-ES (no weather penalty) + - CMA-ES + FMS (no penalty, with FMS post-refinement) + - CMA-ES + Penalty (wind/wave penalties in objective) + - CMA-ES + Penalty + FMS (penalised + FMS post-refinement) + +Usage +----- + # Generate all figures with default paths + uv run scripts/swopp3_analysis.py + + # Custom data and output directories + uv run scripts/swopp3_analysis.py \ + --data-dir /path/to/output --output-dir /path/to/figs + + # Generate only selected figures (e.g. fig01 and fig05) + uv run scripts/swopp3_analysis.py --figures 1 5 + + # Higher resolution + uv run scripts/swopp3_analysis.py --dpi 300 + +Options +------- + --data-dir DIR Root directory containing experiment output folders. + Default: /output + --output-dir DIR Directory where figures and tables are saved. + Default: /output/analysis + --figures N [N...] Space-separated list of figure numbers to generate + (1–12). Generates all figures if omitted. + --dpi DPI Figure resolution in DPI. Default: 180 + +Outputs +------- + fig01_energy_overview.pdf / .png + fig02_optimization_gains.pdf / .png + fig03_penalty_tradeoff.pdf / .png + fig04a_seasonality_sweep_combined.pdf / .png + fig04b_seasonality_penalty.pdf / .png + fig13a_relative_gain_sweep_combined.pdf / .png + fig13b_relative_gain_penalty.pdf / .png + fig05_wps_impact.pdf / .png + fig06_fms_improvement.pdf / .png + fig07_route_maps.pdf / .png + fig08_risk_calendar.pdf / .png + fig09_fms_delta_byseason.pdf / .png + fig10_gc_victory_rate.pdf / .png + fig11_gc_margin_heatmap.pdf / .png + fig12_gc_violations.pdf / .png + table01_summary.csv + +Experimental conditions +----------------------- + The active experiments are driven by the ``EXPERIMENT_PAIRS`` constant near + the top of this file. Each entry is a ``(base_key, fms_key)`` tuple that + maps a base CMA-ES run to its FMS post-refinement counterpart. + + Built-in profiles (uncomment the desired one): + + Two-experiment profile (default — sweep combined): + EXPERIMENT_PAIRS = [("sweep_combined", "sweep_combined_fms")] + + Four-experiment profile (penalty vs no-penalty comparison): + EXPERIMENT_PAIRS = [ + ("no_penalty", "no_penalty_fms"), + ("penalty", "penalty_fms"), + ] + + All known experiment keys and their folder/colour/label metadata live in + ``EXPERIMENTS_REGISTRY``. ``ACTIVE_EXPERIMENTS`` is derived automatically + from ``EXPERIMENT_PAIRS`` — do not edit it directly. + + Penalty thresholds: wind > 20 m/s, significant wave height > 7 m Hs. + +Cases +----- + AO_WPS Atlantic (Santander → New York), vessel with Wind Propulsion System + AO_noWPS Atlantic, vessel without WPS + PO_WPS Pacific (Tokyo → Los Angeles), vessel with WPS + PO_noWPS Pacific, vessel without WPS + + Great-circle baselines (AGC_*, PGC_*) are included as reference. + +Figure descriptions +------------------- + fig01 Violin plots of energy consumption (MWh) per case × experiment, + with great-circle baseline markers and median-savings annotations. + fig02 Grouped bar chart of median % energy savings vs the great-circle + baseline for every case × experiment combination. + fig03 Three-panel safety/efficiency trade-off: wind-violation rate, + wave-violation rate, and median energy cost across conditions. + fig04a Seasonal energy lines — non-penalized (2 × 2 per case). Daily + energy per departure plotted as a line for GC, CMA-ES, and + CMA-ES + FMS (no weather penalty). + fig04b Seasonal energy lines — penalized (2 × 2 per case). Same layout + for CMA-ES + Penalty and CMA-ES + Penalty + FMS. + fig13a Relative energy gain vs GC — non-penalized (2 × 2 per case). + Two lines per panel: CMA-ES and CMA-ES + FMS, as % saving + over the matched GC departure. + fig13b Same layout as fig13a for the penalized experiments + (CMA-ES + Penalty, CMA-ES + Penalty + FMS). + fig05 Horizontal bars of absolute and relative energy savings from WPS + (WPS vs no-WPS vessel, same route and experiment). + fig06 Scatter of CMA-ES energy vs CMA-ES + FMS energy per departure; + points below the diagonal confirm FMS always reduces energy. + fig07 Cartopy maps of sampled vessel tracks coloured by experiment, + showing how penalty routing avoids storm-prone corridors. + fig08 2 × 2 heatmap (month × experiment) of any-violation rate per + calendar month, revealing seasonal weather-risk patterns. + fig09 Grouped bars of % energy reduction delivered by FMS, broken down + by season and case. + fig10 Monthly "victory rate" — % of departures that beat the GC energy + for each case × experiment. + fig11 Heatmap of median % margin over the great-circle route + (rows = experiments, columns = months) per case. + fig12 Side-by-side bars of any-violation rate for the great-circle route + vs CMA-ES + Penalty + FMS, per month and case. + +Data dependencies +----------------- + The script reads CSV files from ``output/`` experiment folders (not + tracked in Git). Expected layout for each experiment:: + + output/swopp3_/ + -.csv # one summary file per case + tracks/ + # per-voyage track + + Missing experiment folders or individual CSVs are silently skipped; + all figures degrade gracefully to show only available data. +""" + +from __future__ import annotations + +import argparse +import warnings +from functools import cache +from pathlib import Path + +import cartopy.crs as ccrs +import cartopy.feature as cfeature +import matplotlib as mpl +import matplotlib.lines as mlines +import matplotlib.patches as mpatches +import matplotlib.pyplot as plt # noqa: E402 +import matplotlib.ticker as mticker +import numpy as np +import pandas as pd + +from routetools.analysis_config import ( + EXPERIMENTS_REGISTRY, + AnalysisPaths, + _experiment_folder, +) +from routetools.violations import find_team_prefix + +# --------------------------------------------------------------------------- +# Paths (defaults; overridden at runtime via CLI args in main()) +# --------------------------------------------------------------------------- +_REPO_ROOT = Path(__file__).parent.parent + + +DEFAULT_PATHS = AnalysisPaths( + output_dir=_REPO_ROOT / "output", + figs_dir=_REPO_ROOT / "output" / "analysis", + config_path=_REPO_ROOT / "config.toml", +) + + +@cache +def _team_prefix(folder_dir: Path) -> str: + """Return the detected team prefix for one experiment output folder.""" + return find_team_prefix(folder_dir) + + +def _summary_csv_path(paths: AnalysisPaths, folder: str, case_id: str) -> Path | None: + """Return the summary CSV path for one case when present.""" + folder_dir = paths.output_dir / folder + if not folder_dir.exists(): + return None + try: + team_prefix = _team_prefix(folder_dir) + except FileNotFoundError: + return None + return folder_dir / f"{team_prefix}-{case_id}.csv" + + +# --------------------------------------------------------------------------- +# Experiment registry is imported from routetools.analysis_config +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Analysis profile — THE only place to change for a different comparison +# --------------------------------------------------------------------------- +# Each tuple is (base_experiment_key, fms_experiment_key). All figures and +# data loaders are derived from this list automatically. +# Two-experiment profile (sweep combined, no penalty distinction): +EXPERIMENT_PAIRS: list[tuple[str, str]] = [ + ("sweep_combined", "sweep_combined_fms_strict"), +] +# Four-experiment profile (no-penalty vs penalty comparison); uncomment to use: +# EXPERIMENT_PAIRS = [ +# ("no_penalty", "no_penalty_fms"), +# ("penalty", "penalty_fms"), +# ] + +# Active experiments for this run — derived from EXPERIMENT_PAIRS, do not edit +ACTIVE_EXPERIMENTS: dict[str, dict] = { + k: EXPERIMENTS_REGISTRY[k] for pair in EXPERIMENT_PAIRS for k in pair +} + +# Optimised cases (what we are comparing) +OPT_CASES: dict[str, dict] = { + "AO_WPS": { + "label": "Atlantic\nWPS", + "label_short": "Atl. WPS", + "route": "atlantic", + "wps": True, + "gc": "AGC_WPS", + "color": "#000066", # IE ocean-blue + }, + "AO_noWPS": { + "label": "Atlantic\nno WPS", + "label_short": "Atl. noWPS", + "route": "atlantic", + "wps": False, + "gc": "AGC_noWPS", + "color": "#0097DC", # IE business blue + }, + "PO_WPS": { + "label": "Pacific\nWPS", + "label_short": "Pac. WPS", + "route": "pacific", + "wps": True, + "gc": "PGC_WPS", + "color": "#6DC201", # IE tech green + }, + "PO_noWPS": { + "label": "Pacific\nno WPS", + "label_short": "Pac. noWPS", + "route": "pacific", + "wps": False, + "gc": "PGC_noWPS", + "color": "#47BFFF", # IE sea-blue + }, +} + +# Great-circle baselines (GC = fixed route, constant speed) +GC_CASES = ["AGC_WPS", "AGC_noWPS", "PGC_WPS", "PGC_noWPS"] + +# Codabench thresholds +WIND_LIMIT = 20.0 # m/s +WAVE_LIMIT = 7.0 # m + +# Season mapping and colours +_MONTH_TO_SEASON = { + 12: "Winter", + 1: "Winter", + 2: "Winter", + 3: "Spring", + 4: "Spring", + 5: "Spring", + 6: "Summer", + 7: "Summer", + 8: "Summer", + 9: "Autumn", + 10: "Autumn", + 11: "Autumn", +} +SEASON_ORDER = ["Winter", "Spring", "Summer", "Autumn"] +SEASON_COLORS = { + "Winter": "#0097DC", # IE business blue (cool) + "Spring": "#6DC201", # IE tech green + "Summer": "#FF630F", # IE humanities orange + "Autumn": "#F23333", # IE law red +} + +MONTH_ABBR = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] + + +# --------------------------------------------------------------------------- +# Style +# --------------------------------------------------------------------------- +def setup_style() -> None: + """Configure IE Science & Technology branded matplotlib defaults.""" + mpl.rcParams.update( + { + "font.family": "Montserrat", + "font.size": 10, + "axes.titlesize": 12, + "axes.titleweight": "bold", + "axes.titlepad": 10, + "axes.labelsize": 9, + "axes.spines.top": False, + "axes.spines.right": False, + "axes.grid": True, + "axes.grid.axis": "y", + "grid.color": "#E5E5E5", + "grid.linewidth": 0.7, + "figure.facecolor": "none", + "axes.facecolor": "none", + "xtick.bottom": False, + "ytick.left": False, + "xtick.labelsize": 8.5, + "ytick.labelsize": 8.5, + "legend.frameon": False, + "legend.fontsize": 8.5, + "legend.handlelength": 1.5, + "savefig.bbox": "tight", + "savefig.dpi": 180, + "savefig.facecolor": "none", + "savefig.transparent": True, + "figure.constrained_layout.use": True, + } + ) + + +def _save_figure_outputs( + fig: plt.Figure, + out: Path, + **savefig_kwargs: object, +) -> None: + """Write transparent PDF/PNG files for one figure.""" + fig.patch.set_alpha(0) + for ax in fig.axes: + ax.set_facecolor("none") + ax.patch.set_alpha(0) + + save_kwargs = {"transparent": True, **savefig_kwargs} + fig.savefig(out, **save_kwargs) + + hidden_items: list[tuple[object, bool]] = [] + if fig._suptitle is not None: + hidden_items.append((fig._suptitle, fig._suptitle.get_visible())) + fig._suptitle.set_visible(False) + for text in fig.texts: + if "source" in text.get_text().lower(): + hidden_items.append((text, text.get_visible())) + text.set_visible(False) + + fig.savefig(out.with_suffix(".png"), **save_kwargs) + + for artist, was_visible in hidden_items: + artist.set_visible(was_visible) + + out.with_suffix(".tikz").unlink(missing_ok=True) + + +def add_source_note( + fig: plt.Figure, note: str = "Source: SWOPP3 2024, IEResearchDatalab" +) -> None: + """Add a small source note at the bottom-left of a figure.""" + fig.text( + 0.01, + -0.01, + note, + ha="left", + va="top", + fontsize=7.5, + color="#666666", + style="italic", + ) + + +# --------------------------------------------------------------------------- +# Data loading +# --------------------------------------------------------------------------- +def _outlier_mask(series: pd.Series, iqr_factor: float = 5.0) -> pd.Series: + """Return a boolean mask of non-outliers using IQR rule.""" + q1, q3 = series.quantile(0.25), series.quantile(0.75) + iqr = q3 - q1 + return (series >= q1 - iqr_factor * iqr) & (series <= q3 + iqr_factor * iqr) + + +def load_summary_csv( + exp_key: str, + case_id: str, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> pd.DataFrame | None: + """Load and annotate one experiment/case summary CSV.""" + folder = _experiment_folder(exp_key, paths) + path = _summary_csv_path(paths, folder, case_id) + if path is None or not path.exists(): + return None + df = pd.read_csv(path, parse_dates=["departure_time_utc", "arrival_time_utc"]) + df["experiment"] = exp_key + df["case_id"] = case_id + + # Temporal features + df["month"] = df["departure_time_utc"].dt.month + df["season"] = df["month"].map(_MONTH_TO_SEASON) + + # Violation flags + df["wind_viol"] = df["max_wind_mps"] > WIND_LIMIT + df["wave_viol"] = df["max_hs_m"] > WAVE_LIMIT + df["any_viol"] = df["wind_viol"] | df["wave_viol"] + + # Remove extreme outliers (FMS occasionally yields 10000+ MWh routes) + mask = _outlier_mask(df["energy_cons_mwh"]) + if (~mask).any(): + print(f" [!] Dropping {(~mask).sum()} outliers from {exp_key}/{case_id}") + return df[mask].copy() + + +def load_gc_baselines(paths: AnalysisPaths = DEFAULT_PATHS) -> dict[str, float]: + """Return mean energy per GC case, averaged across all base experiment folders. + + Iterates over all pairs in ``EXPERIMENT_PAIRS`` and collects GC data from + each base experiment folder. + """ + baselines: dict[str, list] = {} + for base_key, _fms_key in EXPERIMENT_PAIRS: + folder = _experiment_folder(base_key, paths) + for gc_id in GC_CASES: + path = _summary_csv_path(paths, folder, gc_id) + if path is None or not path.exists(): + continue + df = pd.read_csv(path) + baselines.setdefault(gc_id, []).append(df["energy_cons_mwh"].mean()) + return {k: float(np.mean(v)) for k, v in baselines.items()} + + +def load_all_data(paths: AnalysisPaths = DEFAULT_PATHS) -> pd.DataFrame: + """Load all optimised-case summary rows across all experiments.""" + frames = [] + for exp_key in ACTIVE_EXPERIMENTS: + for case_id in OPT_CASES: + df = load_summary_csv(exp_key, case_id, paths) + if df is not None: + frames.append(df) + return pd.concat(frames, ignore_index=True) + + +def load_tracks( + exp_key: str, + case_id: str, + paths: AnalysisPaths = DEFAULT_PATHS, + season_filter: str | None = None, + n_sample: int = 8, +) -> list[pd.DataFrame]: + """Return sampled per-voyage tracks for one experiment/case pair. + + The sampling operates on the summary CSV first so that the selected track + files inherit season and departure metadata from the same voyage rows. + """ + folder = _experiment_folder(exp_key, paths) + tracks_dir = paths.output_dir / folder / "tracks" + if not tracks_dir.exists(): + return [] + + # Load the summary to know departure dates by season + summary = load_summary_csv(exp_key, case_id, paths) + if summary is None: + return [] + + if season_filter: + summary = summary[summary["season"] == season_filter] + + # Keep figure selection deterministic so repeated runs save the same tracks. + sample = summary.sample( + min(n_sample, len(summary)), replace=False, random_state=42 + ).sort_values("departure_time_utc") + + result = [] + for _, row in sample.iterrows(): + fname = row["details_filename"] + fpath = tracks_dir / fname + if fpath.exists(): + trk = pd.read_csv(fpath, parse_dates=["time_utc"]) + trk["experiment"] = exp_key + trk["case_id"] = case_id + trk["departure"] = row["departure_time_utc"] + trk["season"] = row["season"] + result.append(trk) + return result + + +# =========================================================================== +# FIGURE 1 — Energy overview (violin plots) +# =========================================================================== +def fig_energy_overview( + df: pd.DataFrame, + gc: dict[str, float], + gc_full: pd.DataFrame, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """Violin plot of energy distributions per case and experiment. + + The GC route is shown as its own violin (position 0) so its + departure-to-departure variability is visible rather than a flat line. + """ + setup_style() + fig, axes = plt.subplots(1, 4, figsize=(14, 5), sharey=False) + fig.suptitle( + "Optimised routing cuts energy by 10–55 % versus the great-circle baseline", + fontsize=13, + fontweight="bold", + x=0.02, + ha="left", + ) + + GC_COLOR = "#878787" + exp_order = list(ACTIVE_EXPERIMENTS.keys()) + # Position 1 = GC, positions 2..5 = experiments + gc_pos = 1 + exp_positions = np.arange(2, len(exp_order) + 2) + width = 0.7 + + def _draw_violin(ax, data, pos, color, alpha=0.80): + vp = ax.violinplot( + data, + positions=[pos], + widths=width, + showmedians=True, + showextrema=False, + ) + for pc in vp["bodies"]: + pc.set_facecolor(color) + pc.set_edgecolor("none") + pc.set_alpha(alpha) + vp["cmedians"].set_color("white") + vp["cmedians"].set_linewidth(2) + return vp + + for ax, (case_id, case_meta) in zip(axes, OPT_CASES.items(), strict=False): + gc_id = case_meta["gc"] + gc_vals_raw = gc_full.loc[ + gc_full["case_id"] == case_id, "energy_cons_mwh" + ].dropna() + gc_vals = gc_vals_raw[_outlier_mask(gc_vals_raw)] + gc_mean = gc_vals.median() if not gc_vals.empty else gc.get(gc_id, np.nan) + + # GC violin (position 1) + if not gc_vals.empty: + _draw_violin(ax, gc_vals.values, gc_pos, GC_COLOR, alpha=0.65) + ax.text( + gc_pos, + gc_vals.quantile(0.05), + "GC", + ha="center", + va="top", + fontsize=7.5, + color=GC_COLOR, + fontweight="bold", + ) + + # Optimised experiment violins + for i, exp_key in enumerate(exp_order): + sub = df[(df["experiment"] == exp_key) & (df["case_id"] == case_id)][ + "energy_cons_mwh" + ] + if sub.empty: + continue + _draw_violin( + ax, sub.values, exp_positions[i], ACTIVE_EXPERIMENTS[exp_key]["color"] + ) + + # % savings vs GC median + pct = (gc_mean - sub.median()) / gc_mean * 100 + ax.text( + exp_positions[i], + sub.quantile(0.05), + f"−{pct:.0f}%", + ha="center", + va="top", + fontsize=7.5, + color=ACTIVE_EXPERIMENTS[exp_key]["color"], + fontweight="bold", + ) + + ax.set_title( + case_meta["label"].replace("\n", " "), fontsize=10, fontweight="bold" + ) + all_ticks = [gc_pos] + list(exp_positions) + all_labels = ["GC"] + [ + ACTIVE_EXPERIMENTS[k]["short"].replace(" + ", "\n+\n") for k in exp_order + ] + ax.set_xticks(all_ticks) + ax.set_xticklabels(all_labels, fontsize=7.0) + ax.set_ylabel("Energy (MWh)", fontsize=8) + ax.grid(axis="y", color="#E5E5E5", linewidth=0.7) + ax.set_axisbelow(True) + + # Legend + legend_elements = [ + mpatches.Patch(facecolor=GC_COLOR, alpha=0.65, label="Great-circle baseline"), + ] + [ + mpatches.Patch( + facecolor=ACTIVE_EXPERIMENTS[k]["color"], + alpha=0.85, + label=ACTIVE_EXPERIMENTS[k]["label"], + ) + for k in exp_order + ] + fig.legend( + handles=legend_elements, + loc="lower center", + ncol=5, + bbox_to_anchor=(0.5, -0.04), + fontsize=8.5, + ) + + add_source_note(fig) + out = paths.figs_dir / "fig01_energy_overview.pdf" + _save_figure_outputs(fig, out) + print(f" Saved {out.name}") + plt.close(fig) + + +# =========================================================================== +# FIGURE 2 — Optimisation gains vs GC baseline +# =========================================================================== +def fig_optimization_gains( + df: pd.DataFrame, + gc: dict[str, float], + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """Plot grouped bar chart of % energy savings vs GC baseline per experiment.""" # noqa: E501 + setup_style() + fig, axes = plt.subplots(1, 2, figsize=(11, 5)) + fig.suptitle( + "Weather-routing optimisation reduces energy consumption relative to the great-circle baseline", # noqa: E501 + fontsize=12, + fontweight="bold", + x=0.02, + ha="left", + ) + + route_groups = [ + ("atlantic", "Atlantic route (Santander → New York)", ["AO_WPS", "AO_noWPS"]), + ("pacific", "Pacific route (Tokyo → Los Angeles)", ["PO_WPS", "PO_noWPS"]), + ] + + bar_w = 0.18 + exp_order = list(ACTIVE_EXPERIMENTS.keys()) + + for ax, (_route, title, cases) in zip(axes, route_groups, strict=False): + ax.set_title(title, fontsize=11, fontweight="bold") + ax.axhline(0, color="#444", linewidth=0.8) + + x_centers = np.arange(len(cases)) + offsets = np.linspace( + -(len(exp_order) - 1) / 2 * bar_w, + (len(exp_order) - 1) / 2 * bar_w, + len(exp_order), + ) + + for j, exp_key in enumerate(exp_order): + savings = [] + for case_id in cases: + gc_id = OPT_CASES[case_id]["gc"] + gc_mean = gc.get(gc_id, np.nan) + sub = df[(df["experiment"] == exp_key) & (df["case_id"] == case_id)][ + "energy_cons_mwh" + ] + if sub.empty or np.isnan(gc_mean): + savings.append(np.nan) + else: + savings.append((gc_mean - sub.median()) / gc_mean * 100) + + xs = x_centers + offsets[j] + bars = ax.bar( + xs, + savings, + width=bar_w * 0.92, + color=ACTIVE_EXPERIMENTS[exp_key]["color"], + alpha=0.88, + label=ACTIVE_EXPERIMENTS[exp_key]["label"], + zorder=3, + ) + for bar, val in zip(bars, savings, strict=False): + if not np.isnan(val): + ax.text( + bar.get_x() + bar.get_width() / 2, + val + 0.4 if val >= 0 else val - 0.4, + f"{val:.1f}%", + ha="center", + va="bottom" if val >= 0 else "top", + fontsize=7, + color=ACTIVE_EXPERIMENTS[exp_key]["color"], + fontweight="bold", + ) + + ax.set_xticks(x_centers) + ax.set_xticklabels([OPT_CASES[c]["label_short"] for c in cases], fontsize=9) + ax.set_ylabel("Energy saving vs GC baseline (%)") + ax.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=100, decimals=0)) + ax.grid(axis="y", color="#E5E5E5", linewidth=0.7) + ax.set_axisbelow(True) + + # Shared legend + handles = [ + mpatches.Patch( + facecolor=ACTIVE_EXPERIMENTS[k]["color"], + alpha=0.85, + label=ACTIVE_EXPERIMENTS[k]["label"], + ) + for k in exp_order + ] + fig.legend( + handles=handles, + loc="lower center", + ncol=4, + bbox_to_anchor=(0.5, -0.05), + fontsize=8.5, + ) + + # Equalise y-axis across both route panels + ymax = max(ax.get_ylim()[1] for ax in axes) + for ax in axes: + ax.set_ylim(0, ymax) + + add_source_note(fig) + out = paths.figs_dir / "fig02_optimization_gains.pdf" + _save_figure_outputs(fig, out) + print(f" Saved {out.name}") + plt.close(fig) + + +# =========================================================================== +# FIGURE 3 — Penalty trade-off (safety vs efficiency) +# =========================================================================== +def fig_penalty_tradeoff( + df: pd.DataFrame, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """Side-by-side bars: experiment comparison — violation rates and mean energy.""" + setup_style() + active_keys = list(ACTIVE_EXPERIMENTS.keys()) + sub = df[df["experiment"].isin(active_keys)].copy() + + records = [] + for case_id in OPT_CASES: + for exp_key in active_keys: + piece = sub[(sub["experiment"] == exp_key) & (sub["case_id"] == case_id)] + if piece.empty: + continue + records.append( + { + "case_id": case_id, + "experiment": exp_key, + "wind_violation_pct": piece["wind_viol"].mean() * 100, + "wave_violation_pct": piece["wave_viol"].mean() * 100, + "any_violation_pct": piece["any_viol"].mean() * 100, + "mean_energy": piece["energy_cons_mwh"].mean(), + } + ) + metrics = pd.DataFrame(records) + + fig, axes = plt.subplots(1, 3, figsize=(13, 5)) + fig.suptitle( + "Experiment comparison — violation rates and energy consumption", + fontsize=12, + fontweight="bold", + x=0.02, + ha="left", + ) + + cases_order = list(OPT_CASES.keys()) + x = np.arange(len(cases_order)) + bw = 0.7 / max(len(active_keys), 1) + _offsets = np.linspace( + -(len(active_keys) - 1) / 2 * bw, + (len(active_keys) - 1) / 2 * bw, + len(active_keys), + ) + + # Panel A — wind violation rate + ax = axes[0] + ax.set_title("Wind violations\n(% of departures above 20 m/s)", fontsize=9.5) + for i, exp_key in enumerate(active_keys): + vals = [ + metrics.loc[ + (metrics.case_id == c) & (metrics.experiment == exp_key), + "wind_violation_pct", + ].values + for c in cases_order + ] + vals = [v[0] if len(v) > 0 else np.nan for v in vals] + ax.bar( + x + _offsets[i], + vals, + width=bw * 0.92, + color=ACTIVE_EXPERIMENTS[exp_key]["color"], + alpha=0.88, + label=ACTIVE_EXPERIMENTS[exp_key]["label"], + zorder=3, + ) + ax.set_xticks(x) + ax.set_xticklabels([OPT_CASES[c]["label_short"] for c in cases_order], fontsize=8) + ax.set_ylabel("Departures with wind violation (%)") + ax.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=100, decimals=0)) + + # Panel B — wave violation rate + ax = axes[1] + ax.set_title("Wave violations\n(% of departures above 7 m Hs)", fontsize=9.5) + for i, exp_key in enumerate(active_keys): + vals = [ + metrics.loc[ + (metrics.case_id == c) & (metrics.experiment == exp_key), + "wave_violation_pct", + ].values + for c in cases_order + ] + vals = [v[0] if len(v) > 0 else np.nan for v in vals] + ax.bar( + x + _offsets[i], + vals, + width=bw * 0.92, + color=ACTIVE_EXPERIMENTS[exp_key]["color"], + alpha=0.88, + zorder=3, + ) + ax.set_xticks(x) + ax.set_xticklabels([OPT_CASES[c]["label_short"] for c in cases_order], fontsize=8) + ax.set_ylabel("Departures with wave violation (%)") + ax.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=100, decimals=0)) + + # Panel C — mean energy + ax = axes[2] + ax.set_title("Mean energy consumption\n(MWh per voyage)", fontsize=9.5) + for i, exp_key in enumerate(active_keys): + vals = [ + metrics.loc[ + (metrics.case_id == c) & (metrics.experiment == exp_key), "mean_energy" + ].values + for c in cases_order + ] + vals = [v[0] if len(v) > 0 else np.nan for v in vals] + bars = ax.bar( + x + _offsets[i], + vals, + width=bw * 0.92, + color=ACTIVE_EXPERIMENTS[exp_key]["color"], + alpha=0.88, + zorder=3, + ) + for bar, val in zip(bars, vals, strict=False): + if not np.isnan(val): + ax.text( + bar.get_x() + bar.get_width() / 2, + val + 1, + f"{val:.0f}", + ha="center", + va="bottom", + fontsize=7.5, + color=ACTIVE_EXPERIMENTS[exp_key]["color"], + fontweight="bold", + ) + ax.set_xticks(x) + ax.set_xticklabels([OPT_CASES[c]["label_short"] for c in cases_order], fontsize=8) + ax.set_ylabel("Mean energy consumption (MWh)") + + # Share y-axis between the two violation-rate panels + ymax_viol = max(axes[0].get_ylim()[1], axes[1].get_ylim()[1]) + axes[0].set_ylim(0, ymax_viol) + axes[1].set_ylim(0, ymax_viol) + + for ax in axes: + ax.grid(axis="y", color="#E5E5E5", linewidth=0.7) + ax.set_axisbelow(True) + + handles = [ + mpatches.Patch( + facecolor=ACTIVE_EXPERIMENTS[k]["color"], + alpha=0.85, + label=ACTIVE_EXPERIMENTS[k]["label"], + ) + for k in active_keys + ] + fig.legend( + handles=handles, + loc="lower center", + ncol=len(active_keys), + bbox_to_anchor=(0.5, -0.04), + fontsize=9, + ) + + add_source_note(fig) + out = paths.figs_dir / "fig03_penalty_tradeoff.pdf" + _save_figure_outputs(fig, out) + print(f" Saved {out.name}") + plt.close(fig) + + +# =========================================================================== +# FIGURE 4 — Seasonality (monthly mean energy) +# =========================================================================== +def _fig_seasonality_panel( + df: pd.DataFrame, + gc_full: pd.DataFrame, + exp_keys: list[str], + title: str, + out_stem: str, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """Shared implementation for fig04a and fig04b. + + Draws daily energy (line per experiment) vs day-of-year, one panel per + SWOPP3 optimised case, for the subset of experiments given by *exp_keys*. + GC is always included as a grey dashed reference line. + """ + setup_style() + fig, axes = plt.subplots(2, 2, figsize=(13, 9)) + fig.suptitle(title, fontsize=12, fontweight="bold", x=0.02, ha="left") + + cases_order = [ + ("AO_WPS", "Atlantic — with WPS (Santander → New York)"), + ("AO_noWPS", "Atlantic — without WPS (Santander → New York)"), + ("PO_WPS", "Pacific — with WPS (Tokyo → Los Angeles)"), + ("PO_noWPS", "Pacific — without WPS (Tokyo → Los Angeles)"), + ] + + _MONTH_STARTS = [1, 32, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335] + _SEASON_SPANS = [ + (0.5, 59.5, "Winter"), + (59.5, 151.5, "Spring"), + (151.5, 243.5, "Summer"), + (243.5, 334.5, "Autumn"), + (334.5, 365.5, "Winter"), + ] + + for ax, (case_id, panel_title) in zip(axes.flat, cases_order, strict=False): + ax.set_title(panel_title, fontsize=10, fontweight="bold") + + for exp_key in exp_keys: + exp_meta = EXPERIMENTS_REGISTRY[exp_key] + piece = df[ + (df["experiment"] == exp_key) & (df["case_id"] == case_id) + ].copy() + if piece.empty: + continue + + piece["doy"] = piece["departure_time_utc"].dt.dayofyear + piece = piece.sort_values("doy") + + ax.plot( + piece["doy"], + piece["energy_cons_mwh"], + color=exp_meta["color"], + linewidth=1.4, + alpha=0.85, + label=exp_meta["label"], + zorder=4, + ) + + # GC reference line + gc_piece = gc_full[gc_full["case_id"] == case_id].copy() + gc_piece = gc_piece[_outlier_mask(gc_piece["energy_cons_mwh"])] + if not gc_piece.empty: + gc_piece["doy"] = gc_piece["departure_time_utc"].dt.dayofyear + gc_piece = gc_piece.sort_values("doy") + ax.plot( + gc_piece["doy"], + gc_piece["energy_cons_mwh"], + color="#878787", + linewidth=1.4, + linestyle="--", + alpha=0.75, + zorder=3, + ) + + # Season background shading + for start, end, s in _SEASON_SPANS: + ax.axvspan(start, end, alpha=0.06, color=SEASON_COLORS[s], zorder=1) + + ax.set_xlim(1, 365) + ax.set_xticks(_MONTH_STARTS) + ax.set_xticklabels(MONTH_ABBR, fontsize=8.5) + ax.set_xlabel("Departure date") + ax.set_ylabel("Energy consumption (MWh)") + ax.grid(axis="y", color="#E5E5E5", linewidth=0.7) + ax.set_axisbelow(True) + + # Legend + exp_handles = [ + mlines.Line2D( + [], + [], + color=EXPERIMENTS_REGISTRY[k]["color"], + linewidth=2.0, + alpha=0.85, + label=EXPERIMENTS_REGISTRY[k]["label"], + ) + for k in exp_keys + ] + exp_handles.append( + mlines.Line2D( + [], + [], + color="#878787", + linewidth=2.0, + linestyle="--", + alpha=0.75, + label="Great-circle baseline", + ) + ) + season_handles = [ + mpatches.Patch(facecolor=SEASON_COLORS[s], alpha=0.5, label=s) + for s in SEASON_ORDER + ] + fig.legend( + handles=exp_handles + season_handles, + loc="lower center", + ncol=4, + bbox_to_anchor=(0.5, -0.08), + fontsize=8.5, + ) + + # Equalise y-axis across all four panels + all_ylims = [ax.get_ylim() for ax in axes.flat] + ymin_all = min(y[0] for y in all_ylims) + ymax_all = max(y[1] for y in all_ylims) + for ax in axes.flat: + ax.set_ylim(ymin_all, ymax_all) + + add_source_note(fig) + out = paths.figs_dir / f"{out_stem}.pdf" + _save_figure_outputs(fig, out, bbox_inches="tight") + print(f" Saved {out.name}") + plt.close(fig) + + +def fig_seasonality_a( + df: pd.DataFrame, + gc_full: pd.DataFrame, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """fig04a — seasonal energy lines for the first experiment pair.""" + _b, _f = EXPERIMENT_PAIRS[0] + _fig_seasonality_panel( + df, + gc_full, + exp_keys=list(EXPERIMENT_PAIRS[0]), + title=( + f"Seasonal energy \u2014 {ACTIVE_EXPERIMENTS[_b]['short']}" + f" vs {ACTIVE_EXPERIMENTS[_f]['short']}" + f" (GC \u00b7 {ACTIVE_EXPERIMENTS[_b]['label']} \u00b7 {ACTIVE_EXPERIMENTS[_f]['label']})" # noqa: E501 + ), + out_stem="fig04a_seasonality_sweep_combined", + paths=paths, + ) + + +def fig_seasonality_b( + df: pd.DataFrame, + gc_full: pd.DataFrame, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """fig04b — seasonal energy lines for the last experiment pair. + + Identical to fig04a when only one pair is active. + """ + _b, _f = EXPERIMENT_PAIRS[-1] + _fig_seasonality_panel( + df, + gc_full, + exp_keys=list(EXPERIMENT_PAIRS[-1]), + title=( + f"Seasonal energy \u2014 {ACTIVE_EXPERIMENTS[_b]['short']}" + f" vs {ACTIVE_EXPERIMENTS[_f]['short']}" + f" (GC \u00b7 {ACTIVE_EXPERIMENTS[_b]['label']} \u00b7 {ACTIVE_EXPERIMENTS[_f]['label']})" # noqa: E501 + ), + out_stem="fig04b_seasonality_penalty", + paths=paths, + ) + + +def _fig_relative_gain_panel( + df: pd.DataFrame, + gc_full: pd.DataFrame, + exp_keys: list[str], + title: str, + out_stem: str, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """Shared implementation for fig13a and fig13b. + + For each departure, computes the relative energy saving vs the matched + GC departure: ``(gc_energy − exp_energy) / gc_energy × 100``. The + result is plotted as a connected daily line — two lines per panel, one + per experiment. A horizontal zero reference marks the break-even point. + """ + setup_style() + fig, axes = plt.subplots(2, 2, figsize=(13, 9)) + fig.suptitle(title, fontsize=12, fontweight="bold", x=0.02, ha="left") + + cases_order = [ + ("AO_WPS", "Atlantic — with WPS (Santander → New York)"), + ("AO_noWPS", "Atlantic — without WPS (Santander → New York)"), + ("PO_WPS", "Pacific — with WPS (Tokyo → Los Angeles)"), + ("PO_noWPS", "Pacific — without WPS (Tokyo → Los Angeles)"), + ] + + _MONTH_STARTS = [1, 32, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335] + _SEASON_SPANS = [ + (0.5, 59.5, "Winter"), + (59.5, 151.5, "Spring"), + (151.5, 243.5, "Summer"), + (243.5, 334.5, "Autumn"), + (334.5, 365.5, "Winter"), + ] + + for ax, (case_id, panel_title) in zip(axes.flat, cases_order, strict=False): + ax.set_title(panel_title, fontsize=10, fontweight="bold") + + gc_piece = gc_full[gc_full["case_id"] == case_id][ + ["departure_time_utc", "energy_cons_mwh"] + ].rename(columns={"energy_cons_mwh": "gc_energy"}) + + for exp_key in exp_keys: + exp_meta = EXPERIMENTS_REGISTRY[exp_key] + piece = df[(df["experiment"] == exp_key) & (df["case_id"] == case_id)][ + ["departure_time_utc", "energy_cons_mwh"] + ].copy() + if piece.empty or gc_piece.empty: + continue + + merged = piece.merge(gc_piece, on="departure_time_utc", how="inner") + merged = merged[merged["gc_energy"] > 0] # avoid division by zero + merged["saving_pct"] = ( + (merged["gc_energy"] - merged["energy_cons_mwh"]) + / merged["gc_energy"] + * 100 + ) + merged["doy"] = merged["departure_time_utc"].dt.dayofyear + merged = merged.sort_values("doy") + + ax.plot( + merged["doy"], + merged["saving_pct"], + color=exp_meta["color"], + linewidth=1.4, + alpha=0.85, + label=exp_meta["label"], + zorder=4, + ) + + # Zero break-even reference + ax.axhline(0, color="#878787", linewidth=1.0, linestyle="--", zorder=3) + + # Season background shading + for start, end, s in _SEASON_SPANS: + ax.axvspan(start, end, alpha=0.06, color=SEASON_COLORS[s], zorder=1) + + ax.set_xlim(1, 365) + ax.set_xticks(_MONTH_STARTS) + ax.set_xticklabels(MONTH_ABBR, fontsize=8.5) + ax.set_xlabel("Departure date") + ax.set_ylabel("Energy saving vs GC (%)") + ax.grid(axis="y", color="#E5E5E5", linewidth=0.7) + ax.set_axisbelow(True) + + # Legend + exp_handles = [ + mlines.Line2D( + [], + [], + color=EXPERIMENTS_REGISTRY[k]["color"], + linewidth=2.0, + alpha=0.85, + label=EXPERIMENTS_REGISTRY[k]["label"], + ) + for k in exp_keys + ] + exp_handles.append( + mlines.Line2D( + [], + [], + color="#878787", + linewidth=1.0, + linestyle="--", + alpha=0.75, + label="GC baseline (0 %)", + ) + ) + season_handles = [ + mpatches.Patch(facecolor=SEASON_COLORS[s], alpha=0.5, label=s) + for s in SEASON_ORDER + ] + fig.legend( + handles=exp_handles + season_handles, + loc="lower center", + ncol=4, + bbox_to_anchor=(0.5, -0.08), + fontsize=8.5, + ) + + # Equalise y-axis across all four panels + all_ylims = [ax.get_ylim() for ax in axes.flat] + ymin_all = min(y[0] for y in all_ylims) + ymax_all = max(y[1] for y in all_ylims) + for ax in axes.flat: + ax.set_ylim(ymin_all, ymax_all) + + add_source_note(fig) + out = paths.figs_dir / f"{out_stem}.pdf" + _save_figure_outputs(fig, out, bbox_inches="tight") + print(f" Saved {out.name}") + plt.close(fig) + + +def fig_relative_gain_a( + df: pd.DataFrame, + gc_full: pd.DataFrame, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """fig13a — relative energy gain vs GC for the first experiment pair.""" + _b, _f = EXPERIMENT_PAIRS[0] + _fig_relative_gain_panel( + df, + gc_full, + exp_keys=list(EXPERIMENT_PAIRS[0]), + title=( + f"Relative energy saving vs GC" + f" — {ACTIVE_EXPERIMENTS[_b]['short']} vs {ACTIVE_EXPERIMENTS[_f]['short']}" + ), + out_stem="fig13a_relative_gain_sweep_combined", + paths=paths, + ) + + +def fig_relative_gain_b( + df: pd.DataFrame, + gc_full: pd.DataFrame, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """fig13b — relative energy gain vs GC for the last experiment pair. + + Identical to fig13a when only one pair is active. + """ + _b, _f = EXPERIMENT_PAIRS[-1] + _fig_relative_gain_panel( + df, + gc_full, + exp_keys=list(EXPERIMENT_PAIRS[-1]), + title=( + f"Relative energy saving vs GC" + f" — {ACTIVE_EXPERIMENTS[_b]['short']} vs {ACTIVE_EXPERIMENTS[_f]['short']}" + ), + out_stem="fig13b_relative_gain_penalty", + paths=paths, + ) + + +# =========================================================================== +# FIGURE 5 — WPS impact +# =========================================================================== +def fig_wps_impact( + df: pd.DataFrame, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """Bar chart of absolute and relative WPS energy savings per experiment.""" + setup_style() + fig, axes = plt.subplots(1, 2, figsize=(11, 5)) + fig.suptitle( + "Wind-propulsion systems (WPS) cut energy use by 30 % on the Atlantic, 55 % on the Pacific", # noqa: E501 + fontsize=12, + fontweight="bold", + x=0.02, + ha="left", + ) + + route_groups = [ + ("atlantic", "Trans-Atlantic WPS savings", "AO_WPS", "AO_noWPS"), + ("pacific", "Trans-Pacific WPS savings", "PO_WPS", "PO_noWPS"), + ] + + for ax, (_route, title, wps_case, nowps_case) in zip( + axes, route_groups, strict=False + ): + ax.set_title(title, fontsize=10.5, fontweight="bold") + + exp_order = list(ACTIVE_EXPERIMENTS.keys()) + + x = np.arange(len(exp_order)) + bar_w = 0.55 + + abs_savings = [] + rel_savings = [] + for exp_key in exp_order: + wps_e = df[(df["experiment"] == exp_key) & (df["case_id"] == wps_case)][ + "energy_cons_mwh" + ] + nowps_e = df[(df["experiment"] == exp_key) & (df["case_id"] == nowps_case)][ + "energy_cons_mwh" + ] + if wps_e.empty or nowps_e.empty: + abs_savings.append(np.nan) + rel_savings.append(np.nan) + continue + savings = nowps_e.mean() - wps_e.mean() + rel = savings / nowps_e.mean() * 100 + abs_savings.append(savings) + rel_savings.append(rel) + + colors = [ACTIVE_EXPERIMENTS[k]["color"] for k in exp_order] + bars = ax.bar(x, abs_savings, width=bar_w, color=colors, alpha=0.88, zorder=3) + + # Annotate with % saving + for bar, val, rel in zip(bars, abs_savings, rel_savings, strict=False): + if not np.isnan(val): + ax.text( + bar.get_x() + bar.get_width() / 2, + val + 0.5, + f"{rel:.0f}% less", + ha="center", + va="bottom", + fontsize=9, + fontweight="bold", + color=bar.get_facecolor(), + ) + + ax.set_xticks(x) + ax.set_xticklabels( + [ACTIVE_EXPERIMENTS[k]["short"] for k in exp_order], fontsize=8.5 + ) + ax.set_ylabel("WPS energy saving (MWh)") + ax.grid(axis="y", color="#E5E5E5", linewidth=0.7) + ax.set_axisbelow(True) + + # Equalise y-axis so Atlantic vs Pacific savings are directly comparable + ymax = max(ax.get_ylim()[1] for ax in axes) + for ax in axes: + ax.set_ylim(0, ymax) + + add_source_note(fig) + out = paths.figs_dir / "fig05_wps_impact.pdf" + _save_figure_outputs(fig, out) + print(f" Saved {out.name}") + plt.close(fig) + + +# =========================================================================== +# FIGURE 6 — FMS improvement scatter +# =========================================================================== +def fig_fms_improvement( + df: pd.DataFrame, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """Scatter plot: CMA-ES energy vs CMA-ES+FMS energy (each point = one departure).""" + setup_style() + + # Pairs: (base experiment, fms experiment, title); derived from EXPERIMENT_PAIRS + pairs = [ + ( + p[0], + p[1], + f"{ACTIVE_EXPERIMENTS[p[0]]['short']}" + f" vs {ACTIVE_EXPERIMENTS[p[1]]['short']}", + ) + for p in EXPERIMENT_PAIRS + ] + + n_pairs = len(pairs) + fig, _axes_arr = plt.subplots(1, n_pairs, figsize=(7 * n_pairs, 5), squeeze=False) + axes = list(_axes_arr.flat) + fig.suptitle( + "FMS refinement consistently reduces energy — gains are largest for low-energy routes", # noqa: E501 + fontsize=12, + fontweight="bold", + x=0.02, + ha="left", + ) + + # Pre-compute global axis limits so both panels share the same scale + _fig6_all: list[float] = [] + for _b, _f, _ in pairs: + for _c in OPT_CASES: + _fig6_all += ( + df[(df["experiment"] == _b) & (df["case_id"] == _c)]["energy_cons_mwh"] + .dropna() + .tolist() + ) + _fig6_all += ( + df[(df["experiment"] == _f) & (df["case_id"] == _c)]["energy_cons_mwh"] + .dropna() + .tolist() + ) + glim_lo = np.nanmin(_fig6_all) * 0.9 + glim_hi = np.nanmax(_fig6_all) * 1.05 + + for ax, (base_exp, fms_exp, panel_title) in zip(axes, pairs, strict=False): + ax.set_title(panel_title, fontsize=10.5, fontweight="bold") + + for case_id, case_meta in OPT_CASES.items(): + base = df[ + (df["experiment"] == base_exp) & (df["case_id"] == case_id) + ].set_index("departure_time_utc") + fms = df[ + (df["experiment"] == fms_exp) & (df["case_id"] == case_id) + ].set_index("departure_time_utc") + joined = base[["energy_cons_mwh"]].join( + fms[["energy_cons_mwh"]], lsuffix="_base", rsuffix="_fms", how="inner" + ) + if joined.empty: + continue + + ax.scatter( + joined["energy_cons_mwh_base"], + joined["energy_cons_mwh_fms"], + color=case_meta["color"], + alpha=0.35, + s=12, + label=case_meta["label_short"], + zorder=3, + ) + + lim_lo, lim_hi = glim_lo, glim_hi + + # Diagonal x=y (no improvement) + ax.plot( + [lim_lo, lim_hi], + [lim_lo, lim_hi], + color="#444", + linewidth=1, + linestyle="--", + alpha=0.6, + label="No improvement", + zorder=5, + ) + + # 5% improvement line + ax.plot( + [lim_lo, lim_hi], + [lim_lo * 0.95, lim_hi * 0.95], + color="#888", + linewidth=0.8, + linestyle=":", + alpha=0.7, + label="5% improvement", + zorder=4, + ) + + ax.set_xlim(lim_lo, lim_hi) + ax.set_ylim(lim_lo, lim_hi) + ax.set_xlabel(f"{ACTIVE_EXPERIMENTS[base_exp]['label']} energy (MWh)") + ax.set_ylabel(f"{ACTIVE_EXPERIMENTS[fms_exp]['label']} energy (MWh)") + ax.set_aspect("equal", adjustable="box") + ax.legend(fontsize=8, loc="upper left", markerscale=1.5) + + add_source_note(fig) + out = paths.figs_dir / "fig06_fms_improvement.pdf" + _save_figure_outputs(fig, out) + print(f" Saved {out.name}") + plt.close(fig) + + +# =========================================================================== +# GC track loader (used by fig07) +# =========================================================================== +def load_gc_tracks( + gc_case: str, + paths: AnalysisPaths = DEFAULT_PATHS, + season_filter: str | None = None, + n_sample: int = 5, +) -> list[pd.DataFrame]: + """Load sample GC track DataFrames from the first base experiment folder.""" + folder = _experiment_folder(EXPERIMENT_PAIRS[0][0], paths) + tracks_dir = paths.output_dir / folder / "tracks" + summary_path = _summary_csv_path(paths, folder, gc_case) + if summary_path is None or not summary_path.exists(): + return [] + gc_df = pd.read_csv( + summary_path, parse_dates=["departure_time_utc", "arrival_time_utc"] + ) + gc_df["season"] = gc_df["departure_time_utc"].dt.month.map(_MONTH_TO_SEASON) + if season_filter: + gc_df = gc_df[gc_df["season"] == season_filter] + # Keep GC track sampling deterministic so figure exports stay reproducible. + sample = gc_df.sample(min(n_sample, len(gc_df)), replace=False, random_state=7) + sample = sample.sort_values("departure_time_utc") + result = [] + for _, row in sample.iterrows(): + fpath = tracks_dir / row["details_filename"] + if fpath.exists(): + trk = pd.read_csv(fpath, parse_dates=["time_utc"]) + result.append(trk) + return result + + +# =========================================================================== +# FIGURE 7 — Route maps +# =========================================================================== +def fig_route_maps(paths: AnalysisPaths = DEFAULT_PATHS) -> None: + """Geographic maps showing representative routes for Atlantic and Pacific.""" + setup_style() + + def _plot_wrapped_track( + ax: plt.Axes, + lon_vals: np.ndarray, + lat_vals: np.ndarray, + *, + central_longitude: float, + **plot_kwargs: object, + ) -> None: + """Plot a track split at antimeridian crossings to avoid long wrap lines.""" + lon = np.asarray(lon_vals, dtype=float) + lat = np.asarray(lat_vals, dtype=float) + valid = np.isfinite(lon) & np.isfinite(lat) + if not valid.any(): + return + + lon = lon[valid] + lat = lat[valid] + lon = ((lon - central_longitude + 180.0) % 360.0) - 180.0 + central_longitude + + split_idx = np.where(np.abs(np.diff(lon)) > 180.0)[0] + 1 + lon_segments = np.split(lon, split_idx) + lat_segments = np.split(lat, split_idx) + + for lon_seg, lat_seg in zip(lon_segments, lat_segments, strict=False): + if len(lon_seg) < 2: + continue + ax.plot( + lon_seg, + lat_seg, + transform=ccrs.PlateCarree(), + **plot_kwargs, + ) + + # Cartopy/constrained_layout are incompatible — disable for this figure + with mpl.rc_context({"figure.constrained_layout.use": False}): + fig = plt.figure(figsize=(14, 6), facecolor="#FAFAF7") + fig.suptitle( + "Optimised routes diverge from great circles to exploit prevailing winds and avoid storms", # noqa: E501 + fontsize=12, + fontweight="bold", + x=0.02, + ha="left", + ) + + # Atlantic map + ax_atl = fig.add_subplot( + 1, + 2, + 1, + projection=ccrs.PlateCarree(central_longitude=-40), + ) + # Pacific map (centred at 180° to avoid antimeridian split) + ax_pac = fig.add_subplot( + 1, + 2, + 2, + projection=ccrs.PlateCarree(central_longitude=180), + ) + + route_configs = [ + { + "ax": ax_atl, + "title": "Trans-Atlantic (Santander → New York)", + "central_longitude": -40, + "extent": [-80, 15, 25, 65], + "cases": ["AO_WPS", "AO_noWPS"], + "gc_case": "AGC_WPS", + "seasons": ["Winter", "Summer"], + }, + { + "ax": ax_pac, + "title": "Trans-Pacific (Tokyo → Los Angeles)", + "central_longitude": 180, + "extent": [115, 250, 20, 65], + "cases": ["PO_WPS", "PO_noWPS"], + "gc_case": "PGC_WPS", + "seasons": ["Winter", "Summer"], + }, + ] + + for cfg in route_configs: + ax = cfg["ax"] + ax.set_extent(cfg["extent"], crs=ccrs.PlateCarree()) + ax.add_feature(cfeature.LAND, facecolor="#D9D0C3", zorder=1) + ax.add_feature(cfeature.OCEAN, facecolor="#EFF5FF", zorder=0) + ax.add_feature( + cfeature.COASTLINE, linewidth=0.5, edgecolor="#7D7D7D", zorder=2 + ) + ax.add_feature( + cfeature.BORDERS, linewidth=0.3, edgecolor="#BBBBBB", zorder=2 + ) + gl = ax.gridlines( + draw_labels=True, + linewidth=0.4, + color="#CCCCCC", + x_inline=False, + y_inline=False, + ) + gl.xlabel_style = {"size": 7} + gl.ylabel_style = {"size": 7} + ax.set_title(cfg["title"], fontsize=10, fontweight="bold", pad=6) + + for exp_key in ACTIVE_EXPERIMENTS: + exp_meta = ACTIVE_EXPERIMENTS[exp_key] + for case_id in cfg["cases"]: + for season in cfg["seasons"]: + tracks = load_tracks( + exp_key, + case_id, + paths, + season_filter=season, + n_sample=4, + ) + alpha = 0.55 if season == "Winter" else 0.35 + for trk in tracks: + _plot_wrapped_track( + ax, + trk["lon_deg"].values, + trk["lat_deg"].values, + central_longitude=cfg["central_longitude"], + color=exp_meta["color"], + linewidth=0.85, + alpha=alpha, + zorder=3, + ) + + # Great-circle reference routes — dashed dark grey + gc_case = cfg.get("gc_case") + if gc_case: + for season in cfg["seasons"]: + alpha_gc = 0.80 if season == "Winter" else 0.45 + for trk in load_gc_tracks( + gc_case, + paths, + season_filter=season, + n_sample=4, + ): + _plot_wrapped_track( + ax, + trk["lon_deg"].values, + trk["lat_deg"].values, + central_longitude=cfg["central_longitude"], + color="#111111", + linewidth=1.5, + linestyle="--", + alpha=alpha_gc, + zorder=5, + ) + + # Legend + exp_lines = [ + mlines.Line2D( + [], + [], + color=ACTIVE_EXPERIMENTS[k]["color"], + linewidth=2, + label=ACTIVE_EXPERIMENTS[k]["label"], + ) + for k in ACTIVE_EXPERIMENTS + ] + legend_elements = ( + [ + mlines.Line2D( + [], + [], + color="#111111", + linewidth=1.5, + linestyle="--", + label="Great-circle route", + ), + ] + + exp_lines + + [ + mlines.Line2D( + [], + [], + color="#555", + linewidth=2, + alpha=0.75, + label="Winter departures (bold)", + ), + mlines.Line2D( + [], + [], + color="#555", + linewidth=2, + alpha=0.40, + label="Summer departures (faint)", + ), + ] + ) + fig.legend( + handles=legend_elements, + loc="lower center", + ncol=1 + len(ACTIVE_EXPERIMENTS) + 2, + bbox_to_anchor=(0.5, -0.01), + fontsize=8.5, + ) + + add_source_note(fig) + fig.tight_layout(rect=[0, 0.05, 1, 0.93]) + out = paths.figs_dir / "fig07_route_maps.pdf" + _save_figure_outputs(fig, out, bbox_inches="tight") + print(f" Saved {out.name}") + plt.close(fig) + + +# =========================================================================== +# FIGURE 8 — Risk calendar (heatmap of violation rate) +# =========================================================================== +def fig_risk_calendar( + df: pd.DataFrame, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """Heatmap: departure month × case × experiment — violation rate.""" + setup_style() + + cases_order = list(OPT_CASES.keys()) + months = np.arange(1, 13) + n_exp = len(ACTIVE_EXPERIMENTS) + + fig, axes = plt.subplots( + 2, + n_exp, + figsize=(6.5 * n_exp, 7), + gridspec_kw={"height_ratios": [1, 1]}, + squeeze=False, + ) + fig.suptitle( + "Experiment comparison — monthly weather violation rates", # noqa: E501 + fontsize=12, + fontweight="bold", + x=0.02, + ha="left", + ) + + viol_titles = { + "wind_viol": "Wind violations (> 20 m/s)", + "wave_viol": "Wave violations (> 7 m Hs)", + } + viol_cmaps = { + "wind_viol": "Reds", + "wave_viol": "Blues", + } + + for row_idx, viol_col in enumerate(["wind_viol", "wave_viol"]): + for col_idx, exp_key in enumerate(ACTIVE_EXPERIMENTS): + ax = axes[row_idx][col_idx] + exp_label = ACTIVE_EXPERIMENTS[exp_key]["label"] + ax.set_title( + f"{viol_titles[viol_col]}\n{exp_label}", + fontsize=9.5, + fontweight="bold", + ) + + # Build heatmap matrix: rows=cases, cols=months + matrix = np.full((len(cases_order), len(months)), np.nan) + for i, case_id in enumerate(cases_order): + piece = df[(df["experiment"] == exp_key) & (df["case_id"] == case_id)] + if piece.empty: + continue + monthly = piece.groupby("month")[viol_col].mean() * 100 + for j, m in enumerate(months): + matrix[i, j] = monthly.get(m, np.nan) + + cmap = plt.get_cmap(viol_cmaps[viol_col]) + im = ax.imshow( + matrix, + cmap=cmap, + aspect="auto", + vmin=0, + vmax=50, + origin="upper", + ) + + ax.set_xticks(np.arange(12)) + ax.set_xticklabels(MONTH_ABBR, fontsize=8) + ax.set_yticks(np.arange(len(cases_order))) + ax.set_yticklabels( + [OPT_CASES[c]["label_short"] for c in cases_order], + fontsize=8.5, + ) + ax.tick_params(left=True, bottom=True) + ax.grid(False) + + # Annotate cells + for i in range(len(cases_order)): + for j in range(12): + val = matrix[i, j] + if not np.isnan(val): + text_color = "white" if val > 25 else "#333333" + ax.text( + j, + i, + f"{val:.0f}%", + ha="center", + va="center", + fontsize=7.5, + color=text_color, + fontweight="bold", + ) + + cb = plt.colorbar(im, ax=ax, fraction=0.03, pad=0.02) + cb.set_label("Violation rate (%)", fontsize=8) + cb.ax.tick_params(labelsize=7.5) + + add_source_note(fig) + out = paths.figs_dir / "fig08_risk_calendar.pdf" + _save_figure_outputs(fig, out) + print(f" Saved {out.name}") + plt.close(fig) + + +# =========================================================================== +# SUMMARY TABLE +# =========================================================================== +def generate_summary_table( + df: pd.DataFrame, + gc: dict[str, float], + paths: AnalysisPaths = DEFAULT_PATHS, +) -> pd.DataFrame: + """Generate and save a summary statistics table.""" + rows = [] + for exp_key in ACTIVE_EXPERIMENTS: + for case_id in OPT_CASES: + piece = df[(df["experiment"] == exp_key) & (df["case_id"] == case_id)] + if piece.empty: + continue + gc_id = OPT_CASES[case_id]["gc"] + gc_mean = gc.get(gc_id, np.nan) + mean_e = piece["energy_cons_mwh"].mean() + rows.append( + { + "Experiment": ACTIVE_EXPERIMENTS[exp_key]["label"], + "Case": OPT_CASES[case_id]["label"].replace("\n", " "), + "N departures": len(piece), + "Mean energy (MWh)": round(mean_e, 1), + "Median energy (MWh)": round(piece["energy_cons_mwh"].median(), 1), + "Std energy (MWh)": round(piece["energy_cons_mwh"].std(), 1), + "GC baseline (MWh)": round(gc_mean, 1), + "Saving vs GC (%)": round((gc_mean - mean_e) / gc_mean * 100, 1), + "Wind violation (%)": round(piece["wind_viol"].mean() * 100, 1), + "Wave violation (%)": round(piece["wave_viol"].mean() * 100, 1), + "Mean distance (nm)": round(piece["sailed_distance_nm"].mean(), 0), + } + ) + summary = pd.DataFrame(rows) + out = paths.figs_dir / "table01_summary.csv" + summary.to_csv(out, index=False) + print(f" Saved {out.name}") + return summary + + +# =========================================================================== +# BONUS: FMS delta plot — per-voyage improvement +# =========================================================================== +def fig_fms_delta_byseason( + df: pd.DataFrame, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """Bar chart: median FMS improvement (%) by season and by case.""" + setup_style() + + pairs = list(EXPERIMENT_PAIRS) + n_pairs = len(pairs) + fig, _axes_arr = plt.subplots(1, n_pairs, figsize=(8 * n_pairs, 5), squeeze=False) + axes = list(_axes_arr.flat) + fig.suptitle( + "FMS refinement is most effective in winter and for no-penalty runs", + fontsize=12, + fontweight="bold", + x=0.02, + ha="left", + ) + + bar_w = 0.18 + cases_order = list(OPT_CASES.keys()) + bar_positions = np.arange(len(SEASON_ORDER)) + + for ax, (base_exp, fms_exp) in zip(axes, pairs, strict=False): + ax.set_title( + f"{ACTIVE_EXPERIMENTS[base_exp]['label']}" + f" vs {ACTIVE_EXPERIMENTS[fms_exp]['label']}", + fontsize=10.5, + fontweight="bold", + ) + offsets = np.linspace( + -(len(cases_order) - 1) / 2 * bar_w, + (len(cases_order) - 1) / 2 * bar_w, + len(cases_order), + ) + + for j, case_id in enumerate(cases_order): + base = df[ + (df["experiment"] == base_exp) & (df["case_id"] == case_id) + ].set_index("departure_time_utc") + fms_d = df[ + (df["experiment"] == fms_exp) & (df["case_id"] == case_id) + ].set_index("departure_time_utc") + joined = base[["energy_cons_mwh", "season"]].join( + fms_d[["energy_cons_mwh"]], + lsuffix="_base", + rsuffix="_fms", + how="inner", + ) + if joined.empty: + continue + joined["delta_pct"] = ( + (joined["energy_cons_mwh_base"] - joined["energy_cons_mwh_fms"]) + / joined["energy_cons_mwh_base"] + * 100 + ) + + medians = [ + joined.loc[joined["season"] == s, "delta_pct"].median() + if s in joined["season"].values + else np.nan + for s in SEASON_ORDER + ] + + xs = bar_positions + offsets[j] + bars = ax.bar( + xs, + medians, + width=bar_w * 0.9, + color=OPT_CASES[case_id]["color"], + alpha=0.85, + label=OPT_CASES[case_id]["label_short"], + zorder=3, + ) + for bar, val in zip(bars, medians, strict=False): + if not np.isnan(val): + ax.text( + bar.get_x() + bar.get_width() / 2, + val + 0.2 if val >= 0 else val - 0.5, + f"{val:.1f}%", + ha="center", + va="bottom" if val >= 0 else "top", + fontsize=6.5, + color=OPT_CASES[case_id]["color"], + fontweight="bold", + ) + + ax.axhline(0, color="#444", linewidth=0.8) + ax.set_xticks(bar_positions) + ax.set_xticklabels(SEASON_ORDER, fontsize=9) + ax.set_ylabel("Median energy reduction from FMS (%)") + ax.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=100, decimals=0)) + ax.grid(axis="y", color="#E5E5E5", linewidth=0.7) + ax.set_axisbelow(True) + + handles = [ + mpatches.Patch( + facecolor=OPT_CASES[c]["color"], + alpha=0.85, + label=OPT_CASES[c]["label_short"], + ) + for c in cases_order + ] + fig.legend( + handles=handles, + loc="lower center", + ncol=4, + bbox_to_anchor=(0.5, -0.04), + fontsize=9, + ) + + # Equalise y-axis (single panel) + ymin = axes[0].get_ylim()[0] + ymax = axes[0].get_ylim()[1] + axes[0].set_ylim(ymin, ymax) + + add_source_note(fig) + out = paths.figs_dir / "fig09_fms_seasonal_delta.pdf" + _save_figure_outputs(fig, out) + print(f" Saved {out.name}") + plt.close(fig) + + +# =========================================================================== +# GC per-departure data loader +# =========================================================================== +# Maps each GC case to the corresponding optimised case +_GC_TO_OPT = { + "AGC_WPS": "AO_WPS", + "AGC_noWPS": "AO_noWPS", + "PGC_WPS": "PO_WPS", + "PGC_noWPS": "PO_noWPS", +} + + +def load_gc_full(paths: AnalysisPaths = DEFAULT_PATHS) -> pd.DataFrame: + """Load per-departure GC rows and map them onto optimised-case ids. + + GC summaries are read from the first base experiment folder because that run + contains the reference great-circle exports for all four cases. + """ + folder = _experiment_folder(EXPERIMENT_PAIRS[0][0], paths) + frames = [] + for gc_id, opt_id in _GC_TO_OPT.items(): + path = _summary_csv_path(paths, folder, gc_id) + if path is None or not path.exists(): + continue + gc = pd.read_csv(path, parse_dates=["departure_time_utc", "arrival_time_utc"]) + gc["case_id"] = opt_id + gc["gc_id"] = gc_id + gc["month"] = gc["departure_time_utc"].dt.month + gc["season"] = gc["month"].map(_MONTH_TO_SEASON) + gc["wind_viol"] = gc["max_wind_mps"] > WIND_LIMIT + gc["wave_viol"] = gc["max_hs_m"] > WAVE_LIMIT + gc["any_viol"] = gc["wind_viol"] | gc["wave_viol"] + frames.append(gc) + return pd.concat(frames, ignore_index=True) + + +def _join_opt_to_gc( + df: pd.DataFrame, gc_full: pd.DataFrame, exp_key: str, case_id: str +) -> pd.DataFrame: + """Join one experiment/case slice against its matched GC departure rows. + + The join is keyed by ``departure_time_utc`` so every comparison uses the + same calendar departure in the optimised and great-circle datasets. + """ + opt = ( + df[(df["experiment"] == exp_key) & (df["case_id"] == case_id)] + .set_index("departure_time_utc")[["energy_cons_mwh", "month", "season"]] + .rename(columns={"energy_cons_mwh": "energy_opt"}) + ) + gc = ( + gc_full[gc_full["case_id"] == case_id] + .set_index("departure_time_utc")[["energy_cons_mwh"]] + .rename(columns={"energy_cons_mwh": "energy_gc"}) + ) + joined = opt.join(gc, how="inner") + joined["margin_pct"] = ( + (joined["energy_gc"] - joined["energy_opt"]) / joined["energy_gc"] * 100 + ) + joined["beats_gc"] = joined["margin_pct"] > 0 + return joined.reset_index() + + +# =========================================================================== +# FIGURE 10 — Monthly "victory rate" over GC +# =========================================================================== +def fig_gc_victory_rate( + df: pd.DataFrame, + gc_full: pd.DataFrame, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """Monthly % of departures that beat the GC energy for each case × experiment.""" + setup_style() + fig, axes = plt.subplots(2, 2, figsize=(13, 9)) + fig.suptitle( + "How often do we beat the great-circle route? A month-by-month scorecard", + fontsize=12, + fontweight="bold", + x=0.02, + ha="left", + ) + + cases_order = [ + ("AO_WPS", "Atlantic — with WPS"), + ("AO_noWPS", "Atlantic — without WPS"), + ("PO_WPS", "Pacific — with WPS"), + ("PO_noWPS", "Pacific — without WPS"), + ] + months = np.arange(1, 13) + + for ax, (case_id, panel_title) in zip(axes.flat, cases_order, strict=False): + ax.set_title(panel_title, fontsize=10, fontweight="bold") + + # 50 % reference band + ax.axhspan(45, 55, color="#E5E5E5", alpha=0.5, zorder=1) + ax.axhline( + 50, + color="#888", + linewidth=1.0, + linestyle="--", + zorder=2, + label="50% threshold", + ) + + for exp_key in ACTIVE_EXPERIMENTS: + joined = _join_opt_to_gc(df, gc_full, exp_key, case_id) + if joined.empty: + continue + monthly_rate = (joined.groupby("month")["beats_gc"].mean() * 100).reindex( + months + ) + ax.plot( + monthly_rate.index, + monthly_rate.values, + color=ACTIVE_EXPERIMENTS[exp_key]["color"], + linewidth=2.0, + marker="o", + markersize=4, + label=ACTIVE_EXPERIMENTS[exp_key]["label"], + zorder=4, + alpha=0.92, + ) + + # Season background + for start, end, s in [ + (0.5, 2.5, "Winter"), + (2.5, 5.5, "Spring"), + (5.5, 8.5, "Summer"), + (8.5, 11.5, "Autumn"), + (11.5, 12.5, "Winter"), + ]: + ax.axvspan(start, end, alpha=0.05, color=SEASON_COLORS[s], zorder=0) + + ax.set_xticks(months) + ax.set_xticklabels(MONTH_ABBR, fontsize=8) + ax.set_xlabel("Departure month") + ax.set_ylabel("Departures beating GC (%)") + ax.set_ylim(40, 105) + ax.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=100, decimals=0)) + ax.grid(axis="y", color="#E5E5E5", linewidth=0.7) + ax.set_axisbelow(True) + + exp_handles = [ + mlines.Line2D( + [], + [], + color=ACTIVE_EXPERIMENTS[k]["color"], + linewidth=2, + marker="o", + markersize=4, + label=ACTIVE_EXPERIMENTS[k]["label"], + ) + for k in ACTIVE_EXPERIMENTS + ] + season_handles = [ + mpatches.Patch(facecolor=SEASON_COLORS[s], alpha=0.5, label=s) + for s in SEASON_ORDER + ] + fig.legend( + handles=exp_handles + season_handles, + loc="lower center", + ncol=4, + bbox_to_anchor=(0.5, -0.03), + fontsize=8.5, + ) + add_source_note(fig) + out = paths.figs_dir / "fig10_gc_victory_rate.pdf" + _save_figure_outputs(fig, out) + print(f" Saved {out.name}") + plt.close(fig) + + +# =========================================================================== +# FIGURE 11 — Margin-over-GC heatmap +# =========================================================================== +def fig_gc_margin_heatmap( + df: pd.DataFrame, + gc_full: pd.DataFrame, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """Heatmap: median % margin over GC (rows=experiments, cols=months) per case.""" + setup_style() + # 2×2 grid of cases; each subplot is an experiment×month heatmap + fig, axes = plt.subplots(2, 2, figsize=(13, 9)) + fig.suptitle( + "Energy margin over the great-circle route — darker green means a bigger win", + fontsize=12, + fontweight="bold", + x=0.02, + ha="left", + ) + + cases_order = [ + ("AO_WPS", "Atlantic — with WPS"), + ("AO_noWPS", "Atlantic — without WPS"), + ("PO_WPS", "Pacific — with WPS"), + ("PO_noWPS", "Pacific — without WPS"), + ] + + import matplotlib.colors as mcolors + + # Sequential green: since we always beat GC, show "how much we win" + cmap = mcolors.LinearSegmentedColormap.from_list( + "gc_margin_win", + [ + "#F8F8F8", # near-zero margin → white + "#C7E5A0", # moderate margin → light green + "#6DC201", # large margin → IE tech green + ], + N=256, + ) + + # Collect all margin values — use 0 to 95th pct (positive range only) + all_margins: list[float] = [] + for case_id, _ in cases_order: + for exp_key in ACTIVE_EXPERIMENTS: + joined = _join_opt_to_gc(df, gc_full, exp_key, case_id) + if not joined.empty: + all_margins.extend(joined["margin_pct"].dropna().tolist()) + vabs = np.nanpercentile(all_margins, 95) # upper bound + + for ax, (case_id, panel_title) in zip(axes.flat, cases_order, strict=False): + ax.set_title(panel_title, fontsize=10, fontweight="bold") + + matrix = np.full((len(ACTIVE_EXPERIMENTS), 12), np.nan) + exp_labels = [] + for i, exp_key in enumerate(ACTIVE_EXPERIMENTS): + exp_labels.append(ACTIVE_EXPERIMENTS[exp_key]["label"]) + joined = _join_opt_to_gc(df, gc_full, exp_key, case_id) + if joined.empty: + continue + for m in range(1, 13): + vals = joined.loc[joined["month"] == m, "margin_pct"] + if len(vals) > 0: + matrix[i, m - 1] = vals.median() + + im = ax.imshow( + matrix, + aspect="auto", + cmap=cmap, + vmin=0, + vmax=vabs, + interpolation="nearest", + ) + + # Annotate cells + for i in range(len(ACTIVE_EXPERIMENTS)): + for j in range(12): + val = matrix[i, j] + if not np.isnan(val): + txt_color = "white" if val > vabs * 0.60 else "#333" + ax.text( + j, + i, + f"{val:+.0f}%", + ha="center", + va="center", + fontsize=6.5, + color=txt_color, + fontweight="bold", + ) + + ax.set_xticks(np.arange(12)) + ax.set_xticklabels(MONTH_ABBR, fontsize=8) + ax.set_yticks(np.arange(len(ACTIVE_EXPERIMENTS))) + ax.set_yticklabels(exp_labels, fontsize=7.5) + + # Month-season separators + for sep in [2.5, 5.5, 8.5, 11.5]: + ax.axvline(sep, color="#888", linewidth=0.6, linestyle=":") + + plt.colorbar( + im, + ax=ax, + shrink=0.7, + label="Median margin over GC (%)", + format=mticker.FuncFormatter(lambda x, _: f"{x:+.0f}%"), + ) + + add_source_note(fig) + out = paths.figs_dir / "fig11_gc_margin_heatmap.pdf" + _save_figure_outputs(fig, out) + print(f" Saved {out.name}") + plt.close(fig) + + +# =========================================================================== +# FIGURE 12 — GC's own violations: the unfair baseline +# =========================================================================== +def fig_gc_violations( + df: pd.DataFrame, + gc_full: pd.DataFrame, + paths: AnalysisPaths = DEFAULT_PATHS, +) -> None: + """Monthly 'any violation' rate — GC vs best optimised (Penalty + FMS).""" + setup_style() + best_exp = EXPERIMENT_PAIRS[-1][1] + GC_COLOR = "#878787" + OPT_COLOR = ACTIVE_EXPERIMENTS[best_exp]["color"] + + fig, axes = plt.subplots(2, 2, figsize=(13, 9)) + fig.suptitle( + "Optimised routing reduces dangerous weather exposure in the Atlantic — Pacific wind routes face an energy–safety tradeoff", # noqa: E501 + fontsize=12, + fontweight="bold", + x=0.02, + ha="left", + ) + + cases_order = [ + ("AO_WPS", "Atlantic — with WPS"), + ("AO_noWPS", "Atlantic — without WPS"), + ("PO_WPS", "Pacific — with WPS"), + ("PO_noWPS", "Pacific — without WPS"), + ] + months = np.arange(1, 13) + bar_w = 0.38 + + for ax, (case_id, panel_title) in zip(axes.flat, cases_order, strict=False): + ax.set_title(panel_title, fontsize=10, fontweight="bold") + + gc_piece = gc_full[gc_full["case_id"] == case_id] + opt_piece = df[(df["experiment"] == best_exp) & (df["case_id"] == case_id)] + + gc_any = gc_piece.groupby("month")["any_viol"].mean() * 100 + opt_any = opt_piece.groupby("month")["any_viol"].mean() * 100 + + x = months - 1 # 0-indexed + + ax.bar( + x - bar_w / 2, + gc_any.reindex(months, fill_value=0), + width=bar_w * 0.92, + color=GC_COLOR, + alpha=0.75, + label="Great-circle", + zorder=3, + ) + ax.bar( + x + bar_w / 2, + opt_any.reindex(months, fill_value=0), + width=bar_w * 0.92, + color=OPT_COLOR, + alpha=0.88, + label="CMA-ES + Penalty + FMS", + zorder=3, + ) + + # Annotate the biggest reductions + for m_idx in range(12): + gc_val = gc_any.get(m_idx + 1, 0) + opt_val = opt_any.get(m_idx + 1, 0) + reduction = gc_val - opt_val + if gc_val > 15 and reduction > 8: + ax.text( + m_idx, + max(gc_val, opt_val) + 1.5, + f"\u2212{reduction:.0f}pp", + ha="center", + va="bottom", + fontsize=6.5, + color="#444", + ) + + # Season background + for start, end, s in [ + (-0.5, 1.5, "Winter"), + (1.5, 4.5, "Spring"), + (4.5, 7.5, "Summer"), + (7.5, 10.5, "Autumn"), + (10.5, 11.5, "Winter"), + ]: + ax.axvspan(start, end, alpha=0.05, color=SEASON_COLORS[s], zorder=0) + + ax.set_xticks(np.arange(12)) + ax.set_xticklabels(MONTH_ABBR, fontsize=8) + ax.set_xlabel("Departure month") + ax.set_ylabel("Departures with any weather violation (%)") + ax.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=100, decimals=0)) + ax.grid(axis="y", color="#E5E5E5", linewidth=0.7) + ax.set_axisbelow(True) + + # In Pacific WPS panels, warn that WPS routes seek high-wind areas + if "PO_WPS" in case_id: + ax.text( + 0.97, + 0.96, + "WPS routes seek\nwindy/wavy areas", + transform=ax.transAxes, + ha="right", + va="top", + fontsize=7.5, + color="#555", + style="italic", + ) + + # Shared y-axis + ymax = max(ax.get_ylim()[1] for ax in axes.flat) + for ax in axes.flat: + ax.set_ylim(0, ymax) + + handles = [ + mpatches.Patch(facecolor=GC_COLOR, alpha=0.75, label="Great-circle route"), + mpatches.Patch( + facecolor=OPT_COLOR, + alpha=0.88, + label=ACTIVE_EXPERIMENTS[best_exp]["label"], + ), + ] + fig.legend( + handles=handles, + loc="lower center", + ncol=2, + bbox_to_anchor=(0.5, -0.04), + fontsize=9, + ) + add_source_note(fig) + out = paths.figs_dir / "fig12_gc_violations.pdf" + _save_figure_outputs(fig, out) + print(f" Saved {out.name}") + plt.close(fig) + + +# =========================================================================== +# CLI +# =========================================================================== +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + p = argparse.ArgumentParser( + description="SWOPP3 2024 comparative analysis — generate figures and summary table." # noqa: E501 + ) + p.add_argument( + "--data-dir", + type=Path, + default=None, + metavar="DIR", + help="Root directory containing experiment output folders (default: /output).", # noqa: E501 + ) + p.add_argument( + "--output-dir", + type=Path, + default=None, + metavar="DIR", + help="Directory where figures and tables are saved (default: /output/analysis).", # noqa: E501 + ) + p.add_argument( + "--dpi", + type=int, + default=180, + metavar="DPI", + help="Figure resolution in DPI (default: 180).", + ) + p.add_argument( + "--figures", + nargs="+", + type=int, + metavar="N", + default=None, + help="Figure numbers to generate, e.g. --figures 1 5 10. Generates all if omitted.", # noqa: E501 + ) + return p.parse_args() + + +# =========================================================================== +# MAIN +# =========================================================================== +def main() -> None: + """Load datasets once, then generate the requested figures and summary.""" + args = parse_args() + warnings.filterwarnings("ignore", category=UserWarning) + paths = AnalysisPaths( + output_dir=args.data_dir + if args.data_dir is not None + else DEFAULT_PATHS.output_dir, + figs_dir=args.output_dir + if args.output_dir is not None + else DEFAULT_PATHS.figs_dir, + config_path=DEFAULT_PATHS.config_path, + ) + + paths.figs_dir.mkdir(parents=True, exist_ok=True) + + # Apply DPI setting + import matplotlib as mpl # noqa: PLC0415 — local import fine here + + mpl.rcParams["savefig.dpi"] = args.dpi + + want = set(args.figures) if args.figures else None + + def _want(n: int) -> bool: + return want is None or n in want + + print("Loading data…") + # Keep shared datasets in memory once so each figure function can focus on + # presentation instead of repeating the same I/O and alignment work. + gc_baselines = load_gc_baselines(paths) + df = load_all_data(paths) + gc_full = load_gc_full(paths) + print( + f" Loaded {len(df):,} voyage records across " + f"{df['experiment'].nunique()} experiments and {df['case_id'].nunique()} cases." + ) + + print("\nGenerating figures…") + if _want(1): + fig_energy_overview(df, gc_baselines, gc_full, paths) + if _want(2): + fig_optimization_gains(df, gc_baselines, paths) + if _want(3): + fig_penalty_tradeoff(df, paths) + if _want(4): + fig_seasonality_a(df, gc_full, paths) + fig_seasonality_b(df, gc_full, paths) + if _want(5): + fig_wps_impact(df, paths) + if _want(6): + fig_fms_improvement(df, paths) + if _want(7): + fig_route_maps(paths) + if _want(8): + fig_risk_calendar(df, paths) + if _want(9): + fig_fms_delta_byseason(df, paths) + if _want(10): + fig_gc_victory_rate(df, gc_full, paths) + if _want(11): + fig_gc_margin_heatmap(df, gc_full, paths) + if _want(12): + fig_gc_violations(df, gc_full, paths) + if _want(13): + fig_relative_gain_a(df, gc_full, paths) + fig_relative_gain_b(df, gc_full, paths) + + print("\nGenerating summary table…") + summary = generate_summary_table(df, gc_baselines, paths) + print(summary.to_string(index=False)) + + print(f"\nAll outputs saved to {paths.figs_dir}/") + + +if __name__ == "__main__": + main() diff --git a/scripts/swopp3_apply_fms.py b/scripts/swopp3_apply_fms.py new file mode 100755 index 00000000..fd3c5ab1 --- /dev/null +++ b/scripts/swopp3_apply_fms.py @@ -0,0 +1,753 @@ +#!/usr/bin/env python +"""Apply FMS refinement to existing SWOPP3 route outputs. + +This script reads a folder produced by ``scripts/swopp3_run.py`` and writes a +new folder with suffix ``_fms`` by default. Great-circle cases are copied as-is. +Optimised cases are refined route-by-route with FMS. + +Usage +----- +Refine the default SWOPP3 output folder and write ``output/swopp3_fms``:: + + uv run scripts/swopp3_apply_fms.py output/swopp3 + +Use explicit ERA5 paths for both corridors:: + + uv run scripts/swopp3_apply_fms.py output/swopp3 \ + --wind-path-atlantic data/era5/era5_wind_atlantic_2024.nc \ + --wave-path-atlantic data/era5/era5_waves_atlantic_2024.nc \ + --wind-path-pacific data/era5/era5_wind_pacific_2024.nc \ + --wave-path-pacific data/era5/era5_waves_pacific_2024.nc +""" + +from __future__ import annotations + +import csv +import gc +import re +import shutil +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import jax +import jax.numpy as jnp +import typer + +from routetools.cost import cost_function_rise_penalized +from routetools.fms import optimize_fms +from routetools.swopp3 import SWOPP3_CASES +from routetools.swopp3_output import ( + file_a_row, + resolve_file_b_path, + sailed_distance_nm, + waypoint_times, + write_file_a, + write_file_b, +) +from routetools.swopp3_runner import evaluate_energy +from routetools.weather import DEFAULT_HS_LIMIT, DEFAULT_TWS_LIMIT + +if TYPE_CHECKING: + from routetools.swopp3_runner import FieldClosure + + +app = typer.Typer(help="Apply FMS refinement to an existing SWOPP3 output folder.") + +_TEAM_FILE_RE = re.compile(r"^IEUniversity-(?P\d+)-(?P.+)\.csv$") +_ERA5_FILE_RE = re.compile( + r"^(?Pera5_[^_]+_[^_]+_)(?P\d{4})(?:_(?P\d{2}(?:-\d{2})?))?\.nc$" +) +_DTFMT = "%Y-%m-%d %H:%M:%S" +_DEFAULT_ERA5_BATCH_DAYS = 183.0 +_DEFAULT_ERA5_RELOAD_MARGIN_DAYS = 20.0 + +# Default penalty weights forwarded to cost_function_rise_penalized. +_DEFAULT_WIND_PENALTY_WEIGHT = 1000 +_DEFAULT_WAVE_PENALTY_WEIGHT = 1000 + + +@dataclass(frozen=True) +class CaseFile: + """Summary CSV metadata discovered in an input folder.""" + + case_id: str + submission: int + summary_path: Path + + +@dataclass(frozen=True) +class CorridorResources: + """Loaded weather, vectorfield, and land resources for one corridor.""" + + vectorfield: FieldClosure + windfield: FieldClosure + wavefield: FieldClosure + land: Any + dataset_epoch: datetime + + +def _count_curve_land_violations(curve: jnp.ndarray, land: Any) -> int: + """Return the number of land-invalid positions for one route.""" + curve_batch = curve if curve.ndim == 3 else curve[None, ...] + return int(jnp.sum(land(curve_batch) > 0)) + + +def _default_output_dir(input_dir: Path) -> Path: + """Return the default FMS output directory for an input folder.""" + return input_dir.with_name(f"{input_dir.name}_fms") + + +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")) + return [path, *continuation_paths] + + +def _discover_case_files(input_dir: Path) -> list[CaseFile]: + """Return all valid SWOPP3 summary CSVs in an input directory.""" + case_files: list[CaseFile] = [] + for path in sorted(input_dir.glob("IEUniversity-*.csv")): + if not path.is_file(): + continue + match = _TEAM_FILE_RE.match(path.name) + if match is None: + continue + case_id = match.group("case_id") + if case_id not in SWOPP3_CASES: + continue + case_files.append( + CaseFile( + case_id=case_id, + submission=int(match.group("submission")), + summary_path=path, + ) + ) + return case_files + + +def _build_corridor_path_maps( + *, + wind_path: Path | None, + wave_path: Path | None, + wind_path_atlantic: Path | None, + wave_path_atlantic: Path | None, + wind_path_pacific: Path | None, + wave_path_pacific: Path | None, +) -> tuple[dict[str, Path], dict[str, Path]]: + """Build per-corridor field path maps.""" + corridor_wind: dict[str, Path] = {} + corridor_wave: dict[str, Path] = {} + + if wind_path_atlantic is not None: + corridor_wind["atlantic"] = wind_path_atlantic + if wave_path_atlantic is not None: + corridor_wave["atlantic"] = wave_path_atlantic + if wind_path_pacific is not None: + corridor_wind["pacific"] = wind_path_pacific + if wave_path_pacific is not None: + corridor_wave["pacific"] = wave_path_pacific + + if wind_path is not None: + corridor_wind["atlantic"] = wind_path + corridor_wind["pacific"] = wind_path + if wave_path is not None: + corridor_wave["atlantic"] = wave_path + corridor_wave["pacific"] = wave_path + + return corridor_wind, corridor_wave + + +def _validate_required_data_paths( + case_ids: list[str], + corridor_wind: dict[str, Path], + corridor_wave: dict[str, Path], +) -> None: + """Fail fast when the required ERA5 inputs are missing.""" + required_corridors = sorted( + {str(SWOPP3_CASES[case_id]["route"]) for case_id in case_ids} + ) + missing: list[str] = [] + + for corridor in required_corridors: + wind = corridor_wind.get(corridor) + wave = corridor_wave.get(corridor) + + if wind is None: + missing.append(f"{corridor} wind dataset path is not configured") + elif not Path(wind).exists(): + missing.append(f"{corridor} wind dataset not found: {wind}") + + if wave is None: + missing.append(f"{corridor} wave dataset path is not configured") + elif not Path(wave).exists(): + missing.append(f"{corridor} wave dataset not found: {wave}") + + if not missing: + return + + corridor_list = ", ".join(required_corridors) + missing_lines = "\n".join(f"- {item}" for item in missing) + raise FileNotFoundError( + "SWOPP3 FMS input validation failed.\n\n" + f"Optimised cases require ERA5 datasets for corridor(s): {corridor_list}.\n" + "The FMS post-processing step uses wind data for the vectorfield and\n" + "uses wind and wave data to evaluate each original and refined route.\n\n" + f"Missing inputs:\n{missing_lines}\n\n" + "Fix:\n" + "- Run `uv run scripts/download_era5.py` to download the default " + "2024 datasets.\n" + "- Or pass matching `--wind-path*` and `--wave-path*` options." + ) + + +def _load_corridor_resources_for_cases( + case_ids: list[str], + corridor_wind: dict[str, Path], + corridor_wave: dict[str, Path], + *, + time_start: datetime | None = None, + time_end: datetime | None = None, + quiet: bool, +) -> dict[str, CorridorResources]: + """Load weather, vectorfield, and land resources needed by optimised cases.""" + if not case_ids: + return {} + + import xarray as xr + + from routetools.era5.loader import ( + load_dataset_epoch, + load_era5_wavefield, + load_era5_windfield, + load_natural_earth_land_mask, + ) + + resources: dict[str, CorridorResources] = {} + corridors = sorted({str(SWOPP3_CASES[case_id]["route"]) for case_id in case_ids}) + + for corridor in corridors: + wind_path = corridor_wind[corridor] + wave_path = corridor_wave[corridor] + + wind_paths = _loadable_era5_paths(wind_path) + wave_paths = _loadable_era5_paths(wave_path) + wind_target = wind_paths if len(wind_paths) > 1 else wind_paths[0] + wave_target = wave_paths if len(wave_paths) > 1 else wave_paths[0] + + if not quiet: + typer.echo( + f"Loading corridor {corridor}: wind from " + f"{', '.join(str(path) for path in wind_paths)}" + ) + typer.echo( + f"Loading corridor {corridor}: waves from " + f"{', '.join(str(path) for path in wave_paths)}" + ) + + dataset_epoch = load_dataset_epoch( + wind_target, + time_start=time_start, + time_end=time_end, + ) + windfield = load_era5_windfield( + wind_target, + time_start=time_start, + time_end=time_end, + ) + vectorfield = windfield + wavefield = load_era5_wavefield( + wave_target, + time_start=time_start, + time_end=time_end, + ) + + with xr.open_dataset(wave_paths[0]) as ds: + for lon_name in ("longitude", "lon"): + if lon_name in ds.coords: + lons = ds[lon_name].values + break + else: + raise KeyError(f"No longitude coordinate found in {wave_paths[0]}") + + for lat_name in ("latitude", "lat"): + if lat_name in ds.coords: + lats = ds[lat_name].values + break + else: + raise KeyError(f"No latitude coordinate found in {wave_paths[0]}") + + land = load_natural_earth_land_mask( + (float(lons.min()), float(lons.max())), + (float(lats.min()), float(lats.max())), + ) + + resources[corridor] = CorridorResources( + vectorfield=vectorfield, + windfield=windfield, + wavefield=wavefield, + land=land, + dataset_epoch=dataset_epoch, + ) + + return resources + + +def _read_file_a_rows(summary_path: Path) -> list[dict[str, str]]: + """Read one SWOPP3 summary CSV as raw rows.""" + with summary_path.open(newline="") as handle: + reader = csv.DictReader(handle) + return list(reader) + + +def _read_track_curve(track_path: Path) -> jnp.ndarray: + """Read a SWOPP3 track CSV as a ``(L, 2)`` ``(lon, lat)`` array.""" + lons: list[float] = [] + lats: list[float] = [] + with track_path.open(newline="") as handle: + reader = csv.DictReader(handle) + for row in reader: + lats.append(float(row["lat_deg"])) + lons.append(float(row["lon_deg"])) + return jnp.stack( + [jnp.asarray(lons, dtype=jnp.float32), jnp.asarray(lats, dtype=jnp.float32)], + axis=1, + ) + + +def _departure_offset_hours(departure: datetime, dataset_epoch: datetime) -> float: + """Return departure offset in hours relative to the dataset epoch.""" + departure_naive = departure.replace(tzinfo=None) if departure.tzinfo else departure + epoch_naive = ( + dataset_epoch.replace(tzinfo=None) + if hasattr(dataset_epoch, "tzinfo") and dataset_epoch.tzinfo + else dataset_epoch + ) + return (departure_naive - epoch_naive).total_seconds() / 3600.0 + + +def _copy_case_outputs(case_file: CaseFile, input_dir: Path, output_dir: Path) -> None: + """Copy one case's summary CSV and all referenced tracks unchanged.""" + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "tracks").mkdir(parents=True, exist_ok=True) + shutil.copy2(case_file.summary_path, output_dir / case_file.summary_path.name) + + for row in _read_file_a_rows(case_file.summary_path): + filename = row["details_filename"] + src = resolve_file_b_path(input_dir, filename) + dst = output_dir / "tracks" / filename + shutil.copy2(src, dst) + + +def _case_output_complete(case_file: CaseFile, output_dir: Path) -> bool: + """Return whether a case summary and all referenced tracks already exist.""" + summary_path = output_dir / case_file.summary_path.name + if not summary_path.exists(): + return False + + try: + rows = _read_file_a_rows(summary_path) + except (OSError, csv.Error, KeyError): + return False + + if not rows: + return False + + return all( + (output_dir / "tracks" / row["details_filename"]).exists() for row in rows + ) + + +def _release_fms_state() -> None: + """Release cached JAX/FMS state between optimised cases.""" + if hasattr(jax, "clear_caches"): + jax.clear_caches() + gc.collect() + + +def _batch_window_parameters( + passage_hours: float, + era5_batch_days: float, + era5_reload_margin_days: float, +) -> tuple[timedelta, timedelta]: + """Return batch duration and reload margin for rolling ERA5 windows.""" + if era5_batch_days <= 0: + raise ValueError("era5_batch_days must be positive") + if era5_reload_margin_days <= 0: + raise ValueError("era5_reload_margin_days must be positive") + + margin_hours = max(passage_hours, era5_reload_margin_days * 24.0) + batch_hours = max(margin_hours, era5_batch_days * 24.0) + return timedelta(hours=batch_hours), timedelta(hours=margin_hours) + + +def apply_fms_to_outputs( + input_dir: Path, + *, + output_dir: Path | None = None, + wind_path: Path | None = None, + wave_path: Path | None = None, + wind_path_atlantic: Path | None = Path("data/era5/era5_wind_atlantic_2024.nc"), + wave_path_atlantic: Path | None = Path("data/era5/era5_waves_atlantic_2024.nc"), + wind_path_pacific: Path | None = Path("data/era5/era5_wind_pacific_2024.nc"), + wave_path_pacific: Path | None = Path("data/era5/era5_waves_pacific_2024.nc"), + fms_patience: int = 200, + fms_damping: float = 0.95, + fms_maxfevals: int = 10000, + era5_batch_days: float = _DEFAULT_ERA5_BATCH_DAYS, + era5_reload_margin_days: float = _DEFAULT_ERA5_RELOAD_MARGIN_DAYS, + tws_limit: float = DEFAULT_TWS_LIMIT, + hs_limit: float = DEFAULT_HS_LIMIT, + wind_penalty_weight: float = _DEFAULT_WIND_PENALTY_WEIGHT, + wave_penalty_weight: float = _DEFAULT_WAVE_PENALTY_WEIGHT, + enforce_weather_limits: bool = False, + quiet: bool = False, +) -> Path: + """Apply FMS to an existing SWOPP3 output folder and write a sibling folder.""" + input_dir = Path(input_dir) + if not input_dir.exists(): + raise FileNotFoundError(f"Input directory not found: {input_dir}") + if not input_dir.is_dir(): + raise NotADirectoryError(f"Input path is not a directory: {input_dir}") + + resolved_output_dir = ( + _default_output_dir(input_dir) if output_dir is None else Path(output_dir) + ) + if resolved_output_dir == input_dir: + raise ValueError("output_dir must be different from input_dir") + + case_files = _discover_case_files(input_dir) + if not case_files: + raise FileNotFoundError( + f"No SWOPP3 summary CSV files found in input directory: {input_dir}" + ) + + optimised_case_ids = [ + case_file.case_id + for case_file in case_files + if SWOPP3_CASES[case_file.case_id]["strategy"] == "optimised" + ] + + corridor_wind, corridor_wave = _build_corridor_path_maps( + wind_path=wind_path, + wave_path=wave_path, + wind_path_atlantic=wind_path_atlantic, + wave_path_atlantic=wave_path_atlantic, + wind_path_pacific=wind_path_pacific, + wave_path_pacific=wave_path_pacific, + ) + + if optimised_case_ids: + _validate_required_data_paths(optimised_case_ids, corridor_wind, corridor_wave) + + resolved_output_dir.mkdir(parents=True, exist_ok=True) + (resolved_output_dir / "tracks").mkdir(parents=True, exist_ok=True) + + for case_file in case_files: + if _case_output_complete(case_file, resolved_output_dir): + if not quiet: + typer.echo(f"Skipping completed case {case_file.case_id}") + continue + + case = SWOPP3_CASES[case_file.case_id] + if case["strategy"] == "gc": + if not quiet: + typer.echo(f"Copying GC case {case_file.case_id} unchanged") + _copy_case_outputs(case_file, input_dir, resolved_output_dir) + continue + + corridor = str(case["route"]) + passage_hours = float(case["passage_hours"]) + batch_duration, reload_margin = _batch_window_parameters( + passage_hours, + era5_batch_days, + era5_reload_margin_days, + ) + output_rows: list[dict[str, str]] = [] + resources: CorridorResources | None = None + reload_after: datetime | None = None + + try: + rows = _read_file_a_rows(case_file.summary_path) + for idx, row in enumerate(rows, start=1): + departure = datetime.strptime(row["departure_time_utc"], _DTFMT) + if ( + resources is None + or reload_after is None + or departure >= reload_after + ): + if resources is not None: + del resources + _release_fms_state() + + batch_start = departure + batch_end = batch_start + batch_duration + reload_after = batch_end - reload_margin + resources = _load_corridor_resources_for_cases( + [case_file.case_id], + corridor_wind, + corridor_wave, + time_start=batch_start, + time_end=batch_end, + quiet=quiet, + )[corridor] + if not quiet: + typer.echo( + f"Loaded {corridor} ERA5 batch for {case_file.case_id}: " + f"{batch_start.strftime('%Y-%m-%d')} to " + f"{batch_end.strftime('%Y-%m-%d')}" + ) + + if resources is None: + raise RuntimeError( + f"Failed to load corridor resources for {case_file.case_id}" + ) + + details_filename = row["details_filename"] + track_path = resolve_file_b_path(input_dir, details_filename) + curve_original = _read_track_curve(track_path) + departure_offset_h = _departure_offset_hours( + departure, + resources.dataset_epoch, + ) + + curve_fms_batch, _ = optimize_fms( + vectorfield=resources.vectorfield, + curve=curve_original, + land=resources.land, + windfield=resources.windfield, + wavefield=resources.wavefield, + penalty=1.0, + travel_time=passage_hours, + patience=fms_patience, + damping=fms_damping, + maxfevals=fms_maxfevals, + spherical_correction=True, + costfun=cost_function_rise_penalized, + costfun_kwargs={ + "windfield": resources.windfield, + "wavefield": resources.wavefield, + "wps": bool(case["wps"]), + "wave_penalty_weight": wave_penalty_weight, + "wind_penalty_weight": wind_penalty_weight, + "tws_limit": tws_limit, + "hs_limit": hs_limit, + }, + verbose=not quiet, + time_offset=departure_offset_h, + enforce_weather_limits=enforce_weather_limits, + tws_limit=tws_limit, + hs_limit=hs_limit, + ) + curve_fms = curve_fms_batch[0] + + original_land_violations = _count_curve_land_violations( + curve_original, + resources.land, + ) + fms_land_violations = _count_curve_land_violations( + curve_fms, + resources.land, + ) + if fms_land_violations > original_land_violations: + curve_fms = curve_original + fms_land_violations = original_land_violations + + original_energy, original_max_tws, original_max_hs = evaluate_energy( + curve_original, + departure, + passage_hours, + wps=bool(case["wps"]), + windfield=resources.windfield, + wavefield=resources.wavefield, + departure_offset_h=departure_offset_h, + ) + fms_energy, fms_max_tws, fms_max_hs = evaluate_energy( + curve_fms, + departure, + passage_hours, + wps=bool(case["wps"]), + windfield=resources.windfield, + wavefield=resources.wavefield, + departure_offset_h=departure_offset_h, + ) + + distance_nm = sailed_distance_nm(curve_fms) + write_file_b( + curve_fms, + waypoint_times(curve_fms, departure, passage_hours), + resolved_output_dir / "tracks" / details_filename, + ) + output_rows.append( + file_a_row( + departure=departure, + passage_hours=passage_hours, + energy_mwh=fms_energy, + max_wind_mps=fms_max_tws, + max_hs_m=fms_max_hs, + distance_nm=distance_nm, + details_filename=details_filename, + ) + ) + + if not quiet: + typer.echo( + f"[{case_file.case_id}] {idx}/{len(rows)} " + f"{departure.strftime('%Y-%m-%d')} " + f"original={original_energy:.3f} MWh " + f"fms={fms_energy:.3f} MWh " + f"land={original_land_violations}->{fms_land_violations}" + ) + + write_file_a(output_rows, resolved_output_dir / case_file.summary_path.name) + finally: + if resources is not None: + del resources + _release_fms_state() + + if not quiet: + typer.echo(f"Wrote FMS-refined output folder to {resolved_output_dir}") + return resolved_output_dir + + +@app.command() +def main( + input_dir: Path = typer.Argument( # noqa: B008 + ..., help="Folder produced by swopp3_run.py." + ), + output_dir: Path | None = typer.Option( # noqa: B008 + None, + "--output-dir", + "-o", + help="Output directory. Default: _fms.", + ), + wind_path: Path | None = typer.Option( # noqa: B008 + None, + "--wind-path", + help="Path to ERA5 wind NetCDF used for all selected corridors.", + ), + wave_path: Path | None = typer.Option( # noqa: B008 + None, + "--wave-path", + help="Path to ERA5 wave NetCDF used for all selected corridors.", + ), + wind_path_atlantic: Path | None = typer.Option( # noqa: B008 + Path("data/era5/era5_wind_atlantic_2024.nc"), + "--wind-path-atlantic", + help="Path to ERA5 wind NetCDF for Atlantic routes.", + ), + wave_path_atlantic: Path | None = typer.Option( # noqa: B008 + Path("data/era5/era5_waves_atlantic_2024.nc"), + "--wave-path-atlantic", + help="Path to ERA5 wave NetCDF for Atlantic routes.", + ), + wind_path_pacific: Path | None = typer.Option( # noqa: B008 + Path("data/era5/era5_wind_pacific_2024.nc"), + "--wind-path-pacific", + help="Path to ERA5 wind NetCDF for Pacific routes.", + ), + wave_path_pacific: Path | None = typer.Option( # noqa: B008 + Path("data/era5/era5_waves_pacific_2024.nc"), + "--wave-path-pacific", + help="Path to ERA5 wave NetCDF for Pacific routes.", + ), + fms_patience: int = typer.Option( # noqa: B008 + 200, + "--fms-patience", + help="Early-stopping patience for FMS.", + ), + fms_damping: float = typer.Option( # noqa: B008 + 0.95, + "--fms-damping", + help="FMS damping factor.", + ), + fms_maxfevals: int = typer.Option( # noqa: B008 + 10000, + "--fms-maxfevals", + help="Maximum FMS iterations per route.", + ), + era5_batch_days: float = typer.Option( # noqa: B008 + _DEFAULT_ERA5_BATCH_DAYS, + "--era5-batch-days", + help="Maximum number of days of ERA5 data to keep loaded at once.", + ), + era5_reload_margin_days: float = typer.Option( # noqa: B008 + _DEFAULT_ERA5_RELOAD_MARGIN_DAYS, + "--era5-reload-margin-days", + help=( + "Reload ERA5 data when a departure is this close to the current batch end." + ), + ), + tws_limit: float = typer.Option( # noqa: B008 + DEFAULT_TWS_LIMIT, + "--tws-limit", + help="Maximum true wind speed allowed during FMS refinement.", + ), + hs_limit: float = typer.Option( # noqa: B008 + DEFAULT_HS_LIMIT, + "--hs-limit", + help="Maximum significant wave height allowed during FMS refinement.", + ), + wind_penalty_weight: float = typer.Option( # noqa: B008 + _DEFAULT_WIND_PENALTY_WEIGHT, + "--wind-penalty-weight", + help="Penalty weight for wind violations in the RISE cost function.", + ), + wave_penalty_weight: float = typer.Option( # noqa: B008 + _DEFAULT_WAVE_PENALTY_WEIGHT, + "--wave-penalty-weight", + help="Penalty weight for wave violations in the RISE cost function.", + ), + enforce_weather_limits: bool = typer.Option( # noqa: B008 + False, + "--enforce-weather-limits/--no-enforce-weather-limits", + help=( + "Reject FMS updates that newly violate the configured weather limits. " + "Already-violating routes may keep moving so FMS can escape an " + "initially infeasible route." + ), + ), + quiet: bool = typer.Option( # noqa: B008 + False, + "--quiet", + "-q", + help="Suppress progress output.", + ), +) -> None: + """Apply FMS to every non-GC route in an existing SWOPP3 output folder.""" + apply_fms_to_outputs( + input_dir, + output_dir=output_dir, + wind_path=wind_path, + wave_path=wave_path, + wind_path_atlantic=wind_path_atlantic, + wave_path_atlantic=wave_path_atlantic, + wind_path_pacific=wind_path_pacific, + wave_path_pacific=wave_path_pacific, + fms_patience=fms_patience, + fms_damping=fms_damping, + fms_maxfevals=fms_maxfevals, + era5_batch_days=era5_batch_days, + era5_reload_margin_days=era5_reload_margin_days, + tws_limit=tws_limit, + hs_limit=hs_limit, + wind_penalty_weight=wind_penalty_weight, + wave_penalty_weight=wave_penalty_weight, + enforce_weather_limits=enforce_weather_limits, + quiet=quiet, + ) + + +if __name__ == "__main__": + app() diff --git a/scripts/swopp3_plot_routes.py b/scripts/swopp3_plot_routes.py new file mode 100755 index 00000000..671fa42e --- /dev/null +++ b/scripts/swopp3_plot_routes.py @@ -0,0 +1,611 @@ +#!/usr/bin/env python +"""Visualize SWOPP3 routes from output CSV files. + +Generates publication-quality maps comparing Great-Circle vs Optimised +routes for each corridor (Atlantic, Pacific) and WPS configuration. + +Usage +----- + python scripts/swopp3_plot_routes.py --input-dir output/swopp3_rise + +Outputs PNG figures into ``/figures/``. +""" + +from __future__ import annotations + +from pathlib import Path + +import cartopy.crs as ccrs +import cartopy.feature as cfeature +import matplotlib.pyplot as plt +import pandas as pd +import typer + +from routetools.swopp3_output import read_file_a_dataframe, read_file_b_dataframe + +app = typer.Typer(help="Visualize SWOPP3 route outputs.") + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _load_summary( + input_dir: Path, + case_id: str, + submission: int | None = None, +) -> pd.DataFrame: + """Load a case summary table from File A CSV.""" + return read_file_a_dataframe(input_dir, case_id, submission=submission) + + +def _load_track(input_dir: Path, filename: str) -> pd.DataFrame: + """Load a single track (waypoints) CSV.""" + return read_file_b_dataframe(input_dir, filename) + + +# --------------------------------------------------------------------------- +# Figure 1: All routes for one corridor (GC + Optimised, spaghetti) +# --------------------------------------------------------------------------- + + +def plot_corridor_spaghetti( + input_dir: Path, + corridor: str, + wps: bool, + fig_dir: Path, + submission: int | None = None, + n_departures: int = 366, + sample_step: int = 1, +) -> None: + """Plot all departure routes for one corridor overlaid on a map. + + GC routes in blue, optimised routes in red, with low alpha for the + spaghetti and a thicker line for the mean/median representative route. + """ + wps_label = "WPS" if wps else "noWPS" + gc_case = f"{'A' if corridor == 'atlantic' else 'P'}GC_{wps_label}" + opt_case = f"{'A' if corridor == 'atlantic' else 'P'}O_{wps_label}" + + gc_summary = _load_summary(input_dir, gc_case, submission=submission) + opt_summary = _load_summary(input_dir, opt_case, submission=submission) + + # Use central_longitude to avoid antimeridian wrapping issues + if corridor == "pacific": + proj = ccrs.PlateCarree(central_longitude=180) + transform = ccrs.PlateCarree() + extent = [120, 260, 20, 60] # [lon_min, lon_max, lat_min, lat_max] + else: + proj = ccrs.PlateCarree() + transform = ccrs.PlateCarree() + extent = [-80, 5, 30, 60] + + fig, ax = plt.subplots(figsize=(14, 7), subplot_kw={"projection": proj}) + ax.set_extent(extent, crs=ccrs.PlateCarree()) + ax.add_feature(cfeature.LAND, facecolor="#e8e8e8", edgecolor="none") + ax.add_feature(cfeature.COASTLINE, linewidth=0.5, color="#888888") + ax.add_feature(cfeature.BORDERS, linewidth=0.3, color="#cccccc") + ax.gridlines(draw_labels=True, linewidth=0.3, alpha=0.5) + + # --- Plot GC routes (all identical, just plot one thick) --- + gc_track = _load_track(input_dir, gc_summary.iloc[0]["details_filename"]) + lons_gc = gc_track["lon_deg"].values + lats_gc = gc_track["lat_deg"].values + ax.plot( + lons_gc, + lats_gc, + color="#2166ac", + linewidth=2.5, + alpha=0.9, + transform=transform, + label="Great Circle", + zorder=5, + ) + + # --- Plot optimised routes --- + indices = range(0, min(n_departures, len(opt_summary)), sample_step) + for i in indices: + row = opt_summary.iloc[i] + track = _load_track(input_dir, row["details_filename"]) + lons = track["lon_deg"].values + lats = track["lat_deg"].values + ax.plot( + lons, + lats, + color="#b2182b", + linewidth=0.4, + alpha=0.15, + transform=transform, + zorder=3, + ) + + # Plot the median-energy optimised route thicker + median_idx = ( + opt_summary["energy_cons_mwh"] + .sub(opt_summary["energy_cons_mwh"].median()) + .abs() + .idxmin() + ) + med_row = opt_summary.loc[median_idx] + med_track = _load_track(input_dir, med_row["details_filename"]) + ax.plot( + med_track["lon_deg"].values, + med_track["lat_deg"].values, + color="#b2182b", + linewidth=2.5, + alpha=0.9, + transform=transform, + label="Optimised (median)", + zorder=6, + ) + + # Endpoints + ax.plot( + lons_gc[0], + lats_gc[0], + "o", + color="#1a9850", + markersize=8, + transform=transform, + zorder=10, + ) + ax.plot( + lons_gc[-1], + lats_gc[-1], + "s", + color="#d73027", + markersize=8, + transform=transform, + zorder=10, + ) + + # Energy stats + gc_energy = gc_summary["energy_cons_mwh"].mean() + opt_energy = opt_summary["energy_cons_mwh"].mean() + savings_pct = (1 - opt_energy / gc_energy) * 100 + + ax.set_title( + f"{corridor.title()} Corridor — {wps_label}\n" + f"GC: {gc_energy:.1f} MWh | Optimised: {opt_energy:.1f} MWh " + f"| Savings: {savings_pct:.1f}%", + fontsize=13, + fontweight="bold", + ) + ax.legend(loc="lower left", fontsize=10) + + out = fig_dir / f"routes_{corridor}_{wps_label}.png" + fig.savefig(out, dpi=200, bbox_inches="tight", facecolor="white") + plt.close(fig) + print(f" Saved {out}") + + +# --------------------------------------------------------------------------- +# Figure 2: Energy distribution comparison (violin / box) +# --------------------------------------------------------------------------- + + +def plot_energy_comparison( + input_dir: Path, + fig_dir: Path, + submission: int | None = None, +) -> None: + """Box plots comparing energy across all 8 cases.""" + cases_order = [ + "AGC_noWPS", + "AO_noWPS", + "AGC_WPS", + "AO_WPS", + "PGC_noWPS", + "PO_noWPS", + "PGC_WPS", + "PO_WPS", + ] + labels = [ + "A-GC\nno WPS", + "A-Opt\nno WPS", + "A-GC\nWPS", + "A-Opt\nWPS", + "P-GC\nno WPS", + "P-Opt\nno WPS", + "P-GC\nWPS", + "P-Opt\nWPS", + ] + colors = [ + "#2166ac", + "#b2182b", + "#2166ac", + "#b2182b", + "#2166ac", + "#b2182b", + "#2166ac", + "#b2182b", + ] + + data = [] + for cid in cases_order: + df = _load_summary(input_dir, cid, submission=submission) + data.append(df["energy_cons_mwh"].values) + + fig, ax = plt.subplots(figsize=(12, 6)) + bp = ax.boxplot( + data, + tick_labels=labels, + patch_artist=True, + widths=0.6, + showfliers=True, + flierprops=dict(marker=".", markersize=3, alpha=0.4), + ) + for patch, color in zip(bp["boxes"], colors, strict=False): + patch.set_facecolor(color) + patch.set_alpha(0.6) + + # Add mean markers + means = [d.mean() for d in data] + ax.scatter( + range(1, len(means) + 1), + means, + color="black", + marker="D", + s=40, + zorder=5, + label="Mean", + ) + + ax.set_ylabel("Energy Consumption (MWh)", fontsize=12) + ax.set_title( + "SWOPP3 Energy Consumption — All Cases (366 departures each)", + fontsize=13, + fontweight="bold", + ) + + # Add vertical separator between Atlantic and Pacific + ax.axvline(4.5, color="gray", linestyle="--", linewidth=0.8, alpha=0.5) + ax.text( + 2.5, + ax.get_ylim()[1] * 0.97, + "Atlantic", + ha="center", + fontsize=11, + fontstyle="italic", + alpha=0.7, + ) + ax.text( + 6.5, + ax.get_ylim()[1] * 0.97, + "Pacific", + ha="center", + fontsize=11, + fontstyle="italic", + alpha=0.7, + ) + + ax.legend(loc="upper right", fontsize=10) + ax.grid(axis="y", alpha=0.3) + + out = fig_dir / "energy_comparison_boxplot.png" + fig.savefig(out, dpi=200, bbox_inches="tight", facecolor="white") + plt.close(fig) + print(f" Saved {out}") + + +# --------------------------------------------------------------------------- +# Figure 3: Energy time series (daily departures) +# --------------------------------------------------------------------------- + + +def plot_energy_timeseries( + input_dir: Path, + fig_dir: Path, + submission: int | None = None, +) -> None: + """Plot energy consumption over time for each corridor.""" + for corridor in ["atlantic", "pacific"]: + prefix = "A" if corridor == "atlantic" else "P" + + fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True) + for i, wps_label in enumerate(["noWPS", "WPS"]): + ax = axes[i] + gc_case = f"{prefix}GC_{wps_label}" + opt_case = f"{prefix}O_{wps_label}" + + gc_df = _load_summary(input_dir, gc_case, submission=submission) + opt_df = _load_summary(input_dir, opt_case, submission=submission) + + dates = gc_df["departure_time_utc"] + ax.fill_between( + dates, + gc_df["energy_cons_mwh"], + opt_df["energy_cons_mwh"], + alpha=0.2, + color="#2ca02c", + label="Savings", + ) + ax.plot( + dates, + gc_df["energy_cons_mwh"], + color="#2166ac", + linewidth=1, + alpha=0.8, + label=f"GC ({gc_df['energy_cons_mwh'].mean():.0f} MWh avg)", + ) + ax.plot( + dates, + opt_df["energy_cons_mwh"], + color="#b2182b", + linewidth=1, + alpha=0.8, + label=f"Opt ({opt_df['energy_cons_mwh'].mean():.0f} MWh avg)", + ) + + savings = ( + 1 - opt_df["energy_cons_mwh"].mean() / gc_df["energy_cons_mwh"].mean() + ) * 100 + ax.set_ylabel("Energy (MWh)", fontsize=11) + ax.set_title( + f"{wps_label} — Savings: {savings:.1f}%", fontsize=11, fontweight="bold" + ) + ax.legend(loc="upper right", fontsize=9) + ax.grid(alpha=0.3) + + fig.suptitle( + f"{corridor.title()} Corridor — Energy Consumption over 2024", + fontsize=13, + fontweight="bold", + ) + axes[-1].set_xlabel("Departure Date", fontsize=11) + fig.autofmt_xdate(rotation=30) + + out = fig_dir / f"energy_timeseries_{corridor}.png" + fig.savefig(out, dpi=200, bbox_inches="tight", facecolor="white") + plt.close(fig) + print(f" Saved {out}") + + +# --------------------------------------------------------------------------- +# Figure 4: Seasonal sample routes (best, worst, median) +# --------------------------------------------------------------------------- + + +def plot_seasonal_routes( + input_dir: Path, + fig_dir: Path, + submission: int | None = None, +) -> None: + """Plot representative routes for different seasons on a single map.""" + for corridor in ["atlantic", "pacific"]: + prefix = "A" if corridor == "atlantic" else "P" + + if corridor == "pacific": + proj = ccrs.PlateCarree(central_longitude=180) + transform = ccrs.PlateCarree() + extent = [120, 260, 20, 60] + else: + proj = ccrs.PlateCarree() + transform = ccrs.PlateCarree() + extent = [-80, 5, 30, 60] + + for wps_label in ["WPS", "noWPS"]: + opt_case = f"{prefix}O_{wps_label}" + gc_case = f"{prefix}GC_{wps_label}" + + opt_df = _load_summary(input_dir, opt_case, submission=submission) + gc_df = _load_summary(input_dir, gc_case, submission=submission) + + # Pick best, worst, and median departures + best_idx = opt_df["energy_cons_mwh"].idxmin() + worst_idx = opt_df["energy_cons_mwh"].idxmax() + median_idx = ( + opt_df["energy_cons_mwh"] + .sub(opt_df["energy_cons_mwh"].median()) + .abs() + .idxmin() + ) + + picks = { + "Best": (best_idx, "#1a9850"), + "Median": (median_idx, "#f46d43"), + "Worst": (worst_idx, "#d73027"), + } + + fig, ax = plt.subplots(figsize=(14, 7), subplot_kw={"projection": proj}) + ax.set_extent(extent, crs=ccrs.PlateCarree()) + ax.add_feature(cfeature.LAND, facecolor="#e8e8e8", edgecolor="none") + ax.add_feature(cfeature.COASTLINE, linewidth=0.5, color="#888888") + ax.gridlines(draw_labels=True, linewidth=0.3, alpha=0.5) + + # GC baseline + gc_track = _load_track(input_dir, gc_df.iloc[0]["details_filename"]) + ax.plot( + gc_track["lon_deg"].values, + gc_track["lat_deg"].values, + color="#2166ac", + linewidth=2, + alpha=0.7, + transform=transform, + label="Great Circle", + linestyle="--", + ) + + for label_name, (idx, color) in picks.items(): + row = opt_df.loc[idx] + track = _load_track(input_dir, row["details_filename"]) + dep_date = pd.to_datetime(row["departure_time_utc"]).strftime("%b %d") + energy = row["energy_cons_mwh"] + ax.plot( + track["lon_deg"].values, + track["lat_deg"].values, + color=color, + linewidth=2.5, + alpha=0.9, + transform=transform, + label=f"{label_name}: {dep_date} ({energy:.0f} MWh)", + ) + + ax.set_title( + f"{corridor.title()} Optimised — {wps_label}\n" + f"Best / Median / Worst departures out of 366", + fontsize=13, + fontweight="bold", + ) + ax.legend(loc="lower left", fontsize=10) + + out = fig_dir / f"seasonal_routes_{corridor}_{wps_label}.png" + fig.savefig(out, dpi=200, bbox_inches="tight", facecolor="white") + plt.close(fig) + print(f" Saved {out}") + + +# --------------------------------------------------------------------------- +# Figure 5: Distance vs Energy scatter +# --------------------------------------------------------------------------- + + +def plot_distance_vs_energy( + input_dir: Path, + fig_dir: Path, + submission: int | None = None, +) -> None: + """Scatter plot of sailed distance vs energy, colored by departure month.""" + for corridor in ["atlantic", "pacific"]: + prefix = "A" if corridor == "atlantic" else "P" + + fig, axes = plt.subplots(1, 2, figsize=(14, 6)) + + for i, wps_label in enumerate(["noWPS", "WPS"]): + ax = axes[i] + opt_case = f"{prefix}O_{wps_label}" + gc_case = f"{prefix}GC_{wps_label}" + + opt_df = _load_summary(input_dir, opt_case, submission=submission) + gc_df = _load_summary(input_dir, gc_case, submission=submission) + + months = pd.to_datetime(opt_df["departure_time_utc"]).dt.month + sc = ax.scatter( + opt_df["sailed_distance_nm"], + opt_df["energy_cons_mwh"], + c=months, + cmap="hsv", + s=12, + alpha=0.7, + vmin=1, + vmax=12, + label="Optimised", + ) + # GC: same route every departure but energy varies with weather + gc_months = pd.to_datetime(gc_df["departure_time_utc"]).dt.month + ax.scatter( + gc_df["sailed_distance_nm"], + gc_df["energy_cons_mwh"], + c=gc_months, + cmap="hsv", + s=12, + alpha=0.7, + vmin=1, + vmax=12, + marker="^", + label="GC", + ) + # GC mean reference star + gc_d = gc_df["sailed_distance_nm"].mean() + gc_e = gc_df["energy_cons_mwh"].mean() + ax.scatter( + gc_d, + gc_e, + color="navy", + marker="*", + s=200, + zorder=10, + edgecolors="white", + linewidths=0.5, + label=f"GC mean ({gc_d:.0f} nm, {gc_e:.0f} MWh)", + ) + + ax.set_xlabel("Sailed Distance (nm)", fontsize=11) + ax.set_ylabel("Energy (MWh)", fontsize=11) + ax.set_title(f"{wps_label}", fontsize=12, fontweight="bold") + ax.legend(fontsize=9) + ax.grid(alpha=0.3) + + fig.suptitle( + f"{corridor.title()} — Distance vs Energy (colored by month)", + fontsize=13, + fontweight="bold", + ) + cbar = fig.colorbar(sc, ax=axes, label="Month", ticks=range(1, 13)) + cbar.set_ticklabels( + ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"] + ) + + out = fig_dir / f"distance_vs_energy_{corridor}.png" + fig.savefig(out, dpi=200, bbox_inches="tight", facecolor="white") + plt.close(fig) + print(f" Saved {out}") + + +# --------------------------------------------------------------------------- +# CLI entry +# --------------------------------------------------------------------------- + + +@app.command() +def main( + input_dir: Path = typer.Option( # noqa: B008 + "output/swopp3", + "--input-dir", + "-i", + help="Directory containing SWOPP3 output CSVs.", + ), + submission: int | None = typer.Option( # noqa: B008 + None, + "--submission", + "-s", + help="Submission number to plot (default: auto-detect latest per case).", + ), + sample_step: int = typer.Option( # noqa: B008 + 1, + "--sample-step", + help="Plot every Nth departure in spaghetti plots (1=all).", + ), +) -> None: + """Generate all SWOPP3 route visualizations.""" + fig_dir = input_dir / "figures" + fig_dir.mkdir(parents=True, exist_ok=True) + + print("=" * 60) + print("SWOPP3 Route Visualizations") + print("=" * 60) + + # 1. Spaghetti maps: all routes overlaid + print("\n[1/5] Route spaghetti maps...") + for corridor in ["atlantic", "pacific"]: + for wps in [True, False]: + plot_corridor_spaghetti( + input_dir, + corridor, + wps, + fig_dir, + submission=submission, + sample_step=sample_step, + ) + + # 2. Energy box plots + print("\n[2/5] Energy comparison box plots...") + plot_energy_comparison(input_dir, fig_dir, submission=submission) + + # 3. Energy time series + print("\n[3/5] Energy time series...") + plot_energy_timeseries(input_dir, fig_dir, submission=submission) + + # 4. Seasonal representative routes + print("\n[4/5] Seasonal representative routes...") + plot_seasonal_routes(input_dir, fig_dir, submission=submission) + + # 5. Distance vs Energy + print("\n[5/5] Distance vs Energy scatter...") + plot_distance_vs_energy(input_dir, fig_dir, submission=submission) + + print(f"\nDone — {len(list(fig_dir.glob('*.png')))} figures in {fig_dir}") + + +if __name__ == "__main__": + app() diff --git a/scripts/swopp3_run.py b/scripts/swopp3_run.py new file mode 100755 index 00000000..a3143a03 --- /dev/null +++ b/scripts/swopp3_run.py @@ -0,0 +1,743 @@ +#!/usr/bin/env python +r"""Run SWOPP3 cases — CLI entry-point. + +Usage +----- +Default pipeline for the full 2024 SWOPP3 dataset: + + uv run scripts/download_era5.py + uv run scripts/swopp3_run.py + +The defaults in this script expect the four NetCDF files written by +``scripts/download_era5.py`` for year 2024. If any required file is missing, +the command fails before execution and explains exactly which datasets are +missing and why they are required. + +Run all 8 cases with explicit per-corridor paths: + + uv run scripts/swopp3_run.py \ + --wind-path-atlantic data/era5/era5_wind_atlantic_2024.nc \ + --wave-path-atlantic data/era5/era5_waves_atlantic_2024.nc \ + --wind-path-pacific data/era5/era5_wind_pacific_2024.nc \ + --wave-path-pacific data/era5/era5_waves_pacific_2024.nc \ + --output-dir output/swopp3 + +Run only Atlantic cases after downloading Atlantic data: + + uv run scripts/swopp3_run.py \ + --cases AGC_WPS AGC_noWPS \ + --wind-path data/era5/era5_wind_atlantic_2024.nc \ + --wave-path data/era5/era5_waves_atlantic_2024.nc \ + --output-dir output/swopp3 + +Run only the first 3 departures (quick test): + + uv run scripts/swopp3_run.py --max-departures 3 --output-dir output/swopp3 +""" + +from __future__ import annotations + +import json +import re +import tomllib +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import typer + +if TYPE_CHECKING: + from routetools.swopp3_runner import FieldClosure + +app = typer.Typer( + help=( + "SWOPP3 competition runner. Expects ERA5 files produced by " + "scripts/download_era5.py unless explicit paths are provided." + ) +) + +_ERA5_FILE_RE = re.compile( + r"^(?Pera5_[^_]+_[^_]+_)(?P\d{4})(?:_(?P\d{2}(?:-\d{2})?))?\.nc$" +) +_CONFIG_PATH_KEYS = { + "output_dir", + "wind_path", + "wave_path", + "wind_path_atlantic", + "wave_path_atlantic", + "wind_path_pacific", + "wave_path_pacific", +} + + +def _selected_corridors(case_ids: list[str]) -> list[str]: + """Return the sorted set of route corridors required by ``case_ids``.""" + from routetools.swopp3 import SWOPP3_CASES + + return sorted({str(SWOPP3_CASES[cid]["route"]) for cid in case_ids}) + + +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")) + return [path, *continuation_paths] + + +def _validate_required_data_paths( + case_ids: list[str], + corridor_wind: dict[str, Path], + corridor_wave: dict[str, Path], +) -> None: + """Fail fast when the ERA5 inputs required by the selected cases are missing.""" + required_corridors = _selected_corridors(case_ids) + missing: list[str] = [] + + for corridor in required_corridors: + wind = corridor_wind.get(corridor) + wave = corridor_wave.get(corridor) + + if wind is None: + missing.append(f"{corridor} wind dataset path is not configured") + elif not Path(wind).exists(): + missing.append(f"{corridor} wind dataset not found: {wind}") + + if wave is None: + missing.append(f"{corridor} wave dataset path is not configured") + elif not Path(wave).exists(): + missing.append(f"{corridor} wave dataset not found: {wave}") + + if not missing: + return + + corridor_list = ", ".join(required_corridors) + missing_lines = "\n".join(f"- {item}" for item in missing) + raise FileNotFoundError( + "SWOPP3 input validation failed.\n\n" + f"Selected cases require ERA5 datasets for corridor(s): {corridor_list}.\n" + "This CLI requires weather data for every selected case:\n" + "- GC cases use wind and wave data during SWOPP3 energy evaluation.\n" + "- Optimised cases use wind data to build the CMA-ES vectorfield " + "and use wind/wave data during energy evaluation.\n" + "- Missing ERA5 files are a hard error; there is no fallback " + "to GC or no-weather mode.\n\n" + f"Missing inputs:\n{missing_lines}\n\n" + "Fix:\n" + "- Run `uv run scripts/download_era5.py` to download the default " + "2024 Atlantic and Pacific datasets, then rerun " + "`uv run scripts/swopp3_run.py`.\n" + "- If you downloaded a different year or only one corridor, " + "pass matching `--wind-path*` and `--wave-path*` options." + ) + + +def _resolve_case_ids( + cases: list[str] | None, + strategy: str | None, +) -> list[str]: + """Return the case IDs selected by the given filters.""" + from routetools.swopp3 import SWOPP3_CASES + + if cases is not None: + case_ids = cases + for cid in case_ids: + if cid not in SWOPP3_CASES: + raise ValueError(f"Unknown case: {cid}") + else: + case_ids = list(SWOPP3_CASES.keys()) + + if strategy is not None: + case_ids = [ + cid for cid in case_ids if SWOPP3_CASES[cid]["strategy"] == strategy + ] + if not case_ids: + raise ValueError(f"No cases match strategy '{strategy}'") + + return case_ids + + +def _resolve_config_value_path(config_path: Path, value: str | Path) -> Path: + """Resolve a path-like config value relative to the config file.""" + path = Path(value) + if path.is_absolute(): + return path + return (config_path.parent / path).resolve() + + +def _resolve_profile_paths(config_path: Path, config: dict[str, Any]) -> dict[str, Any]: + """Resolve path-like fields in a profile or run dictionary.""" + resolved = dict(config) + for key in _CONFIG_PATH_KEYS: + if key in resolved and resolved[key] is not None: + resolved[key] = _resolve_config_value_path(config_path, resolved[key]) + return resolved + + +def _load_experiment_profile(config_path: Path, experiment: str) -> dict[str, Any]: + """Load and resolve one SWOPP3 experiment profile from TOML.""" + if not config_path.exists(): + raise FileNotFoundError(f"Experiment config not found: {config_path}") + + with config_path.open("rb") as handle: + config = tomllib.load(handle) + + experiments = config.get("swopp3", {}).get("experiments", {}) + if experiment not in experiments: + available = ", ".join(sorted(experiments)) or "(none)" + raise KeyError( + f"Unknown SWOPP3 experiment '{experiment}'. Available profiles: {available}" + ) + + raw_profile = experiments[experiment] + defaults = _resolve_profile_paths( + config_path, + dict(raw_profile.get("defaults", {})), + ) + raw_runs = raw_profile.get("runs", []) + if not raw_runs: + raise ValueError(f"Experiment '{experiment}' does not define any runs") + + resolved_runs: list[dict[str, Any]] = [] + for index, raw_run in enumerate(raw_runs, start=1): + run = _resolve_profile_paths(config_path, {**defaults, **raw_run}) + cases = run.get("cases") + if isinstance(cases, str): + run["cases"] = [cases] + elif cases is not None: + run["cases"] = list(cases) + + run.setdefault("name", f"run_{index}") + resolved_runs.append(run) + + resolved_output_dir = raw_profile.get("output_dir", f"output/{experiment}") + return { + "name": experiment, + "description": raw_profile.get("description", ""), + "source_script": raw_profile.get("source_script", ""), + "output_dir": _resolve_config_value_path(config_path, resolved_output_dir), + "runs": resolved_runs, + "raw_profile": raw_profile, + } + + +def _write_experiment_manifest( + *, + config_path: Path, + profile: dict[str, Any], +) -> Path: + """Write a resolved experiment manifest alongside the output files.""" + output_dir = Path(profile["output_dir"]) + output_dir.mkdir(parents=True, exist_ok=True) + manifest_path = output_dir / "experiment_manifest.json" + manifest = { + "experiment": profile["name"], + "description": profile.get("description", ""), + "source_script": profile.get("source_script", ""), + "config_path": str(config_path), + "output_dir": str(output_dir), + "runs": [ + { + key: (str(value) if isinstance(value, Path) else value) + for key, value in run.items() + } + for run in profile["runs"] + ], + } + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + return manifest_path + + +def _run_swopp3_configuration( + *, + cases: list[str] | None, + strategy: str | None, + wind_path: Path | None, + wave_path: Path | None, + wind_path_atlantic: Path | None, + wave_path_atlantic: Path | None, + wind_path_pacific: Path | None, + wave_path_pacific: Path | None, + output_dir: Path, + submission: int, + n_points: int, + max_departures: int | None, + weather_penalty_weight: float, + wind_penalty_weight: float, + wave_penalty_weight: float, + distance_penalty_weight: float, + dt_eval_minutes: float, + cmaes_k: int, + sigma0: float, + popsize: int, + maxfevals: int, + cmaes_verbose: bool, + quiet: bool, +) -> None: + """Execute one resolved SWOPP3 run configuration.""" + import xarray as xr + + from routetools.era5.loader import ( + load_dataset_epoch, + load_era5_vectorfield, + load_era5_wavefield, + load_era5_windfield, + load_natural_earth_land_mask, + ) + from routetools.swopp3 import SWOPP3_CASES, departures_2024 + from routetools.swopp3_runner import run_case + + case_ids = _resolve_case_ids(cases, strategy) + + departures = departures_2024() + if max_departures is not None: + departures = departures[:max_departures] + + typer.echo(f"Running {len(case_ids)} case(s) × {len(departures)} departure(s)") + + corridor_wind: dict[str, Path] = {} + corridor_wave: dict[str, Path] = {} + + if wind_path_atlantic is not None: + corridor_wind["atlantic"] = wind_path_atlantic + if wave_path_atlantic is not None: + corridor_wave["atlantic"] = wave_path_atlantic + if wind_path_pacific is not None: + corridor_wind["pacific"] = wind_path_pacific + if wave_path_pacific is not None: + corridor_wave["pacific"] = wave_path_pacific + + # Shared paths intentionally override corridor defaults so the one-flag + # workflow still works for single-corridor runs. + if wind_path is not None: + corridor_wind["atlantic"] = wind_path + corridor_wind["pacific"] = wind_path + if wave_path is not None: + corridor_wave["atlantic"] = wave_path + corridor_wave["pacific"] = wave_path + + _validate_required_data_paths(case_ids, corridor_wind, corridor_wave) + + _loaded_wind: dict[str, tuple[FieldClosure, datetime]] = {} + _loaded_wave: dict[str, tuple[FieldClosure, datetime]] = {} + _loaded_vf: dict[str, FieldClosure] = {} + _loaded_land: dict[str, object] = {} + + def _get_wind(corridor: str) -> tuple[FieldClosure, datetime]: + """Return the windfield closure and dataset epoch for one corridor.""" + if corridor in _loaded_wind: + return _loaded_wind[corridor] + wp = corridor_wind.get(corridor) + if wp is None: + raise ValueError(f"No wind path available for corridor '{corridor}'") + load_paths = _loadable_era5_paths(wp) + load_target = load_paths if len(load_paths) > 1 else load_paths[0] + typer.echo( + f"Loading wind field for {corridor} from " + f"{', '.join(str(path) for path in load_paths)} …" + ) + epoch = load_dataset_epoch(load_target) + wf = load_era5_windfield(load_target) + _loaded_wind[corridor] = (wf, epoch) + return wf, epoch + + def _get_vectorfield(corridor: str) -> FieldClosure: + """Return the ERA5 vectorfield closure for one corridor.""" + if corridor in _loaded_vf: + return _loaded_vf[corridor] + wp = corridor_wind.get(corridor) + if wp is None: + raise ValueError(f"No wind path available for corridor '{corridor}'") + load_paths = _loadable_era5_paths(wp) + load_target = load_paths if len(load_paths) > 1 else load_paths[0] + typer.echo( + f"Loading vectorfield for {corridor} from " + f"{', '.join(str(path) for path in load_paths)} …" + ) + vf = load_era5_vectorfield(load_target) + _loaded_vf[corridor] = vf + return vf + + def _get_wave(corridor: str) -> tuple[FieldClosure, datetime]: + """Return the wavefield closure and dataset epoch for one corridor.""" + if corridor in _loaded_wave: + return _loaded_wave[corridor] + wp = corridor_wave.get(corridor) + if wp is None: + raise ValueError(f"No wave path available for corridor '{corridor}'") + load_paths = _loadable_era5_paths(wp) + load_target = load_paths if len(load_paths) > 1 else load_paths[0] + typer.echo( + f"Loading wave field for {corridor} from " + f"{', '.join(str(path) for path in load_paths)} …" + ) + epoch = load_dataset_epoch(load_target) + wvf = load_era5_wavefield(load_target) + _loaded_wave[corridor] = (wvf, epoch) + return wvf, epoch + + def _get_land(corridor: str): + """Build and cache the Natural Earth land mask for one corridor.""" + if corridor in _loaded_land: + return _loaded_land[corridor] + # Infer the spatial extent from the weather files so the land mask only + # covers the active corridor. + wp = corridor_wave.get(corridor) or corridor_wind.get(corridor) + if wp is None: + raise ValueError(f"No wind/wave path available for corridor '{corridor}'") + wp = _loadable_era5_paths(wp)[0] + + with xr.open_dataset(wp) as ds: + for cname in ("longitude", "lon"): + if cname in ds.coords: + lons = ds[cname].values + break + else: + raise KeyError(f"No longitude coordinate found in {wp}") + for cname in ("latitude", "lat"): + if cname in ds.coords: + lats = ds[cname].values + break + else: + raise KeyError(f"No latitude coordinate found in {wp}") + lon_range = (float(lons.min()), float(lons.max())) + lat_range = (float(lats.min()), float(lats.max())) + typer.echo( + f"Building Natural Earth land mask for {corridor} " + f"lon={lon_range}, lat={lat_range} …" + ) + land = load_natural_earth_land_mask(lon_range, lat_range) + _loaded_land[corridor] = land + return land + + for cid in case_ids: + case = SWOPP3_CASES[cid] + corridor = case["route"] + typer.echo(f"\n{'=' * 60}") + typer.echo(f"Case {cid}: {case['label']}") + typer.echo( + f" strategy={case['strategy']} wps={case['wps']} route={corridor}" + ) + typer.echo(f"{'=' * 60}") + + windfield, wind_epoch = _get_wind(corridor) + wavefield, wave_epoch = _get_wave(corridor) + if wave_epoch != wind_epoch: + raise ValueError( + "Wind and wave dataset epochs differ for corridor " + f"'{corridor}': {wind_epoch.isoformat()} != {wave_epoch.isoformat()}" + ) + vectorfield = _get_vectorfield(corridor) + land = _get_land(corridor) + dataset_epoch = wind_epoch + + results = run_case( + cid, + departures, + vectorfield=vectorfield, + windfield=windfield, + wavefield=wavefield, + land=land, + output_dir=output_dir, + submission=submission, + n_points=n_points, + verbose=not quiet, + dataset_epoch=dataset_epoch, + weather_penalty_weight=weather_penalty_weight, + wind_penalty_weight=wind_penalty_weight, + wave_penalty_weight=wave_penalty_weight, + distance_penalty_weight=distance_penalty_weight, + dt_eval_minutes=dt_eval_minutes, + K=cmaes_k, + sigma0=sigma0, + popsize=popsize, + maxfevals=maxfevals, + cmaes_verbose=cmaes_verbose, + ) + + energies = [r.energy_mwh for r in results] + total_time = sum(r.comp_time_s for r in results) + typer.echo( + f" {len(results)} departures " + f"mean E={sum(energies) / len(energies):.2f} MWh " + f"total comp time={total_time:.1f}s" + ) + + typer.echo(f"\nOutputs written to {output_dir}") + + +@app.command() +def main( + experiment: str | None = typer.Argument( # noqa: B008 + None, + help=( + "Experiment profile name from config.toml. When provided, the " + "profile drives all run parameters." + ), + ), + config_path: Path = typer.Option( # noqa: B008 + "config.toml", + "--config-path", + help="Path to the TOML file that stores named experiment profiles.", + ), + cases: list[str] | None = typer.Option( # noqa: B008 + None, + "--cases", + "-c", + help="Case IDs to run (e.g. AGC_WPS PO_noWPS). Default: all 8.", + ), + strategy: str | None = typer.Option( # noqa: B008 + None, + "--strategy", + "-s", + help="Filter by strategy: 'gc' or 'optimised'. Default: both.", + ), + wind_path: Path | None = typer.Option( # noqa: B008 + None, + "--wind-path", + help=( + "Path to ERA5 wind NetCDF used for all selected corridors. " + "Overrides the built-in corridor defaults when provided." + ), + ), + wave_path: Path | None = typer.Option( # noqa: B008 + None, + "--wave-path", + help=( + "Path to ERA5 wave NetCDF used for all selected corridors. " + "Overrides the built-in corridor defaults when provided." + ), + ), + wind_path_atlantic: Path | None = typer.Option( # noqa: B008 + "data/era5/era5_wind_atlantic_2024.nc", + "--wind-path-atlantic", + help=( + "Path to ERA5 wind NetCDF for Atlantic corridor. Defaults to the " + "file written by scripts/download_era5.py for year 2024." + ), + ), + wave_path_atlantic: Path | None = typer.Option( # noqa: B008 + "data/era5/era5_waves_atlantic_2024.nc", + "--wave-path-atlantic", + help=( + "Path to ERA5 wave NetCDF for Atlantic corridor. Defaults to the " + "file written by scripts/download_era5.py for year 2024." + ), + ), + wind_path_pacific: Path | None = typer.Option( # noqa: B008 + "data/era5/era5_wind_pacific_2024.nc", + "--wind-path-pacific", + help=( + "Path to ERA5 wind NetCDF for Pacific corridor. Defaults to the " + "file written by scripts/download_era5.py for year 2024." + ), + ), + wave_path_pacific: Path | None = typer.Option( # noqa: B008 + "data/era5/era5_waves_pacific_2024.nc", + "--wave-path-pacific", + help=( + "Path to ERA5 wave NetCDF for Pacific corridor. Defaults to the " + "file written by scripts/download_era5.py for year 2024." + ), + ), + output_dir: Path = typer.Option( # noqa: B008 + "output/swopp3", + "--output-dir", + "-o", + help="Output directory for CSV files.", + ), + submission: int = typer.Option( # noqa: B008 + 1, + "--submission", + help="Submission number for file naming.", + ), + n_points: int = typer.Option( # noqa: B008 + 100, + "--n-points", + help="Number of route waypoints.", + ), + max_departures: int | None = typer.Option( # noqa: B008 + None, + "--max-departures", + "-n", + help="Limit number of departures (for quick testing).", + ), + weather_penalty_weight: float = typer.Option( # noqa: B008 + 0.0, + "--weather-penalty-weight", + help="Hard weather penalty weight (step function). 0 to disable.", + ), + wind_penalty_weight: float = typer.Option( # noqa: B008 + 0.0, + "--wind-penalty-weight", + help="Smooth wind (TWS) penalty weight. 0 to disable.", + ), + wave_penalty_weight: float = typer.Option( # noqa: B008 + 0.0, + "--wave-penalty-weight", + help="Smooth wave (Hs) penalty weight. 0 to disable.", + ), + distance_penalty_weight: float = typer.Option( # noqa: B008 + 0.0, + "--distance-penalty-weight", + help="EDT distance-to-land penalty weight. 0 to disable.", + ), + dt_eval_minutes: float = typer.Option( # noqa: B008 + 0.0, + "--dt-eval-minutes", + help=( + "Evaluation grid spacing in minutes (\u0394t\u2082). " + "When positive, the optimizer evaluates B\u00e9zier curves at a " + "finer resolution than --n-points for more accurate energy " + "quadrature. 0 = use --n-points for both." + ), + ), + cmaes_k: int = typer.Option( # noqa: B008 + 10, + "--cmaes-k", + help="Number of B\u00e9zier control points for CMA-ES.", + ), + sigma0: float = typer.Option( # noqa: B008 + 0.1, + "--sigma0", + help="Initial CMA-ES step size (sigma0).", + ), + popsize: int = typer.Option( # noqa: B008 + 200, + "--popsize", + help="CMA-ES population size.", + ), + maxfevals: int = typer.Option( # noqa: B008 + 25000, + "--maxfevals", + help="Maximum number of CMA-ES function evaluations.", + ), + cmaes_verbose: bool = typer.Option( # noqa: B008 + False, + "--cmaes-verbose", + help="Print per-generation CMA-ES diagnostics.", + ), + quiet: bool = typer.Option( # noqa: B008 + False, + "--quiet", + "-q", + help="Suppress progress output.", + ), +) -> None: + """Run SWOPP3 competition cases. + + The default invocation expects the 2024 ERA5 files produced by + ``scripts/download_era5.py``. The command validates those inputs before + loading any corridor and exits with a precise message if a required file + is missing. + """ + try: + if experiment is not None: + profile = _load_experiment_profile(config_path, experiment) + manifest_path = _write_experiment_manifest( + config_path=config_path, + profile=profile, + ) + if not quiet: + typer.echo( + f"Loaded experiment '{experiment}' from {config_path} " + f"-> {profile['output_dir']}" + ) + typer.echo(f"Wrote experiment manifest to {manifest_path}") + + for run_index, run in enumerate(profile["runs"], start=1): + if not quiet: + typer.echo( + f"\n--- Experiment run {run_index}/{len(profile['runs'])}: " + f"{run['name']} ---" + ) + _run_swopp3_configuration( + cases=run.get("cases"), + strategy=run.get("strategy"), + wind_path=run.get("wind_path", wind_path), + wave_path=run.get("wave_path", wave_path), + wind_path_atlantic=run.get( + "wind_path_atlantic", + wind_path_atlantic, + ), + wave_path_atlantic=run.get( + "wave_path_atlantic", + wave_path_atlantic, + ), + wind_path_pacific=run.get( + "wind_path_pacific", + wind_path_pacific, + ), + wave_path_pacific=run.get( + "wave_path_pacific", + wave_path_pacific, + ), + output_dir=Path(profile["output_dir"]), + submission=int(run.get("submission", submission)), + n_points=int(run.get("n_points", n_points)), + max_departures=run.get("max_departures", max_departures), + weather_penalty_weight=float( + run.get("weather_penalty_weight", weather_penalty_weight) + ), + wind_penalty_weight=float( + run.get("wind_penalty_weight", wind_penalty_weight) + ), + wave_penalty_weight=float( + run.get("wave_penalty_weight", wave_penalty_weight) + ), + distance_penalty_weight=float( + run.get( + "distance_penalty_weight", + distance_penalty_weight, + ) + ), + dt_eval_minutes=float(run.get("dt_eval_minutes", dt_eval_minutes)), + cmaes_k=int(run.get("cmaes_k", cmaes_k)), + sigma0=float(run.get("sigma0", sigma0)), + popsize=int(run.get("popsize", popsize)), + maxfevals=int(run.get("maxfevals", maxfevals)), + cmaes_verbose=bool(run.get("cmaes_verbose", cmaes_verbose)), + quiet=quiet, + ) + return + + _run_swopp3_configuration( + cases=cases, + strategy=strategy, + wind_path=wind_path, + wave_path=wave_path, + wind_path_atlantic=wind_path_atlantic, + wave_path_atlantic=wave_path_atlantic, + wind_path_pacific=wind_path_pacific, + wave_path_pacific=wave_path_pacific, + output_dir=output_dir, + submission=submission, + n_points=n_points, + max_departures=max_departures, + weather_penalty_weight=weather_penalty_weight, + wind_penalty_weight=wind_penalty_weight, + wave_penalty_weight=wave_penalty_weight, + distance_penalty_weight=distance_penalty_weight, + dt_eval_minutes=dt_eval_minutes, + cmaes_k=cmaes_k, + sigma0=sigma0, + popsize=popsize, + maxfevals=maxfevals, + cmaes_verbose=cmaes_verbose, + quiet=quiet, + ) + except (FileNotFoundError, KeyError, ValueError) as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + + +if __name__ == "__main__": + app() diff --git a/scripts/swopp3_slurm.sh b/scripts/swopp3_slurm.sh new file mode 100755 index 00000000..38f754e6 --- /dev/null +++ b/scripts/swopp3_slurm.sh @@ -0,0 +1,85 @@ +#!/bin/bash +#SBATCH --job-name=swopp3_0125 +#SBATCH --partition=cpu +#SBATCH --nodes=1 +#SBATCH --cpus-per-task=64 +#SBATCH --mem=128G +#SBATCH --time=2-00:00:00 +#SBATCH --output=slurm_%j.out +#SBATCH --error=slurm_%j.err + +# ── SWOPP3 full run on rust-HPC (0.125° ERA5 data, CPU mode) ── +# +# Submit: sbatch scripts/swopp3_slurm.sh +# Monitor: squeue -u $USER +# Cancel: scancel + +set -euo pipefail + +# ── Environment ── +export PATH="$HOME/.local/bin:$PATH" +cd "$HOME/routetools" +source .venv/bin/activate + +# Force JAX to use CPU (avoid GPU OOM with large 0.125° grids) +export JAX_PLATFORMS=cpu + +# Use all allocated CPUs for XLA parallelism (preserve existing XLA_FLAGS) +export XLA_FLAGS="${XLA_FLAGS:+$XLA_FLAGS }--xla_cpu_multi_thread_eigen=true --xla_force_host_platform_device_count=${SLURM_CPUS_PER_TASK}" + +# ── Paths ── +DATA_025="data/era5" +DATA_0125="data/era5_0125" +OUTDIR="output/swopp3_0125_rust" + +mkdir -p "$OUTDIR" + +echo "======================================" +echo "SWOPP3 run on $(hostname)" +echo "Date: $(date)" +echo "CPUs: ${SLURM_CPUS_PER_TASK}" +echo "Memory: ${SLURM_MEM_PER_NODE}MB" +echo "JAX: CPU mode" +echo "Data: 0.125°" +echo "Output: ${OUTDIR}" +echo "======================================" + +# Verify data is present +for f in \ + "${DATA_0125}/era5_wind_atlantic_2024.nc" \ + "${DATA_0125}/era5_waves_atlantic_2024.nc" \ + "${DATA_0125}/era5_wind_pacific_2024.nc" \ + "${DATA_0125}/era5_waves_pacific_2024.nc"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All data files present." + +# Quick JAX sanity check +python -c "import jax; print(f'JAX {jax.__version__}, devices: {jax.devices()}')" + +# ── Run all 8 cases with 0.125° data ── +echo "" +echo "Starting SWOPP3 run at $(date)" +echo "" + +python scripts/swopp3_run.py \ + --wind-path-atlantic "${DATA_0125}/era5_wind_atlantic_2024.nc" \ + --wave-path-atlantic "${DATA_0125}/era5_waves_atlantic_2024.nc" \ + --wind-path-pacific "${DATA_0125}/era5_wind_pacific_2024.nc" \ + --wave-path-pacific "${DATA_0125}/era5_waves_pacific_2024.nc" \ + --output-dir "$OUTDIR" + +echo "" +echo "======================================" +echo "SWOPP3 run completed at $(date)" +echo "======================================" + +# ── Summary ── +echo "" +echo "Output files:" +ls -lh "$OUTDIR"/*.csv 2>/dev/null || echo "(no CSV files found)" +echo "" +echo "Done." diff --git a/scripts/swopp3_slurm_atlantic_k10.sh b/scripts/swopp3_slurm_atlantic_k10.sh new file mode 100755 index 00000000..5bb61533 --- /dev/null +++ b/scripts/swopp3_slurm_atlantic_k10.sh @@ -0,0 +1,104 @@ +#!/bin/bash +#SBATCH --job-name=swopp3_atl_k10 +#SBATCH --partition=cpu +#SBATCH --nodes=1 +#SBATCH --cpus-per-task=64 +#SBATCH --mem=128G +#SBATCH --time=12:00:00 +#SBATCH --output=slurm_atl_k10_%j.out +#SBATCH --error=slurm_atl_k10_%j.err + +# ── SWOPP3 Atlantic-only run: K=10, popsize=400, maxfevals=50000 ── +# +# Experiment to test whether more CMA-ES budget / smaller K resolves +# the pathological energy spikes observed in the Atlantic corridor with +# wind×wave penalties (w=50, v=50). +# +# Submit: sbatch scripts/swopp3_slurm_atlantic_k10.sh +# Monitor: squeue -u $USER + +set -euo pipefail + +# ── Parameters ── +K=10 +SIGMA0=0.3 +POPSIZE=400 +MAXFEVALS=50000 +WIND_PW=50.0 +WAVE_PW=50.0 +DIST_PW=10.0 +DT_EVAL=30 # Δt₂ = 30 min evaluation grid + +LABEL="atlantic_k10_p400_w50_v50" + +# ── Environment ── +ROOTDIR="/home/fjsuarez/routetools" +export PATH="/home/fjsuarez/.local/bin:$PATH" +cd "$ROOTDIR" +source .venv/bin/activate + +export JAX_PLATFORMS=cpu +export XLA_FLAGS="${XLA_FLAGS:+$XLA_FLAGS }--xla_cpu_multi_thread_eigen=true --xla_force_host_platform_device_count=${SLURM_CPUS_PER_TASK}" + +# ── Paths ── +DATA="/data/fjsuarez/era5" +OUTDIR="output/swopp3_${LABEL}" +mkdir -p "$OUTDIR" + +echo "======================================" +echo "SWOPP3 Atlantic K=10 experiment" +echo "Date: $(date)" +echo "Host: $(hostname)" +echo "Job: ${SLURM_JOB_ID}" +echo "CPUs: ${SLURM_CPUS_PER_TASK}" +echo "Output: ${OUTDIR}" +echo "K: ${K}" +echo "σ₀: ${SIGMA0}" +echo "popsize: ${POPSIZE}" +echo "maxfevals: ${MAXFEVALS}" +echo "Wind PW: ${WIND_PW}" +echo "Wave PW: ${WAVE_PW}" +echo "Dist PW: ${DIST_PW}" +echo "dt_eval: ${DT_EVAL} min" +echo "======================================" + +# Verify data +for f in \ + "${DATA}/era5_wind_atlantic_2024.nc" \ + "${DATA}/era5_waves_atlantic_2024.nc"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All data files present." + +python -c "import jax; print(f'JAX {jax.__version__}, devices: {jax.devices()}')" + +# ── Atlantic cases (354 h → n_points=178 for Δt₁=2 h) ── +echo "" +echo "=== Atlantic cases (n_points=178) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases AO_WPS --cases AO_noWPS --cases AGC_WPS --cases AGC_noWPS \ + --wind-path-atlantic "${DATA}/era5_wind_atlantic_2024.nc" \ + --wave-path-atlantic "${DATA}/era5_waves_atlantic_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 178 \ + --dt-eval-minutes "$DT_EVAL" \ + --cmaes-k "$K" \ + --sigma0 "$SIGMA0" \ + --popsize "$POPSIZE" \ + --maxfevals "$MAXFEVALS" \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" \ + --distance-penalty-weight "$DIST_PW" + +echo "" +echo "======================================" +echo "SWOPP3 ${LABEL} complete: $(date)" +echo "======================================" +echo "" +echo "Output files:" +ls -lh "$OUTDIR"/*.csv 2>/dev/null || echo "(no CSV files found)" diff --git a/scripts/swopp3_slurm_gpu.sh b/scripts/swopp3_slurm_gpu.sh new file mode 100755 index 00000000..3d065edb --- /dev/null +++ b/scripts/swopp3_slurm_gpu.sh @@ -0,0 +1,96 @@ +#!/bin/bash +#SBATCH --job-name=swopp3_gpu +#SBATCH --partition=gpu +#SBATCH --nodes=1 +#SBATCH --cpus-per-task=32 +#SBATCH --mem=64G +#SBATCH --gres=gpu:1 +#SBATCH --time=1-00:00:00 +#SBATCH --output=slurm_%j.out +#SBATCH --error=slurm_%j.err + +# ── SWOPP3 full run on rust-HPC (0.125° ERA5 data, GPU mode) ── +# +# This variant uses a single RTX 6000 Ada (48 GB). +# Key tuning: +# - XLA_PYTHON_CLIENT_PREALLOCATE=false: don't pre-reserve GPU memory, +# allow on-demand allocation so compilation peaks don't OOM. +# - XLA_PYTHON_CLIENT_MEM_FRACTION=0.95: allow up to 95% of GPU memory. +# +# Submit: sbatch scripts/swopp3_slurm_gpu.sh +# Monitor: squeue -u $USER + +set -euo pipefail + +# ── Environment ── +export PATH="$HOME/.local/bin:$PATH" +cd "$HOME/routetools" +source .venv/bin/activate + +# GPU memory management: don't preallocate, allow peak usage +export JAX_PLATFORMS=cuda +export XLA_PYTHON_CLIENT_PREALLOCATE=false +export XLA_PYTHON_CLIENT_MEM_FRACTION=0.95 + +# CPU threads for data loading/preprocessing +export OMP_NUM_THREADS=${SLURM_CPUS_PER_TASK} + +# ── Paths ── +DATA_0125="data/era5_0125" +OUTDIR="output/swopp3_0125_gpu" + +mkdir -p "$OUTDIR" + +echo "======================================" +echo "SWOPP3 GPU run on $(hostname)" +echo "Date: $(date)" +echo "CPUs: ${SLURM_CPUS_PER_TASK}" +echo "GPU: $(nvidia-smi -L 2>/dev/null | head -1 || echo 'unknown')" +echo "JAX: CUDA mode" +echo "Data: 0.125°" +echo "Output: ${OUTDIR}" +echo "======================================" + +# Verify data +for f in \ + "${DATA_0125}/era5_wind_atlantic_2024.nc" \ + "${DATA_0125}/era5_waves_atlantic_2024.nc" \ + "${DATA_0125}/era5_wind_pacific_2024.nc" \ + "${DATA_0125}/era5_waves_pacific_2024.nc"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All data files present." + +# JAX sanity check + GPU info +python -c " +import jax +print(f'JAX {jax.__version__}, devices: {jax.devices()}') +for d in jax.devices(): + if hasattr(d, 'memory_stats'): + stats = d.memory_stats() + if stats: + total = stats.get('bytes_limit', 0) / 1e9 + print(f' {d}: {total:.1f} GB total') +" + +echo "" +echo "Starting SWOPP3 run at $(date)" +echo "" + +python scripts/swopp3_run.py \ + --wind-path-atlantic "${DATA_0125}/era5_wind_atlantic_2024.nc" \ + --wave-path-atlantic "${DATA_0125}/era5_waves_atlantic_2024.nc" \ + --wind-path-pacific "${DATA_0125}/era5_wind_pacific_2024.nc" \ + --wave-path-pacific "${DATA_0125}/era5_waves_pacific_2024.nc" \ + --output-dir "$OUTDIR" + +echo "" +echo "======================================" +echo "SWOPP3 GPU run completed at $(date)" +echo "======================================" +echo "" +echo "Output files:" +ls -lh "$OUTDIR"/*.csv 2>/dev/null || echo "(no CSV files found)" diff --git a/scripts/swopp3_slurm_hard_penalty.sh b/scripts/swopp3_slurm_hard_penalty.sh new file mode 100755 index 00000000..9e296428 --- /dev/null +++ b/scripts/swopp3_slurm_hard_penalty.sh @@ -0,0 +1,103 @@ +#!/bin/bash +#SBATCH --job-name=swopp3_hard +#SBATCH --partition=cpu +#SBATCH --nodes=1 +#SBATCH --cpus-per-task=64 +#SBATCH --mem=128G +#SBATCH --time=2-00:00:00 +#SBATCH --output=slurm_hard_%j.out +#SBATCH --error=slurm_hard_%j.err + +# ── SWOPP3 full run: hard (step) weather penalty ── +# +# Uses the combined --weather-penalty-weight which counts boolean +# violations (TWS > limit or Hs > limit) per segment and multiplies +# by the weight. No smooth ramp — pure count × weight. +# +# Submit: sbatch scripts/swopp3_slurm_hard_penalty.sh +# Monitor: squeue -u $USER + +set -euo pipefail + +# ── Environment ── +export PATH="$HOME/.local/bin:$PATH" +cd "$HOME/routetools" +source .venv/bin/activate + +export JAX_PLATFORMS=cpu +export XLA_FLAGS="${XLA_FLAGS:+$XLA_FLAGS }--xla_cpu_multi_thread_eigen=true --xla_force_host_platform_device_count=${SLURM_CPUS_PER_TASK}" + +# ── Paths ── +DATA="/scratch/fjsuarez/routetools/data/era5" +OUTDIR="output/swopp3_hard_penalty" +mkdir -p "$OUTDIR" + +# ── Penalty configuration ── +# Hard penalty: count of violating segments × weight +# With ~177 segments (Atlantic) or ~292 (Pacific), a single violation +# adds WEATHER_PW to the cost. Energy is ~100-300 MWh, so weight=10 +# means each violating segment adds 10 to cost. +WEATHER_PW=10.0 +DIST_PW=10.0 +DT_EVAL=30 # Δt₂ = 30 min evaluation grid + +echo "======================================" +echo "SWOPP3 hard-penalty run on $(hostname)" +echo "Date: $(date)" +echo "CPUs: ${SLURM_CPUS_PER_TASK}" +echo "JAX: CPU mode" +echo "Data: ${DATA}" +echo "Output: ${OUTDIR}" +echo "Weather PW: ${WEATHER_PW} (hard count)" +echo "Dist PW: ${DIST_PW}" +echo "dt_eval: ${DT_EVAL} min" +echo "======================================" + +# Verify data +for f in \ + "${DATA}/era5_wind_atlantic_2024.nc" \ + "${DATA}/era5_waves_atlantic_2024.nc" \ + "${DATA}/era5_wind_pacific_2024.nc" \ + "${DATA}/era5_waves_pacific_2024.nc"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All data files present." + +python -c "import jax; print(f'JAX {jax.__version__}, devices: {jax.devices()}')" + +# ── Atlantic cases (354 h → n_points=178 for Δt₁=2 h) ── +echo "" +echo "=== Atlantic cases (n_points=178) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases AO_WPS --cases AO_noWPS --cases AGC_WPS --cases AGC_noWPS \ + --wind-path-atlantic "${DATA}/era5_wind_atlantic_2024.nc" \ + --wave-path-atlantic "${DATA}/era5_waves_atlantic_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 178 \ + --dt-eval-minutes "$DT_EVAL" \ + --weather-penalty-weight "$WEATHER_PW" \ + --distance-penalty-weight "$DIST_PW" + +# ── Pacific cases (583 h → n_points=293 for Δt₁=2 h) ── +echo "" +echo "=== Pacific cases (n_points=293) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases PO_WPS --cases PO_noWPS --cases PGC_WPS --cases PGC_noWPS \ + --wind-path-pacific "${DATA}/era5_wind_pacific_2024.nc" \ + --wave-path-pacific "${DATA}/era5_waves_pacific_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 293 \ + --dt-eval-minutes "$DT_EVAL" \ + --weather-penalty-weight "$WEATHER_PW" \ + --distance-penalty-weight "$DIST_PW" + +echo "" +echo "=== Hard-penalty run complete: $(date) ===" +echo "" diff --git a/scripts/swopp3_slurm_k15_popsize400.sh b/scripts/swopp3_slurm_k15_popsize400.sh new file mode 100755 index 00000000..d31dadc6 --- /dev/null +++ b/scripts/swopp3_slurm_k15_popsize400.sh @@ -0,0 +1,103 @@ +#!/bin/bash +#SBATCH --job-name=swopp3_k15p400 +#SBATCH --partition=cpu +#SBATCH --nodes=1 +#SBATCH --cpus-per-task=64 +#SBATCH --mem=128G +#SBATCH --time=2-00:00:00 +#SBATCH --output=slurm_k15p400_%A_%a.out +#SBATCH --error=slurm_k15p400_%A_%a.err +#SBATCH --array=0-3 + +# ── SWOPP3 "kitchen sink": K=15, popsize=400, w=1000 ── +# +# 4 array tasks, one per optimised case (GC cases skipped — deterministic): +# 0=AO_WPS 1=AO_noWPS (Atlantic, n=178) +# 2=PO_WPS 3=PO_noWPS (Pacific, n=293) +# +# Submit: sbatch scripts/swopp3_slurm_k15_popsize400.sh +# Monitor: squeue -u $USER + +set -euo pipefail + +# ── Environment ── +export PATH="$HOME/.local/bin:$PATH" +cd "$HOME/routetools" +source .venv/bin/activate + +export JAX_PLATFORMS=cpu +export XLA_FLAGS="${XLA_FLAGS:+$XLA_FLAGS }--xla_cpu_multi_thread_eigen=true --xla_force_host_platform_device_count=${SLURM_CPUS_PER_TASK}" + +# ── Parameters ── +K=15 +POPSIZE=400 +WIND_PW=1000.0 +WAVE_PW=1000.0 +DIST_PW=10.0 +MAXFEVALS=50000 +DT_EVAL=30 + +DATA="/scratch/fjsuarez/routetools/data/era5" +OUTDIR="output/swopp3_k15_p400_w1000" +mkdir -p "$OUTDIR" + +# ── Per-task configuration ── +CASES=(AO_WPS AO_noWPS PO_WPS PO_noWPS) +NPOINTS=(178 178 293 293) +# Atlantic tasks (0-1) use atlantic data, Pacific tasks (2-3) use pacific data +OCEAN=() +for i in 0 1; do OCEAN[$i]="atlantic"; done +for i in 2 3; do OCEAN[$i]="pacific"; done + +CASE="${CASES[$SLURM_ARRAY_TASK_ID]}" +NP="${NPOINTS[$SLURM_ARRAY_TASK_ID]}" +OCN="${OCEAN[$SLURM_ARRAY_TASK_ID]}" +WIND_PATH="${DATA}/era5_wind_${OCN}_2024.nc" +WAVE_PATH="${DATA}/era5_waves_${OCN}_2024.nc" + +echo "======================================" +echo "SWOPP3 kitchen sink: K=${K} pop=${POPSIZE} w=${WIND_PW}" +echo "Date: $(date)" +echo "Host: $(hostname)" +echo "Job: ${SLURM_ARRAY_JOB_ID}_${SLURM_ARRAY_TASK_ID}" +echo "CPUs: ${SLURM_CPUS_PER_TASK}" +echo "Case: ${CASE}" +echo "Ocean: ${OCN}" +echo "n_points: ${NP}" +echo "Output: ${OUTDIR}" +echo "======================================" + +# Verify data +for f in "$WIND_PATH" "$WAVE_PATH"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All data files present." + +python -c "import jax; print(f'JAX {jax.__version__}, devices: {jax.devices()}')" + +echo "" +echo "=== Running ${CASE} (n_points=${NP}) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases "$CASE" \ + --wind-path-${OCN} "$WIND_PATH" \ + --wave-path-${OCN} "$WAVE_PATH" \ + --output-dir "$OUTDIR" \ + --n-points "$NP" \ + --dt-eval-minutes "$DT_EVAL" \ + --cmaes-k "$K" \ + --popsize "$POPSIZE" \ + --maxfevals "$MAXFEVALS" \ + --strategy optimised \ + --cmaes-verbose \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" \ + --distance-penalty-weight "$DIST_PW" + +echo "" +echo "=== ${CASE} complete: $(date) ===" +echo "" diff --git a/scripts/swopp3_slurm_k15_sweep.sh b/scripts/swopp3_slurm_k15_sweep.sh new file mode 100755 index 00000000..e8cf1cc1 --- /dev/null +++ b/scripts/swopp3_slurm_k15_sweep.sh @@ -0,0 +1,112 @@ +#!/bin/bash +#SBATCH --job-name=swopp3_k15 +#SBATCH --partition=cpu +#SBATCH --nodes=1 +#SBATCH --cpus-per-task=64 +#SBATCH --mem=128G +#SBATCH --time=2-00:00:00 +#SBATCH --output=slurm_k15_%A_%a.out +#SBATCH --error=slurm_k15_%A_%a.err +#SBATCH --array=0-1 + +# ── SWOPP3 K=15 experiment (normalized mean penalties) ── +# +# Tests whether increasing K from 10 to 15 lets the optimizer better +# avoid weather with smooth penalties. Two weights: w200 and w500. +# +# Submit: sbatch scripts/swopp3_slurm_k15_sweep.sh +# Monitor: squeue -u $USER + +set -euo pipefail + +# ── Sweep configurations ── +CONFIGS=( + "200.0 200.0 10.0 k15_w200" + "500.0 500.0 10.0 k15_w500" +) + +CONFIG="${CONFIGS[$SLURM_ARRAY_TASK_ID]}" +read -r WIND_PW WAVE_PW DIST_PW LABEL <<< "$CONFIG" + +# ── Environment ── +export PATH="$HOME/.local/bin:$PATH" +cd "$HOME/routetools" +source .venv/bin/activate + +export JAX_PLATFORMS=cpu +export XLA_FLAGS="${XLA_FLAGS:+$XLA_FLAGS }--xla_cpu_multi_thread_eigen=true --xla_force_host_platform_device_count=${SLURM_CPUS_PER_TASK}" + +# ── Paths ── +DATA="/scratch/fjsuarez/routetools/data/era5" +OUTDIR="output/swopp3_${LABEL}" +mkdir -p "$OUTDIR" + +K=15 +DT_EVAL=30 # Δt₂ = 30 min evaluation grid + +echo "======================================" +echo "SWOPP3 K=${K} experiment: ${LABEL}" +echo "Date: $(date)" +echo "Host: $(hostname)" +echo "Job: ${SLURM_ARRAY_JOB_ID}_${SLURM_ARRAY_TASK_ID}" +echo "CPUs: ${SLURM_CPUS_PER_TASK}" +echo "Output: ${OUTDIR}" +echo "K: ${K}" +echo "Wind PW: ${WIND_PW}" +echo "Wave PW: ${WAVE_PW}" +echo "Dist PW: ${DIST_PW}" +echo "dt_eval: ${DT_EVAL} min" +echo "======================================" + +# Verify data +for f in \ + "${DATA}/era5_wind_atlantic_2024.nc" \ + "${DATA}/era5_waves_atlantic_2024.nc" \ + "${DATA}/era5_wind_pacific_2024.nc" \ + "${DATA}/era5_waves_pacific_2024.nc"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All data files present." + +python -c "import jax; print(f'JAX {jax.__version__}, devices: {jax.devices()}')" + +# ── Atlantic cases (354 h → n_points=178 for Δt₁=2 h) ── +echo "" +echo "=== Atlantic cases (n_points=178) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases AO_WPS --cases AO_noWPS --cases AGC_WPS --cases AGC_noWPS \ + --wind-path-atlantic "${DATA}/era5_wind_atlantic_2024.nc" \ + --wave-path-atlantic "${DATA}/era5_waves_atlantic_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 178 \ + --dt-eval-minutes "$DT_EVAL" \ + --cmaes-k "$K" \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" \ + --distance-penalty-weight "$DIST_PW" + +# ── Pacific cases (583 h → n_points=293 for Δt₁=2 h) ── +echo "" +echo "=== Pacific cases (n_points=293) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases PO_WPS --cases PO_noWPS --cases PGC_WPS --cases PGC_noWPS \ + --wind-path-pacific "${DATA}/era5_wind_pacific_2024.nc" \ + --wave-path-pacific "${DATA}/era5_waves_pacific_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 293 \ + --dt-eval-minutes "$DT_EVAL" \ + --cmaes-k "$K" \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" \ + --distance-penalty-weight "$DIST_PW" + +echo "" +echo "=== K=${K} ${LABEL} complete: $(date) ===" +echo "" diff --git a/scripts/swopp3_slurm_max_penalty_sweep.sh b/scripts/swopp3_slurm_max_penalty_sweep.sh new file mode 100755 index 00000000..2308e68c --- /dev/null +++ b/scripts/swopp3_slurm_max_penalty_sweep.sh @@ -0,0 +1,105 @@ +#!/bin/bash +#SBATCH --job-name=swopp3_maxsweep +#SBATCH --partition=cpu +#SBATCH --nodes=1 +#SBATCH --cpus-per-task=64 +#SBATCH --mem=128G +#SBATCH --time=2-00:00:00 +#SBATCH --output=slurm_maxsweep_%A_%a.out +#SBATCH --error=slurm_maxsweep_%A_%a.err +#SBATCH --array=0-19 + +# ── SWOPP3 penalty weight sweep (max-based penalties) ── +# +# 5 weights × 4 optimised cases = 20 array tasks, all parallel. +# GC cases skipped — deterministic, unaffected by penalty weights. +# +# Layout (task = weight_idx * 4 + case_idx): +# weight_idx: 0=w5 1=w10 2=w25 3=w50 4=w100 +# case_idx: 0=AO_WPS 1=AO_noWPS 2=PO_WPS 3=PO_noWPS +# +# Submit: sbatch scripts/swopp3_slurm_max_penalty_sweep.sh +# Monitor: squeue -u $USER + +set -euo pipefail + +# ── Per-axis configuration ── +WEIGHTS=(5.0 10.0 25.0 50.0 100.0) +WEIGHT_LABELS=(w5 w10 w25 w50 w100) + +CASES=(AO_WPS AO_noWPS PO_WPS PO_noWPS) +NPOINTS=(178 178 293 293) +OCEANS=(atlantic atlantic pacific pacific) + +# Decode task ID +WEIGHT_IDX=$(( SLURM_ARRAY_TASK_ID / 4 )) +CASE_IDX=$(( SLURM_ARRAY_TASK_ID % 4 )) + +WIND_PW="${WEIGHTS[$WEIGHT_IDX]}" +WAVE_PW="${WEIGHTS[$WEIGHT_IDX]}" +DIST_PW=10.0 +LABEL="${WEIGHT_LABELS[$WEIGHT_IDX]}" + +CASE="${CASES[$CASE_IDX]}" +NP="${NPOINTS[$CASE_IDX]}" +OCN="${OCEANS[$CASE_IDX]}" + +# ── Environment ── +export PATH="$HOME/.local/bin:$PATH" +cd "$HOME/routetools" +source .venv/bin/activate + +export JAX_PLATFORMS=cpu +export XLA_FLAGS="${XLA_FLAGS:+$XLA_FLAGS }--xla_cpu_multi_thread_eigen=true --xla_force_host_platform_device_count=${SLURM_CPUS_PER_TASK}" + +# ── Paths ── +DATA="/scratch/fjsuarez/routetools/data/era5" +OUTDIR="output/swopp3_max_sweep_${LABEL}" +mkdir -p "$OUTDIR" + +WIND_PATH="${DATA}/era5_wind_${OCN}_2024.nc" +WAVE_PATH="${DATA}/era5_waves_${OCN}_2024.nc" + +echo "======================================" +echo "SWOPP3 max-penalty sweep: ${LABEL} / ${CASE}" +echo "Date: $(date)" +echo "Host: $(hostname)" +echo "Job: ${SLURM_ARRAY_JOB_ID}_${SLURM_ARRAY_TASK_ID}" +echo "CPUs: ${SLURM_CPUS_PER_TASK}" +echo "Weight: ${WIND_PW}" +echo "Case: ${CASE}" +echo "Ocean: ${OCN}" +echo "n_points: ${NP}" +echo "Output: ${OUTDIR}" +echo "======================================" + +# Verify data +for f in "$WIND_PATH" "$WAVE_PATH"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All data files present." + +python -c "import jax; print(f'JAX {jax.__version__}, devices: {jax.devices()}')" + +echo "" +echo "=== Running ${CASE} (n_points=${NP}, w=${WIND_PW}) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases "$CASE" \ + --wind-path-${OCN} "$WIND_PATH" \ + --wave-path-${OCN} "$WAVE_PATH" \ + --output-dir "$OUTDIR" \ + --n-points "$NP" \ + --dt-eval-minutes 30 \ + --strategy optimised \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" \ + --distance-penalty-weight "$DIST_PW" + +echo "" +echo "=== ${CASE} w=${WIND_PW} complete: $(date) ===" +echo "" diff --git a/scripts/swopp3_slurm_no_penalty.sh b/scripts/swopp3_slurm_no_penalty.sh new file mode 100755 index 00000000..48dc62b5 --- /dev/null +++ b/scripts/swopp3_slurm_no_penalty.sh @@ -0,0 +1,98 @@ +#!/bin/bash +#SBATCH --job-name=swopp3_nopen +#SBATCH --partition=cpu +#SBATCH --nodes=1 +#SBATCH --cpus-per-task=64 +#SBATCH --mem=128G +#SBATCH --time=2-00:00:00 +#SBATCH --output=slurm_nopen_%j.out +#SBATCH --error=slurm_nopen_%j.err + +# ── SWOPP3 full run: no weather penalization (Δt₁ = 2 h) ── +# +# Baseline run with all weather penalty weights at 0. The optimizer is +# free to route through severe weather if it reduces energy consumption. +# n-points per corridor: Atlantic=178 (354h/2h+1), Pacific=293 (583h/2h+1). +# +# Submit: sbatch scripts/swopp3_slurm_no_penalty.sh +# Monitor: squeue -u $USER + +set -euo pipefail + +# ── Environment ── +export PATH="$HOME/.local/bin:$PATH" +cd "$HOME/routetools" +source .venv/bin/activate + +export JAX_PLATFORMS=cpu +export XLA_FLAGS="${XLA_FLAGS:+$XLA_FLAGS }--xla_cpu_multi_thread_eigen=true --xla_force_host_platform_device_count=${SLURM_CPUS_PER_TASK}" + +# ── Paths ── +DATA="/scratch/fjsuarez/routetools/data/era5" +OUTDIR="output/swopp3_no_penalty" +DT_EVAL=30 # Δt₂ = 30 min evaluation grid +DIST_PW=10.0 # distance-to-land penalty (always active) +mkdir -p "$OUTDIR" + +echo "======================================" +echo "SWOPP3 no-penalty run on $(hostname)" +echo "Date: $(date)" +echo "CPUs: ${SLURM_CPUS_PER_TASK}" +echo "JAX: CPU mode" +echo "Data: ${DATA}" +echo "Output: ${OUTDIR}" +echo "Weather: ALL ZERO" +echo "Dist PW: ${DIST_PW}" +echo "dt_eval: ${DT_EVAL} min" +echo "======================================" + +# Verify data +for f in \ + "${DATA}/era5_wind_atlantic_2024.nc" \ + "${DATA}/era5_waves_atlantic_2024.nc" \ + "${DATA}/era5_wind_pacific_2024.nc" \ + "${DATA}/era5_waves_pacific_2024.nc"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All data files present." + +python -c "import jax; print(f'JAX {jax.__version__}, devices: {jax.devices()}')" + +# ── Atlantic cases (354 h → n_points=178 for Δt₁=2 h) ── +echo "" +echo "=== Atlantic cases (n_points=178) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases AO_WPS --cases AO_noWPS --cases AGC_WPS --cases AGC_noWPS \ + --wind-path-atlantic "${DATA}/era5_wind_atlantic_2024.nc" \ + --wave-path-atlantic "${DATA}/era5_waves_atlantic_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 178 \ + --dt-eval-minutes "$DT_EVAL" \ + --distance-penalty-weight "$DIST_PW" + +# ── Pacific cases (583 h → n_points=293 for Δt₁=2 h) ── +echo "" +echo "=== Pacific cases (n_points=293) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases PO_WPS --cases PO_noWPS --cases PGC_WPS --cases PGC_noWPS \ + --wind-path-pacific "${DATA}/era5_wind_pacific_2024.nc" \ + --wave-path-pacific "${DATA}/era5_waves_pacific_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 293 \ + --dt-eval-minutes "$DT_EVAL" \ + --distance-penalty-weight "$DIST_PW" + +echo "" +echo "======================================" +echo "SWOPP3 no-penalty run completed at $(date)" +echo "======================================" +echo "" +echo "Output files:" +ls -lh "$OUTDIR"/*.csv 2>/dev/null || echo "(no CSV files found)" diff --git a/scripts/swopp3_slurm_optimal_params.sh b/scripts/swopp3_slurm_optimal_params.sh new file mode 100755 index 00000000..11eb4f53 --- /dev/null +++ b/scripts/swopp3_slurm_optimal_params.sh @@ -0,0 +1,117 @@ +#!/bin/bash +#SBATCH --job-name=swopp3_optimal +#SBATCH --partition=cpu +#SBATCH --nodes=1 +#SBATCH --cpus-per-task=64 +#SBATCH --mem=128G +#SBATCH --time=2-00:00:00 +#SBATCH --output=slurm_optimal_%j.out +#SBATCH --error=slurm_optimal_%j.err + +# ── SWOPP3 full run: sweep-optimal parameters (K=15, σ₀=0.3) ── +# +# Uses the parameter sweep recommendations from docs/swopp3_sweep_results.md: +# K=15 — more Bézier control points for route flexibility +# sigma0=0.3 — larger initial CMA-ES step for better exploration +# wind/wave penalty weight=10 — moderate penalty (sweep-optimal) +# distance penalty weight=10 — EDT proximity penalty +# +# Submit: sbatch scripts/swopp3_slurm_optimal_params.sh +# Monitor: squeue -u $USER + +set -euo pipefail + +# ── Environment ── +export PATH="$HOME/.local/bin:$PATH" +cd "$HOME/routetools" +source .venv/bin/activate + +export JAX_PLATFORMS=cpu +export XLA_FLAGS="${XLA_FLAGS:+$XLA_FLAGS }--xla_cpu_multi_thread_eigen=true --xla_force_host_platform_device_count=${SLURM_CPUS_PER_TASK}" + +# ── Paths ── +DATA="/scratch/fjsuarez/routetools/data/era5" +OUTDIR="output/swopp3_optimal_params" +mkdir -p "$OUTDIR" + +# ── Sweep-optimal configuration ── +CMAES_K=15 +SIGMA0=0.3 +WIND_PW=10.0 +WAVE_PW=10.0 +DIST_PW=10.0 +DT_EVAL=30 # Δt₂ = 30 min evaluation grid + +echo "======================================" +echo "SWOPP3 optimal-params run on $(hostname)" +echo "Date: $(date)" +echo "CPUs: ${SLURM_CPUS_PER_TASK}" +echo "JAX: CPU mode" +echo "Data: ${DATA}" +echo "Output: ${OUTDIR}" +echo "K: ${CMAES_K}" +echo "sigma0: ${SIGMA0}" +echo "Wind PW: ${WIND_PW}" +echo "Wave PW: ${WAVE_PW}" +echo "Dist PW: ${DIST_PW}" +echo "dt_eval: ${DT_EVAL} min" +echo "======================================" + +# Verify data +for f in \ + "${DATA}/era5_wind_atlantic_2024.nc" \ + "${DATA}/era5_waves_atlantic_2024.nc" \ + "${DATA}/era5_wind_pacific_2024.nc" \ + "${DATA}/era5_waves_pacific_2024.nc"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All data files present." + +python -c "import jax; print(f'JAX {jax.__version__}, devices: {jax.devices()}')" + +# ── Atlantic cases (354 h → n_points=178 for Δt₁=2 h) ── +echo "" +echo "=== Atlantic cases (n_points=178) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases AO_WPS --cases AO_noWPS --cases AGC_WPS --cases AGC_noWPS \ + --wind-path-atlantic "${DATA}/era5_wind_atlantic_2024.nc" \ + --wave-path-atlantic "${DATA}/era5_waves_atlantic_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 178 \ + --dt-eval-minutes "$DT_EVAL" \ + --cmaes-k "$CMAES_K" \ + --sigma0 "$SIGMA0" \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" \ + --distance-penalty-weight "$DIST_PW" + +# ── Pacific cases (583 h → n_points=293 for Δt₁=2 h) ── +echo "" +echo "=== Pacific cases (n_points=293) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases PO_WPS --cases PO_noWPS --cases PGC_WPS --cases PGC_noWPS \ + --wind-path-pacific "${DATA}/era5_wind_pacific_2024.nc" \ + --wave-path-pacific "${DATA}/era5_waves_pacific_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 293 \ + --dt-eval-minutes "$DT_EVAL" \ + --cmaes-k "$CMAES_K" \ + --sigma0 "$SIGMA0" \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" \ + --distance-penalty-weight "$DIST_PW" + +echo "" +echo "======================================" +echo "SWOPP3 optimal-params run completed at $(date)" +echo "======================================" +echo "" +echo "Output files:" +ls -lh "$OUTDIR"/*.csv 2>/dev/null || echo "(no CSV files found)" diff --git a/scripts/swopp3_slurm_pacific_k15_p400.sh b/scripts/swopp3_slurm_pacific_k15_p400.sh new file mode 100755 index 00000000..260a257a --- /dev/null +++ b/scripts/swopp3_slurm_pacific_k15_p400.sh @@ -0,0 +1,103 @@ +#!/bin/bash +#SBATCH --job-name=swopp3_pac_k15 +#SBATCH --partition=cpu +#SBATCH --nodes=1 +#SBATCH --cpus-per-task=64 +#SBATCH --mem=128G +#SBATCH --time=12:00:00 +#SBATCH --output=slurm_pac_k15_%j.out +#SBATCH --error=slurm_pac_k15_%j.err + +# ── SWOPP3 Pacific-only run: K=15, popsize=400, maxfevals=50000 ── +# +# Experiment to confirm w=50, v=200 is the best penalty config for +# Pacific, using double the CMA-ES budget (50k fevals, popsize=400). +# +# Submit: sbatch scripts/swopp3_slurm_pacific_k15_p400.sh +# Monitor: squeue -u $USER + +set -euo pipefail + +# ── Parameters ── +K=15 +SIGMA0=0.3 +POPSIZE=400 +MAXFEVALS=50000 +WIND_PW=50.0 +WAVE_PW=200.0 +DIST_PW=10.0 +DT_EVAL=30 # Δt₂ = 30 min evaluation grid + +LABEL="pacific_k15_p400_w50_v200" + +# ── Environment ── +ROOTDIR="/home/fjsuarez/routetools" +export PATH="/home/fjsuarez/.local/bin:$PATH" +cd "$ROOTDIR" +source .venv/bin/activate + +export JAX_PLATFORMS=cpu +export XLA_FLAGS="${XLA_FLAGS:+$XLA_FLAGS }--xla_cpu_multi_thread_eigen=true --xla_force_host_platform_device_count=${SLURM_CPUS_PER_TASK}" + +# ── Paths ── +DATA="/data/fjsuarez/era5" +OUTDIR="output/swopp3_${LABEL}" +mkdir -p "$OUTDIR" + +echo "======================================" +echo "SWOPP3 Pacific K=15 p=400 experiment" +echo "Date: $(date)" +echo "Host: $(hostname)" +echo "Job: ${SLURM_JOB_ID}" +echo "CPUs: ${SLURM_CPUS_PER_TASK}" +echo "Output: ${OUTDIR}" +echo "K: ${K}" +echo "σ₀: ${SIGMA0}" +echo "popsize: ${POPSIZE}" +echo "maxfevals: ${MAXFEVALS}" +echo "Wind PW: ${WIND_PW}" +echo "Wave PW: ${WAVE_PW}" +echo "Dist PW: ${DIST_PW}" +echo "dt_eval: ${DT_EVAL} min" +echo "======================================" + +# Verify data +for f in \ + "${DATA}/era5_wind_pacific_2024.nc" \ + "${DATA}/era5_waves_pacific_2024.nc"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All data files present." + +python -c "import jax; print(f'JAX {jax.__version__}, devices: {jax.devices()}')" + +# ── Pacific cases (583 h → n_points=293 for Δt₁=2 h) ── +echo "" +echo "=== Pacific cases (n_points=293) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases PO_WPS --cases PO_noWPS --cases PGC_WPS --cases PGC_noWPS \ + --wind-path-pacific "${DATA}/era5_wind_pacific_2024.nc" \ + --wave-path-pacific "${DATA}/era5_waves_pacific_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 293 \ + --dt-eval-minutes "$DT_EVAL" \ + --cmaes-k "$K" \ + --sigma0 "$SIGMA0" \ + --popsize "$POPSIZE" \ + --maxfevals "$MAXFEVALS" \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" \ + --distance-penalty-weight "$DIST_PW" + +echo "" +echo "======================================" +echo "SWOPP3 ${LABEL} complete: $(date)" +echo "======================================" +echo "" +echo "Output files:" +ls -lh "$OUTDIR"/*.csv 2>/dev/null || echo "(no CSV files found)" diff --git a/scripts/swopp3_slurm_penalty_sweep.sh b/scripts/swopp3_slurm_penalty_sweep.sh new file mode 100755 index 00000000..a5947795 --- /dev/null +++ b/scripts/swopp3_slurm_penalty_sweep.sh @@ -0,0 +1,111 @@ +#!/bin/bash +#SBATCH --job-name=swopp3_sweep +#SBATCH --partition=cpu +#SBATCH --nodes=1 +#SBATCH --cpus-per-task=64 +#SBATCH --mem=128G +#SBATCH --time=2-00:00:00 +#SBATCH --output=slurm_sweep_%A_%a.out +#SBATCH --error=slurm_sweep_%A_%a.err +#SBATCH --array=0-3 + +# ── SWOPP3 penalty weight sweep (normalized mean penalties) ── +# +# Runs 4 configurations in a SLURM array to find optimal penalty weights. +# All use K=10, σ₀=0.1 (baseline CMA-ES params). +# +# Submit: sbatch scripts/swopp3_slurm_penalty_sweep.sh +# Monitor: squeue -u $USER + +set -euo pipefail + +# ── Sweep configurations ── +# Format: "wind_pw wave_pw dist_pw label" +CONFIGS=( + "50.0 50.0 10.0 w50" + "100.0 100.0 10.0 w100" + "200.0 200.0 10.0 w200" + "500.0 500.0 10.0 w500" +) + +CONFIG="${CONFIGS[$SLURM_ARRAY_TASK_ID]}" +read -r WIND_PW WAVE_PW DIST_PW LABEL <<< "$CONFIG" + +# ── Environment ── +export PATH="$HOME/.local/bin:$PATH" +cd "$HOME/routetools" +source .venv/bin/activate + +export JAX_PLATFORMS=cpu +export XLA_FLAGS="${XLA_FLAGS:+$XLA_FLAGS }--xla_cpu_multi_thread_eigen=true --xla_force_host_platform_device_count=${SLURM_CPUS_PER_TASK}" + +# ── Paths ── +DATA="/scratch/fjsuarez/routetools/data/era5" +OUTDIR="output/swopp3_sweep_${LABEL}" +mkdir -p "$OUTDIR" + +DT_EVAL=30 # Δt₂ = 30 min evaluation grid + +echo "======================================" +echo "SWOPP3 penalty sweep: ${LABEL}" +echo "Date: $(date)" +echo "Host: $(hostname)" +echo "Job: ${SLURM_ARRAY_JOB_ID}_${SLURM_ARRAY_TASK_ID}" +echo "CPUs: ${SLURM_CPUS_PER_TASK}" +echo "Output: ${OUTDIR}" +echo "Wind PW: ${WIND_PW}" +echo "Wave PW: ${WAVE_PW}" +echo "Dist PW: ${DIST_PW}" +echo "dt_eval: ${DT_EVAL} min" +echo "======================================" + +# Verify data +for f in \ + "${DATA}/era5_wind_atlantic_2024.nc" \ + "${DATA}/era5_waves_atlantic_2024.nc" \ + "${DATA}/era5_wind_pacific_2024.nc" \ + "${DATA}/era5_waves_pacific_2024.nc"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All data files present." + +python -c "import jax; print(f'JAX {jax.__version__}, devices: {jax.devices()}')" + +# ── Atlantic cases (354 h → n_points=178 for Δt₁=2 h) ── +echo "" +echo "=== Atlantic cases (n_points=178) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases AO_WPS --cases AO_noWPS --cases AGC_WPS --cases AGC_noWPS \ + --wind-path-atlantic "${DATA}/era5_wind_atlantic_2024.nc" \ + --wave-path-atlantic "${DATA}/era5_waves_atlantic_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 178 \ + --dt-eval-minutes "$DT_EVAL" \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" \ + --distance-penalty-weight "$DIST_PW" + +# ── Pacific cases (583 h → n_points=293 for Δt₁=2 h) ── +echo "" +echo "=== Pacific cases (n_points=293) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases PO_WPS --cases PO_noWPS --cases PGC_WPS --cases PGC_noWPS \ + --wind-path-pacific "${DATA}/era5_wind_pacific_2024.nc" \ + --wave-path-pacific "${DATA}/era5_waves_pacific_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 293 \ + --dt-eval-minutes "$DT_EVAL" \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" \ + --distance-penalty-weight "$DIST_PW" + +echo "" +echo "=== Sweep ${LABEL} complete: $(date) ===" +echo "" diff --git a/scripts/swopp3_slurm_split_penalty.sh b/scripts/swopp3_slurm_split_penalty.sh new file mode 100755 index 00000000..563318de --- /dev/null +++ b/scripts/swopp3_slurm_split_penalty.sh @@ -0,0 +1,107 @@ +#!/bin/bash +#SBATCH --job-name=swopp3_split +#SBATCH --partition=cpu +#SBATCH --nodes=1 +#SBATCH --cpus-per-task=64 +#SBATCH --mem=128G +#SBATCH --time=2-00:00:00 +#SBATCH --output=slurm_split_%j.out +#SBATCH --error=slurm_split_%j.err + +# ── SWOPP3 full run: split wind+wave penalties (Δt₁ = 2 h) ── +# +# Uses separate --wind-penalty-weight and --wave-penalty-weight instead +# of the old combined --weather-penalty-weight. +# n-points per corridor: Atlantic=178 (354h/2h+1), Pacific=293 (583h/2h+1). +# +# Submit: sbatch scripts/swopp3_slurm_split_penalty.sh +# Monitor: squeue -u $USER + +set -euo pipefail + +# ── Environment ── +export PATH="$HOME/.local/bin:$PATH" +cd "$HOME/routetools" +source .venv/bin/activate + +export JAX_PLATFORMS=cpu +export XLA_FLAGS="${XLA_FLAGS:+$XLA_FLAGS }--xla_cpu_multi_thread_eigen=true --xla_force_host_platform_device_count=${SLURM_CPUS_PER_TASK}" + +# ── Paths ── +DATA="/scratch/fjsuarez/routetools/data/era5" +OUTDIR="output/swopp3_split_penalty" +mkdir -p "$OUTDIR" + +# ── Penalty configuration ── +WIND_PW=100.0 +WAVE_PW=100.0 +DIST_PW=10.0 +DT_EVAL=30 # Δt₂ = 30 min evaluation grid + +echo "======================================" +echo "SWOPP3 split-penalty run on $(hostname)" +echo "Date: $(date)" +echo "CPUs: ${SLURM_CPUS_PER_TASK}" +echo "JAX: CPU mode" +echo "Data: ${DATA}" +echo "Output: ${OUTDIR}" +echo "Wind PW: ${WIND_PW}" +echo "Wave PW: ${WAVE_PW}" +echo "Dist PW: ${DIST_PW}" +echo "dt_eval: ${DT_EVAL} min" +echo "======================================" + +# Verify data +for f in \ + "${DATA}/era5_wind_atlantic_2024.nc" \ + "${DATA}/era5_waves_atlantic_2024.nc" \ + "${DATA}/era5_wind_pacific_2024.nc" \ + "${DATA}/era5_waves_pacific_2024.nc"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All data files present." + +python -c "import jax; print(f'JAX {jax.__version__}, devices: {jax.devices()}')" + +# ── Atlantic cases (354 h → n_points=178 for Δt₁=2 h) ── +echo "" +echo "=== Atlantic cases (n_points=178) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases AO_WPS --cases AO_noWPS --cases AGC_WPS --cases AGC_noWPS \ + --wind-path-atlantic "${DATA}/era5_wind_atlantic_2024.nc" \ + --wave-path-atlantic "${DATA}/era5_waves_atlantic_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 178 \ + --dt-eval-minutes "$DT_EVAL" \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" \ + --distance-penalty-weight "$DIST_PW" + +# ── Pacific cases (583 h → n_points=293 for Δt₁=2 h) ── +echo "" +echo "=== Pacific cases (n_points=293) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases PO_WPS --cases PO_noWPS --cases PGC_WPS --cases PGC_noWPS \ + --wind-path-pacific "${DATA}/era5_wind_pacific_2024.nc" \ + --wave-path-pacific "${DATA}/era5_waves_pacific_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 293 \ + --dt-eval-minutes "$DT_EVAL" \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" \ + --distance-penalty-weight "$DIST_PW" + +echo "" +echo "======================================" +echo "SWOPP3 split-penalty run completed at $(date)" +echo "======================================" +echo "" +echo "Output files:" +ls -lh "$OUTDIR"/*.csv 2>/dev/null || echo "(no CSV files found)" diff --git a/scripts/swopp3_slurm_wind_wave_sweep.sh b/scripts/swopp3_slurm_wind_wave_sweep.sh new file mode 100755 index 00000000..ce41c347 --- /dev/null +++ b/scripts/swopp3_slurm_wind_wave_sweep.sh @@ -0,0 +1,133 @@ +#!/bin/bash +#SBATCH --job-name=swopp3_ww +#SBATCH --partition=cpu +#SBATCH --nodes=1 +#SBATCH --cpus-per-task=64 +#SBATCH --mem=128G +#SBATCH --time=2-00:00:00 +#SBATCH --output=slurm_ww_%A_%a.out +#SBATCH --error=slurm_ww_%A_%a.err +#SBATCH --array=0-35 + +# ── SWOPP3 2-D wind × wave penalty weight sweep ── +# +# Explores a 6×6 grid of (wind_penalty_weight, wave_penalty_weight) with +# fixed CMA-ES parameters (K=15, σ₀=0.3, popsize=200, dt_eval=30 min). +# Each SLURM array task handles one (wind, wave) combination and runs +# both Atlantic and Pacific corridors. +# +# Grid: wind_pw ∈ {0, 50, 100, 200, 500, 1000} +# wave_pw ∈ {0, 50, 100, 200, 500, 1000} +# = 36 combinations (array 0-35) +# +# Submit: sbatch scripts/swopp3_slurm_wind_wave_sweep.sh +# Monitor: squeue -u $USER +# Cancel: scancel + +set -euo pipefail + +# ── 2-D grid definition ── +WIND_PWS=(0.0 50.0 100.0 200.0 500.0 1000.0) +WAVE_PWS=(0.0 50.0 100.0 200.0 500.0 1000.0) + +N_WAVE=${#WAVE_PWS[@]} +WIND_IDX=$(( SLURM_ARRAY_TASK_ID / N_WAVE )) +WAVE_IDX=$(( SLURM_ARRAY_TASK_ID % N_WAVE )) + +WIND_PW=${WIND_PWS[$WIND_IDX]} +WAVE_PW=${WAVE_PWS[$WAVE_IDX]} +LABEL="ww_w${WIND_PW%.*}_v${WAVE_PW%.*}" + +# ── Fixed CMA-ES parameters ── +K=15 +SIGMA0=0.3 +DIST_PW=10.0 +DT_EVAL=30 # Δt₂ = 30 min evaluation grid + +# ── Environment ── +ROOTDIR="/home/fjsuarez/routetools" +export PATH="/home/fjsuarez/.local/bin:$PATH" +cd "$ROOTDIR" +source .venv/bin/activate + +export JAX_PLATFORMS=cpu +export XLA_FLAGS="${XLA_FLAGS:+$XLA_FLAGS }--xla_cpu_multi_thread_eigen=true --xla_force_host_platform_device_count=${SLURM_CPUS_PER_TASK}" + +# ── Paths ── +DATA="/data/fjsuarez/era5" +OUTDIR="output/swopp3_${LABEL}" +mkdir -p "$OUTDIR" + +echo "======================================" +echo "SWOPP3 wind×wave sweep: ${LABEL}" +echo "Date: $(date)" +echo "Host: $(hostname)" +echo "Job: ${SLURM_ARRAY_JOB_ID}_${SLURM_ARRAY_TASK_ID}" +echo "CPUs: ${SLURM_CPUS_PER_TASK}" +echo "Output: ${OUTDIR}" +echo "K: ${K}" +echo "σ₀: ${SIGMA0}" +echo "Wind PW: ${WIND_PW}" +echo "Wave PW: ${WAVE_PW}" +echo "Dist PW: ${DIST_PW}" +echo "dt_eval: ${DT_EVAL} min" +echo "======================================" + +# Verify data +for f in \ + "${DATA}/era5_wind_atlantic_2024.nc" \ + "${DATA}/era5_waves_atlantic_2024.nc" \ + "${DATA}/era5_wind_pacific_2024.nc" \ + "${DATA}/era5_waves_pacific_2024.nc"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: Missing data file: $f" >&2 + exit 1 + fi +done +echo "All data files present." + +python -c "import jax; print(f'JAX {jax.__version__}, devices: {jax.devices()}')" + +# ── Atlantic cases (354 h → n_points=178 for Δt₁=2 h) ── +echo "" +echo "=== Atlantic cases (n_points=178) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases AO_WPS --cases AO_noWPS --cases AGC_WPS --cases AGC_noWPS \ + --wind-path-atlantic "${DATA}/era5_wind_atlantic_2024.nc" \ + --wave-path-atlantic "${DATA}/era5_waves_atlantic_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 178 \ + --dt-eval-minutes "$DT_EVAL" \ + --cmaes-k "$K" \ + --sigma0 "$SIGMA0" \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" \ + --distance-penalty-weight "$DIST_PW" + +# ── Pacific cases (583 h → n_points=293 for Δt₁=2 h) ── +echo "" +echo "=== Pacific cases (n_points=293) ===" +echo "" + +python scripts/swopp3_run.py \ + --cases PO_WPS --cases PO_noWPS --cases PGC_WPS --cases PGC_noWPS \ + --wind-path-pacific "${DATA}/era5_wind_pacific_2024.nc" \ + --wave-path-pacific "${DATA}/era5_waves_pacific_2024.nc" \ + --output-dir "$OUTDIR" \ + --n-points 293 \ + --dt-eval-minutes "$DT_EVAL" \ + --cmaes-k "$K" \ + --sigma0 "$SIGMA0" \ + --wind-penalty-weight "$WIND_PW" \ + --wave-penalty-weight "$WAVE_PW" \ + --distance-penalty-weight "$DIST_PW" + +echo "" +echo "======================================" +echo "SWOPP3 ${LABEL} complete: $(date)" +echo "======================================" +echo "" +echo "Output files:" +ls -lh "$OUTDIR"/*.csv 2>/dev/null || echo "(no CSV files found)" diff --git a/scripts/swopp_demo.py b/scripts/swopp_demo.py new file mode 100644 index 00000000..b3d822b2 --- /dev/null +++ b/scripts/swopp_demo.py @@ -0,0 +1,108 @@ +import jax.numpy as jnp +import matplotlib.pyplot as plt + +from routetools.benchmark import load_benchmark_instance, optimize_benchmark_instance +from routetools.cost import haversine_distance_from_curve +from routetools.fms import optimize_fms +from routetools.plot import plot_curve + + +def main( + instance_name: str = "ESSDR-USNYS", + date_start: str = "2024-01-01", + vel_ship: int = 4, # 8 knots + data_path: str = "./data", + penalty: float = 1e10, + K: int = 10, + L: int = 320, + num_pieces: int = 3, + popsize: int = 500, + sigma0: int = 1, + keep_top: float = 0.002, + tolfun_cmaes: float = 60, + damping_cmaes: float = 1, + maxfevals_cmaes: int = int(1e8), + patience_fms: int = 100, + damping_fms: float = 0.9, + maxfevals_fms: int = int(1e6), + seed: int = 42, + verbose: bool = True, +): + """Test the benchmark.""" + # Extract relevant information from the problem instance + dict_instance = load_benchmark_instance( + instance_name, + date_start=date_start, + vel_ship=vel_ship, + data_path=data_path, + ) + + curve_cmaes, dict_cmaes = optimize_benchmark_instance( + dict_instance, + penalty=penalty, + K=K, + L=L, # One segment per hour approx + num_pieces=num_pieces, + popsize=popsize, + sigma0=sigma0, + tolfun=tolfun_cmaes, + damping=damping_cmaes, + maxfevals=maxfevals_cmaes, + init_circumnavigate=True, + keep_top=keep_top, + seed=seed, + verbose=verbose, + ) + print("CMA-ES optimization details:", dict_cmaes) + + curve_fms, dict_fms = optimize_fms( + vectorfield=dict_instance["vectorfield"], + curve=curve_cmaes, + land=dict_instance["land"], + travel_stw=dict_instance["travel_stw"], + travel_time=dict_instance["travel_time"], + patience=patience_fms, + damping=damping_fms, + maxfevals=maxfevals_fms, + weight_l1=1.0, + weight_l2=0.0, + spherical_correction=True, + seed=seed, + verbose=verbose, + ) + + print("FMS optimization details:", dict_fms) + + # Compute distances + dist_cmaes = jnp.sum(haversine_distance_from_curve(curve_cmaes)) / 1000 + dist_fms = jnp.sum(haversine_distance_from_curve(curve_fms[0])) / 1000 + + # Plot + vectorfield = dict_instance["vectorfield"] + land = dict_instance["land"] + fig, ax = plot_curve( + vectorfield=vectorfield, + ls_curve=[curve_cmaes, curve_fms[0]], + ls_name=[ + f"CMA-ES ({dict_cmaes['cost'] / 3600:.0f} hrs, {dist_cmaes:.0f} km)", + f"FMS ({sum(dict_fms['cost']) / 3600:.0f} hrs, {dist_fms:.0f} km)", + ], + land=land, + gridstep=1 / 12, + figsize=(6, 6), + xlim=(land.xmin, land.xmax), + ylim=(land.ymin, land.ymax), + color_currents=True, + ) + # Include date and velocity in the title + ax.set_title( + f"{instance_name} | {dict_instance['date_start']} | {int(2 * vel_ship)} knots" + ) + fig.tight_layout() + # We use redundant naming to avoid too many images + fig.savefig(f"output/benchmark_{instance_name}_{int(vel_ship)}.jpg", dpi=300) + plt.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_routes.py b/scripts/validate_routes.py new file mode 100755 index 00000000..9fc65e83 --- /dev/null +++ b/scripts/validate_routes.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python +"""Validate SWOPP3 route submissions for land intersection. + +Checks every segment of every track file for land crossings using +Natural Earth 1:10m polygons via Shapely geometric intersection. +This is more precise than the rasterized Land mask used during +optimization, and can be applied to any team's submission. + +Usage +----- +Validate a single track file:: + + uv run scripts/validate_routes.py tracks/MyTeam-1-AO_WPS-20240101.csv + +Validate all tracks in a directory:: + + uv run scripts/validate_routes.py output/swopp3_ne_land/tracks/ + +Validate with a specific interpolation density (points per segment):: + + uv run scripts/validate_routes.py output/swopp3_ne_land/tracks/ --density 100 + +Exclude great-circle baselines (filenames containing ``GC``):: + + uv run scripts/validate_routes.py output/swopp3_ne_land/tracks/ --exclude-gc + +Print only per-file pass/fail (no per-segment details):: + + uv run scripts/validate_routes.py output/swopp3_ne_land/tracks/ --summary-only + +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from pathlib import Path + +import numpy as np + + +def _check_optional_deps() -> None: + """Check that cartopy and shapely are installed.""" + missing: list[str] = [] + try: + import cartopy.io.shapereader # noqa: F401 + except ImportError: + missing.append("cartopy") + try: + from shapely import geometry # noqa: F401 + except ImportError: + missing.append("shapely") + if missing: + pkgs = ", ".join(missing) + sys.stderr.write( + f"Error: optional dependency(ies) not installed: {pkgs}.\n" + "Install them with:\n" + " uv pip install cartopy shapely\n" + ) + sys.exit(1) + + +def _load_track(path: Path) -> np.ndarray: + """Load a track CSV and return an (N, 2) array of (lon, lat).""" + lons, lats = [], [] + with open(path, newline="") as f: + reader = csv.DictReader(f) + for row in reader: + lats.append(float(row["lat_deg"])) + lons.append(float(row["lon_deg"])) + return np.column_stack([lons, lats]) + + +def _build_land_geometries( + bounds: tuple[float, float, float, float] | None = None, +) -> list: + """Load Natural Earth 1:10m land polygons, clipped to bounds. + + Parameters + ---------- + bounds : (lon_min, lat_min, lon_max, lat_max), optional + If given, only geometries intersecting this box are returned. + + Returns + ------- + list of shapely geometries + """ + import cartopy.io.shapereader as shpreader + from shapely.geometry import box + from shapely.ops import unary_union + from shapely.validation import make_valid + + shp_path = shpreader.natural_earth( + resolution="10m", category="physical", name="land" + ) + reader = shpreader.Reader(shp_path) + + if bounds is not None: + clip = box(*bounds).buffer(1.0) + geoms = [] + for g in reader.geometries(): + if g is None or g.is_empty: + continue + g = make_valid(g) if not g.is_valid else g + inter = g.intersection(clip) + if not inter.is_empty: + geoms.append(inter) + return [unary_union(geoms)] if geoms else [] + else: + geoms = [] + for g in reader.geometries(): + if g is None or g.is_empty: + continue + g = make_valid(g) if not g.is_valid else g + geoms.append(g) + return [unary_union(geoms)] if geoms else [] + + +def _interpolate_segment(p1: np.ndarray, p2: np.ndarray, density: int) -> np.ndarray: + """Linearly interpolate between two points, returning (density+1, 2).""" + t = np.linspace(0, 1, density + 1)[:, None] + return p1 + t * (p2 - p1) + + +def validate_track( + waypoints: np.ndarray, + land_union, + density: int = 50, +) -> list[dict]: + """Check each segment for land intersection. + + Parameters + ---------- + waypoints : np.ndarray + Shape (N, 2) with (lon, lat). + land_union : shapely geometry + Union of all land polygons. + density : int + Number of sub-points per segment for intersection check. + + Returns + ------- + list of dict + One entry per violating segment: ``{segment, from_idx, to_idx, + from_coord, to_coord, land_points, land_fraction}``. + """ + from shapely.geometry import LineString, Point + from shapely.prepared import prep + + violations = [] + n = len(waypoints) + prepared_land = prep(land_union) + + for i in range(n - 1): + p1, p2 = waypoints[i], waypoints[i + 1] + segment_line = LineString([p1, p2]) + + if not prepared_land.intersects(segment_line): + continue + + # Count how many sub-points fall on land (including boundary) + sub_pts = _interpolate_segment(p1, p2, density) + on_land = sum(1 for pt in sub_pts if prepared_land.covers(Point(pt[0], pt[1]))) + fraction = on_land / len(sub_pts) + + violations.append( + { + "segment": i, + "from_idx": i, + "to_idx": i + 1, + "from_coord": (float(p1[0]), float(p1[1])), + "to_coord": (float(p2[0]), float(p2[1])), + "land_points": on_land, + "total_points": len(sub_pts), + "land_fraction": round(fraction, 4), + } + ) + + return violations + + +def validate_file( + track_path: Path, + land_union, + density: int = 50, +) -> list[dict]: + """Validate a single track file. Returns list of violations.""" + waypoints = _load_track(track_path) + return validate_track(waypoints, land_union, density=density) + + +def main() -> None: + """CLI entry-point for land-intersection validation.""" + parser = argparse.ArgumentParser( + description="Validate SWOPP3 routes for land intersection.", + ) + parser.add_argument( + "path", + type=Path, + help="Track CSV file or directory of track CSVs.", + ) + parser.add_argument( + "--density", + type=int, + default=50, + help="Sub-points per segment for intersection check (default: 50).", + ) + parser.add_argument( + "--summary-only", + action="store_true", + help="Only print per-file pass/fail, not individual segments.", + ) + parser.add_argument( + "--exclude-gc", + action="store_true", + help="Skip great-circle baseline files (names containing 'GC').", + ) + args = parser.parse_args() + + if args.density < 1: + print("Error: --density must be >= 1.", file=sys.stderr) + sys.exit(1) + + _check_optional_deps() + + # Collect track files + if args.path.is_dir(): + track_files = sorted(args.path.glob("*.csv")) + elif args.path.is_file(): + track_files = [args.path] + else: + print(f"Error: {args.path} not found.", file=sys.stderr) + sys.exit(1) + + if not track_files: + print("No CSV files found.", file=sys.stderr) + sys.exit(1) + + # Filter out great-circle baselines if requested + if args.exclude_gc: + before = len(track_files) + track_files = [f for f in track_files if "GC" not in f.stem] + excluded = before - len(track_files) + if excluded: + print(f"Excluded {excluded} great-circle file(s).") + if not track_files: + print("No files remaining after excluding GC baselines.") + sys.exit(0) + + # Compute a bounding box from all track files for efficient clipping + all_lons, all_lats = [], [] + for tf in track_files: + wp = _load_track(tf) + all_lons.extend(wp[:, 0].tolist()) + all_lats.extend(wp[:, 1].tolist()) + bounds = ( + min(all_lons) - 2, + min(all_lats) - 2, + max(all_lons) + 2, + max(all_lats) + 2, + ) + + print(f"Loading Natural Earth land polygons for bbox {bounds} ...") + land_geoms = _build_land_geometries(bounds=bounds) + if not land_geoms: + print("Warning: no land geometries found in bounding box.") + sys.exit(0) + land_union = land_geoms[0] + print("Land polygons loaded.\n") + + # Validate each file + total_files = len(track_files) + files_with_violations = 0 + total_violations = 0 + + for tf in track_files: + violations = validate_file(tf, land_union, density=args.density) + if violations: + files_with_violations += 1 + total_violations += len(violations) + status = f"FAIL ({len(violations)} segment(s))" + else: + status = "PASS" + + if args.summary_only: + print(f" {status} {tf.name}") + else: + print(f"[{status}] {tf.name}") + for v in violations: + print( + f" seg {v['segment']:3d}: " + f"({v['from_coord'][0]:8.3f}, {v['from_coord'][1]:7.3f}) → " + f"({v['to_coord'][0]:8.3f}, {v['to_coord'][1]:7.3f}) " + f"land={v['land_fraction']:.1%} " + f"({v['land_points']}/{v['total_points']} sub-points)" + ) + + # Summary + print(f"\n{'='*60}") + print(f"Files checked: {total_files}") + print(f"Files with land: {files_with_violations}") + print(f"Total violations: {total_violations}") + if files_with_violations == 0: + print("Result: ALL CLEAR") + else: + print("Result: LAND DETECTED") + sys.exit(1 if files_with_violations > 0 else 0) + + +if __name__ == "__main__": + main() diff --git a/tests/test_benchmark_load.py b/tests/test_benchmark_load.py new file mode 100644 index 00000000..357cc75a --- /dev/null +++ b/tests/test_benchmark_load.py @@ -0,0 +1,225 @@ +import json +import warnings +from pathlib import Path + +import numpy as np +import pytest + +from routetools._ports import DICT_INSTANCES +from routetools.benchmark import LandBenchmark, load_benchmark_instance +from routetools.wrr_bench import load as load_mod +from routetools.wrr_bench.ocean import data_zero + + +def assert_basic_output(out): + # Check presence of keys + for key in ( + "lat_start", + "lon_start", + "lat_end", + "lon_end", + "src", + "dst", + "data", + "vectorfield", + "wavefield", + "land", + ): + assert key in out + + # src/dst shapes + assert hasattr(out["src"], "shape") and out["src"].shape == (2,) + assert hasattr(out["dst"], "shape") and out["dst"].shape == (2,) + + # data and callables + assert callable(out["vectorfield"]) and callable(out["wavefield"]) + assert isinstance(out["land"], LandBenchmark) + + +def test_load_benchmark_instance_basic(tmp_path): + """Basic smoke test using the first configured instance. + + Create minimal NetCDF files under a temporary `data_path` and call the + loader with that `data_path` so no monkeypatching is required. + """ + data_dir = Path(tmp_path) / "data" + (data_dir / "currents").mkdir(parents=True) + (data_dir / "waves").mkdir(parents=True) + # Create a minimal geojson land file that the loader can read + land_geo = { + "type": "GeometryCollection", + "geometries": [ + { + "type": "Polygon", + "coordinates": [ + [[-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90]] + ], + } + ], + } + with open(data_dir / "earth-seas-2km5-valid.geo.json", "w") as f: + json.dump(land_geo, f) + + instance_name = next(iter(DICT_INSTANCES.keys())) + date_start = DICT_INSTANCES[instance_name].get("date_start", "2023-01-01") + date_base = date_start.split("T")[0] + for day in range(15): + d = np.datetime64(date_base) + np.timedelta64(day, "D") + datestr = str(d) + ds_c = data_zero(bounding_box=None, data_vars=("vo", "uo")) + ds_w = data_zero(bounding_box=None, data_vars=("height", "direction")) + # Reduce to a single timestamp per file (use the file date) so that + # concatenated datasets have evenly spaced times + ds_c = ds_c.isel(time=0).expand_dims(time=[np.datetime64(datestr)]) + ds_w = ds_w.isel(time=0).expand_dims(time=[np.datetime64(datestr)]) + # Writing with netCDF4 may emit a RuntimeWarning on import in some + # environments; suppress that specific warning around the write. + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="numpy.ndarray size changed, " + "may indicate binary incompatibility", + category=RuntimeWarning, + ) + ds_c.to_netcdf(data_dir / "currents" / f"{datestr}.nc", engine="netcdf4") + ds_w.to_netcdf(data_dir / "waves" / f"{datestr}.nc", engine="netcdf4") + + # Suppress netCDF4 C-extension ABI RuntimeWarning (may be raised on import) + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="numpy.ndarray size changed, may indicate binary incompatibility", + category=RuntimeWarning, + ) + out = load_benchmark_instance( + instance_name, date_start=date_base, data_path=str(data_dir), route_days=15 + ) + assert_basic_output(out) + + +@pytest.mark.parametrize("instance_name", list(DICT_INSTANCES.keys())) +def test_load_benchmark_for_all_instances(tmp_path, instance_name): + """Run load_benchmark_instance for every configured instance (smoke test).""" + data_dir = Path(tmp_path) / "data" + (data_dir / "currents").mkdir(parents=True) + (data_dir / "waves").mkdir(parents=True) + # Create a minimal geojson land file that the loader can read + land_geo = { + "type": "GeometryCollection", + "geometries": [ + { + "type": "Polygon", + "coordinates": [ + [[-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90]] + ], + } + ], + } + with open(data_dir / "earth-seas-2km5-valid.geo.json", "w") as f: + json.dump(land_geo, f) + + date_start = DICT_INSTANCES[instance_name].get("date_start", "2023-01-01") + date_base = date_start.split("T")[0] + for day in range(15): + d = np.datetime64(date_base) + np.timedelta64(day, "D") + datestr = str(d) + ds_c = data_zero(bounding_box=None, data_vars=("vo", "uo")) + ds_w = data_zero(bounding_box=None, data_vars=("height", "direction")) + # Reduce to a single timestamp per file (use the file date) so that + # concatenated datasets have evenly spaced times + ds_c = ds_c.isel(time=0).expand_dims(time=[np.datetime64(datestr)]) + ds_w = ds_w.isel(time=0).expand_dims(time=[np.datetime64(datestr)]) + # Writing with netCDF4 may emit a RuntimeWarning on import in some + # environments; suppress that specific warning around the write. + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="numpy.ndarray size changed, " + "may indicate binary incompatibility", + category=RuntimeWarning, + ) + ds_c.to_netcdf(data_dir / "currents" / f"{datestr}.nc", engine="netcdf4") + ds_w.to_netcdf(data_dir / "waves" / f"{datestr}.nc", engine="netcdf4") + + # Suppress netCDF4 C-extension ABI RuntimeWarning (may be raised on import) + + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="numpy.ndarray size changed, may indicate binary incompatibility", + category=RuntimeWarning, + ) + out = load_benchmark_instance( + instance_name, date_start=date_base, data_path=str(data_dir), route_days=15 + ) + # Basic smoke checks + assert_basic_output(out) + + +@pytest.mark.parametrize( + "use_currents,use_waves,route_days,expected_vars", + [ + (True, True, 3, ["currents", "waves"]), + (True, False, 4, ["currents"]), + (False, True, 5, ["waves"]), + (False, False, 2, []), + ], +) +def test_load_files_called_with_expected_parameters( + monkeypatch, tmp_path, use_currents, use_waves, route_days, expected_vars +): + """Ensure `load_real_instance` calls `load_files` with list_dates + length == route_days + and weather_variables matching the requested flags. + """ + + captured = {} + + def fake_load_files(list_dates, data_path="./data", weather_variables=None): + # record inputs for assertions + captured["list_dates"] = list_dates + captured["weather_variables"] = ( + list(weather_variables) if weather_variables is not None else [] + ) + # return empty dict so downstream code receives None for datasets + return {} + + monkeypatch.setattr(load_mod, "load_files", fake_load_files) + + # create a minimal data dir with the expected land geojson + # so Ocean can be instantiated + data_dir = Path(tmp_path) / "data" + (data_dir).mkdir(parents=True) + land_geo = { + "type": "GeometryCollection", + "geometries": [ + { + "type": "Polygon", + "coordinates": [ + [[-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90]] + ], + } + ], + } + with open(data_dir / "earth-seas-2km5-valid.geo.json", "w") as f: + json.dump(land_geo, f) + + # pick an instance that exists in DICT_INSTANCES + instance_name = next(iter(DICT_INSTANCES.keys())) + + # Call the loader; it should invoke our fake_load_files + load_mod.load_real_instance( + instance_name, + date_start="2023-01-01", + data_path=str(data_dir), + use_currents=use_currents, + use_waves=use_waves, + route_days=route_days, + vel_ship=10.0, + ) + + # Assertions + assert "list_dates" in captured + assert len(captured["list_dates"]) == route_days + # weather_variables should match expected_vars (order: currents then waves) + assert captured.get("weather_variables", []) == expected_vars diff --git a/tests/test_cmaes.py b/tests/test_cmaes.py index c709c7be..fdea3964 100644 --- a/tests/test_cmaes.py +++ b/tests/test_cmaes.py @@ -233,3 +233,232 @@ def test_curve_to_control_piecewise(L: int = 127, K: int = 10, num_pieces: int = assert jnp.allclose( reconstructed_curve, curve, atol=1e-1 ), "Reconstructed curve does not match original curve" + + +# --------------------------------------------------------------------------- +# dt_eval_minutes (Δt₂ decoupling) +# --------------------------------------------------------------------------- + + +def test_dt_eval_minutes_output_shape(): + """Output curve uses L (Δt₁), not L_eval, when dt_eval_minutes is set.""" + L = 32 + travel_time = 10.0 # hours + dt_eval = 5.0 # minutes → L_eval = 10*60/5 + 1 = 121 + curve, info = optimize( + vectorfield_swirlys, + src=jnp.array([0.0, 0.0]), + dst=jnp.array([6.0, 5.0]), + travel_time=travel_time, + L=L, + dt_eval_minutes=dt_eval, + popsize=10, + sigma0=1, + seed=42, + ) + assert curve.shape == (L, 2), f"Expected output shape ({L}, 2), got {curve.shape}" + + +def test_dt_eval_minutes_zero_is_backward_compatible(): + """dt_eval_minutes=0 behaves identically to omitting it.""" + kwargs = dict( + vectorfield=vectorfield_swirlys, + src=jnp.array([0.0, 0.0]), + dst=jnp.array([6.0, 5.0]), + travel_time=30, + L=64, + popsize=10, + sigma0=1, + seed=1, + ) + _, info_default = optimize(**kwargs) + _, info_zero = optimize(**kwargs, dt_eval_minutes=0.0) + assert info_default["cost"] == info_zero["cost"] + + +def test_dt_eval_minutes_finer_grid_does_not_degrade(): + """Using a finer eval grid should not produce worse results.""" + kwargs = dict( + vectorfield=vectorfield_swirlys, + src=jnp.array([0.0, 0.0]), + dst=jnp.array([6.0, 5.0]), + travel_time=30, + L=64, + popsize=200, + sigma0=2, + seed=1, + ) + _, info_coarse = optimize(**kwargs, dt_eval_minutes=0.0) + _, info_fine = optimize(**kwargs, dt_eval_minutes=5.0) + # Fine grid may produce a different cost but shouldn't be wildly worse + assert info_fine["cost"] < info_coarse["cost"] * 2, ( + f"Fine grid cost {info_fine['cost']} much worse than " + f"coarse cost {info_coarse['cost']}" + ) + + +# --------------------------------------------------------------------------- +# Weather penalty time-awareness regression tests +# --------------------------------------------------------------------------- + + +def _windfield_time_dependent(lon, lat, t): + """Wind field: calm at t=0, stormy (30 m/s) for t >= 5.""" + tws = jnp.where(t >= 5.0, 30.0, 5.0) + u10 = tws * jnp.ones_like(lon) + v10 = jnp.zeros_like(lon) + return u10, v10 + + +def _wavefield_time_dependent(lon, lat, t): + """Wave field: calm at t=0, rough (12 m) for t >= 5.""" + hs = jnp.where(t >= 5.0, 12.0, 1.0) + mwd = jnp.zeros_like(lon) + return hs, mwd + + +def test_wind_penalty_smooth_uses_travel_time(): + """wind_penalty_smooth must evaluate weather at actual voyage timestamps. + + Regression: when travel_time was not forwarded the penalty evaluated + everything at t=0 (calm) and returned ≈0 even though the real voyage + would encounter 30 m/s winds. + """ + from routetools.weather import wind_penalty_smooth + + # Straight route, 10 points + L = 10 + lons = jnp.linspace(0.0, 6.0, L) + lats = jnp.linspace(0.0, 5.0, L) + curve = jnp.stack([lons, lats], axis=-1)[jnp.newaxis, :, :] # (1, L, 2) + + # With travel_time=10 h, segments span t ∈ [0, 10]: most are at t>=5 → stormy + penalty_with_time = wind_penalty_smooth( + curve, + windfield=_windfield_time_dependent, + tws_limit=20.0, + weight=100.0, + travel_time=10.0 * 3600, # seconds + spherical_correction=False, + ) + + # Without travel_time → t=0 everywhere → calm (5 m/s < 20 limit) → penalty ≈ 0 + penalty_no_time = wind_penalty_smooth( + curve, + windfield=_windfield_time_dependent, + tws_limit=20.0, + weight=100.0, + spherical_correction=False, + ) + + assert float(penalty_no_time[0]) == 0.0, "At t=0 wind is calm, penalty must be 0" + assert ( + float(penalty_with_time[0]) > 0.0 + ), "With travel_time the route hits stormy conditions, penalty must be > 0" + + +def test_wave_penalty_smooth_uses_travel_time(): + """wave_penalty_smooth must evaluate weather at actual voyage timestamps.""" + from routetools.weather import wave_penalty_smooth + + L = 10 + lons = jnp.linspace(0.0, 6.0, L) + lats = jnp.linspace(0.0, 5.0, L) + curve = jnp.stack([lons, lats], axis=-1)[jnp.newaxis, :, :] + + penalty_with_time = wave_penalty_smooth( + curve, + wavefield=_wavefield_time_dependent, + hs_limit=7.0, + weight=100.0, + travel_time=10.0 * 3600, + spherical_correction=False, + ) + + penalty_no_time = wave_penalty_smooth( + curve, + wavefield=_wavefield_time_dependent, + hs_limit=7.0, + weight=100.0, + spherical_correction=False, + ) + + assert float(penalty_no_time[0]) == 0.0, "At t=0 seas are calm, penalty must be 0" + assert ( + float(penalty_with_time[0]) > 0.0 + ), "With travel_time the route hits rough seas, penalty must be > 0" + + +def test_weather_penalty_smooth_uses_travel_time(): + """weather_penalty_smooth (combined) must forward travel_time.""" + from routetools.weather import weather_penalty_smooth + + L = 10 + lons = jnp.linspace(0.0, 6.0, L) + lats = jnp.linspace(0.0, 5.0, L) + curve = jnp.stack([lons, lats], axis=-1)[jnp.newaxis, :, :] + + penalty_with_time = weather_penalty_smooth( + curve, + windfield=_windfield_time_dependent, + wavefield=_wavefield_time_dependent, + tws_limit=20.0, + hs_limit=7.0, + penalty=100.0, + travel_time=10.0 * 3600, + spherical_correction=False, + ) + + penalty_no_time = weather_penalty_smooth( + curve, + windfield=_windfield_time_dependent, + wavefield=_wavefield_time_dependent, + tws_limit=20.0, + hs_limit=7.0, + penalty=100.0, + spherical_correction=False, + ) + + assert float(penalty_no_time[0]) == 0.0 + assert float(penalty_with_time[0]) > 0.0 + + +def test_cmaes_penalty_forwards_time_params(): + """CMA-ES loop must forward travel_time and time_offset to penalties. + + Uses a time-dependent wind field where storms only occur at t >= 5 h. + With travel_time=10 h, the penalty should be non-zero and influence + the optimizer to find a different route than without the penalty. + """ + src = jnp.array([0.0, 0.0]) + dst = jnp.array([6.0, 5.0]) + + common = dict( + vectorfield=vectorfield_swirlys, + src=src, + dst=dst, + travel_time=30.0, + L=16, + popsize=10, + sigma0=1, + seed=42, + verbose=False, + ) + + _, info_no_penalty = optimize( + **common, + windfield=_windfield_time_dependent, + wind_penalty_weight=0.0, + ) + + _, info_with_penalty = optimize( + **common, + windfield=_windfield_time_dependent, + wind_penalty_weight=100.0, + ) + + # The penalized run must have a higher total cost (energy + penalty) + assert info_with_penalty["cost"] > info_no_penalty["cost"], ( + f"Penalized cost ({info_with_penalty['cost']:.4f}) should exceed " + f"unpenalized cost ({info_no_penalty['cost']:.4f}) when storms are present" + ) diff --git a/tests/test_compare_era5_sources.py b/tests/test_compare_era5_sources.py new file mode 100644 index 00000000..0a6a8037 --- /dev/null +++ b/tests/test_compare_era5_sources.py @@ -0,0 +1,81 @@ +"""Tests for scripts/compare_era5_sources.py.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import routetools.era5.download_gcs as download_gcs + + +def _load_compare_module(): + """Load the comparison script directly from scripts/compare_era5_sources.py.""" + module_path = ( + Path(__file__).resolve().parents[1] / "scripts" / "compare_era5_sources.py" + ) + spec = importlib.util.spec_from_file_location("compare_era5_sources", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load module spec for {module_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_compare_era5_sources = _load_compare_module() + + +def test_main_downloads_hourly_gcs_by_default(tmp_path: Path, monkeypatch): + """The GCS comparison helper should use hourly downloads by default.""" + cds_dir = tmp_path / "cds" + gcs_dir = tmp_path / "gcs" + cds_dir.mkdir() + + for field in ("wind", "waves"): + (cds_dir / f"era5_{field}_atlantic_2024.nc").touch() + + calls: list[tuple[str, int, list[int]]] = [] + + def _fake_download(field: str): + def _inner(*, output_dir, corridor, year, months, time_step): + calls.append((field, time_step, months)) + output_path = download_gcs._output_filename( + Path(output_dir), field, corridor, year, months + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.touch() + return output_path + + return _inner + + monkeypatch.setattr( + download_gcs, + "download_era5_wind_gcs", + _fake_download("wind"), + ) + monkeypatch.setattr( + download_gcs, + "download_era5_waves_gcs", + _fake_download("waves"), + ) + monkeypatch.setattr( + _compare_era5_sources, + "compare_datasets", + lambda gcs_path, cds_path, field: [{"match": True}], + ) + monkeypatch.setattr(_compare_era5_sources, "print_results", lambda results: None) + monkeypatch.setattr( + sys, + "argv", + [ + "compare_era5_sources.py", + "--cds-dir", + str(cds_dir), + "--gcs-dir", + str(gcs_dir), + ], + ) + + _compare_era5_sources.main() + + assert calls == [("wind", 1, [1]), ("waves", 1, [1])] diff --git a/tests/test_cost.py b/tests/test_cost.py index aa2a0d2b..729677fa 100644 --- a/tests/test_cost.py +++ b/tests/test_cost.py @@ -1,6 +1,7 @@ import jax.numpy as jnp import numpy as np +import routetools.cost as cost_module from routetools.cost import interpolate_to_constant_cost from routetools.vectorfield import vectorfield_zero @@ -37,3 +38,48 @@ def test_interpolate_to_constant_cost(): assert jnp.allclose( distances, avg_distance, atol=1e-2 ), f"distances: {distances}, avg_distance: {avg_distance}" + + +def _field_zero( + lon: jnp.ndarray, + lat: jnp.ndarray, + t: jnp.ndarray, +) -> tuple[jnp.ndarray, jnp.ndarray]: + del lat, t + return jnp.zeros_like(lon), jnp.zeros_like(lon) + + +def test_cost_function_rise_returns_energy_in_mwh(monkeypatch): + """A constant 500 kW profile over 10 h and 2 segments yields 5 MWh.""" + + def _predict_power_constant( + tws: jnp.ndarray, + twa: jnp.ndarray, + hs: jnp.ndarray, + mwa: jnp.ndarray, + v_mps: jnp.ndarray, + wps: bool = False, + ) -> jnp.ndarray: + del twa, hs, mwa, v_mps, wps + return jnp.full_like(tws, 500.0) + + monkeypatch.setattr(cost_module, "predict_power_jax", _predict_power_constant) + + curve = jnp.array( + [ + [ + [0.0, 0.0], + [1.0, 0.0], + [2.0, 0.0], + ] + ] + ) + + energy_mwh = cost_module.cost_function_rise( + windfield=_field_zero, + curve=curve, + travel_time=10.0, + wavefield=_field_zero, + ) + + assert jnp.allclose(energy_mwh, jnp.array([5.0]), atol=1e-6) diff --git a/tests/test_era5.py b/tests/test_era5.py new file mode 100644 index 00000000..d765b67a --- /dev/null +++ b/tests/test_era5.py @@ -0,0 +1,617 @@ +"""Tests for the ERA5 data loading and field construction. + +These tests create small synthetic NetCDF files that mimic the ERA5 format +and verify that the loaders produce JAX-compatible field closures with +correct interpolation behaviour. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import jax.numpy as jnp +import numpy as np +import pytest +import xarray as xr + +# --------------------------------------------------------------------------- +# Fixtures: small synthetic ERA5-like NetCDF files +# --------------------------------------------------------------------------- + + +def _make_wind_nc(path: Path) -> None: + """Create a small synthetic ERA5 wind NetCDF file.""" + # 4 time steps (6-hourly over one day), 5 lats, 6 lons + times = np.array( + [ + "2024-01-15T00:00", + "2024-01-15T06:00", + "2024-01-15T12:00", + "2024-01-15T18:00", + ], + dtype="datetime64[ns]", + ) + lats = np.array([30.0, 35.0, 40.0, 45.0, 50.0]) + lons = np.array([-70.0, -60.0, -50.0, -40.0, -30.0, -20.0]) + + # Simple pattern: u10 = lon/10 + t*0.1, v10 = lat/10 + t*0.1 (varies with time) + u10 = np.zeros((4, 5, 6), dtype=np.float32) + v10 = np.zeros((4, 5, 6), dtype=np.float32) + for t in range(4): + for i, lat in enumerate(lats): + for j, lon in enumerate(lons): + u10[t, i, j] = lon / 10.0 + t * 0.1 + v10[t, i, j] = lat / 10.0 + t * 0.1 + + ds = xr.Dataset( + { + "u10": (["time", "latitude", "longitude"], u10), + "v10": (["time", "latitude", "longitude"], v10), + }, + coords={ + "time": times, + "latitude": lats, + "longitude": lons, + }, + ) + ds.to_netcdf(path, engine="scipy") + + +def _make_wave_nc(path: Path) -> None: + """Create a small synthetic ERA5 wave NetCDF file.""" + times = np.array( + [ + "2024-01-15T00:00", + "2024-01-15T06:00", + "2024-01-15T12:00", + "2024-01-15T18:00", + ], + dtype="datetime64[ns]", + ) + lats = np.array([30.0, 35.0, 40.0, 45.0, 50.0]) + lons = np.array([-70.0, -60.0, -50.0, -40.0, -30.0, -20.0]) + + swh = np.ones((4, 5, 6), dtype=np.float32) * 2.0 # 2m everywhere + mwd = np.ones((4, 5, 6), dtype=np.float32) * 180.0 # from South + + ds = xr.Dataset( + { + "swh": (["time", "latitude", "longitude"], swh), + "mwd": (["time", "latitude", "longitude"], mwd), + }, + coords={ + "time": times, + "latitude": lats, + "longitude": lons, + }, + ) + ds.to_netcdf(path, engine="scipy") + + +def _make_descending_lat_nc(path: Path) -> None: + """Create a wind NetCDF with descending latitudes (ERA5 default order).""" + times = np.array( + ["2024-01-15T00:00", "2024-01-15T06:00"], + dtype="datetime64[ns]", + ) + lats = np.array([50.0, 45.0, 40.0, 35.0, 30.0]) # descending! + lons = np.array([-70.0, -60.0, -50.0, -40.0]) + + u10 = np.zeros((2, 5, 4), dtype=np.float32) + v10 = np.zeros((2, 5, 4), dtype=np.float32) + for t in range(2): + for i, lat in enumerate(lats): + for j, lon in enumerate(lons): + u10[t, i, j] = lon / 10.0 + v10[t, i, j] = lat / 10.0 + + ds = xr.Dataset( + { + "u10": (["time", "latitude", "longitude"], u10), + "v10": (["time", "latitude", "longitude"], v10), + }, + coords={ + "time": times, + "latitude": lats, + "longitude": lons, + }, + ) + ds.to_netcdf(path, engine="scipy") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestLoadERA5Windfield: + """Tests for load_era5_windfield / load_era5_vectorfield.""" + + def test_basic_load_and_shape(self) -> None: + """Windfield closure returns arrays of the correct shape.""" + from routetools.era5.loader import load_era5_windfield + + with tempfile.TemporaryDirectory() as tmpdir: + nc_path = Path(tmpdir) / "wind.nc" + _make_wind_nc(nc_path) + + wf = load_era5_windfield(nc_path) + + # Query at a single point + lon = jnp.array([[-50.0]]) + lat = jnp.array([[40.0]]) + t = jnp.array([0.0]) + + u, v = wf(lon, lat, t) + assert u.shape == lon.shape + assert v.shape == lon.shape + + def test_time_variant_attribute(self) -> None: + """Windfield should be marked as time-variant.""" + from routetools.era5.loader import load_era5_windfield + + with tempfile.TemporaryDirectory() as tmpdir: + nc_path = Path(tmpdir) / "wind.nc" + _make_wind_nc(nc_path) + wf = load_era5_windfield(nc_path) + assert hasattr(wf, "is_time_variant") + assert wf.is_time_variant is True + + def test_interpolation_values(self) -> None: + """Values at grid points should match the synthetic data.""" + from routetools.era5.loader import load_era5_windfield + + with tempfile.TemporaryDirectory() as tmpdir: + nc_path = Path(tmpdir) / "wind.nc" + _make_wind_nc(nc_path) + wf = load_era5_windfield(nc_path) + + # At t=0, grid point (lon=-50, lat=40): u10 = -50/10 = -5.0 + lon = jnp.array([-50.0]) + lat = jnp.array([40.0]) + t = jnp.array([0.0]) + + u, v = wf(lon, lat, t) + np.testing.assert_allclose(float(u[0]), -5.0, atol=0.1) + np.testing.assert_allclose(float(v[0]), 4.0, atol=0.1) + + def test_batch_2d_input(self) -> None: + """Windfield handles 2D batched inputs (B, L-1).""" + from routetools.era5.loader import load_era5_windfield + + with tempfile.TemporaryDirectory() as tmpdir: + nc_path = Path(tmpdir) / "wind.nc" + _make_wind_nc(nc_path) + wf = load_era5_windfield(nc_path) + + # Batch of 3 paths, each with 4 segments + lon = jnp.ones((3, 4)) * -50.0 + lat = jnp.ones((3, 4)) * 40.0 + t = jnp.array([0.0, 0.0, 0.0]) + + u, v = wf(lon, lat, t) + assert u.shape == (3, 4) + assert v.shape == (3, 4) + + def test_departure_time_offset(self) -> None: + """When departure_time is set, t=0 maps to that time.""" + from routetools.era5.loader import load_era5_windfield + + with tempfile.TemporaryDirectory() as tmpdir: + nc_path = Path(tmpdir) / "wind.nc" + _make_wind_nc(nc_path) + + # Departure at 12:00 (third time step, index 2) + wf = load_era5_windfield(nc_path, departure_time="2024-01-15T12:00") + + lon = jnp.array([-50.0]) + lat = jnp.array([40.0]) + t = jnp.array([0.0]) # Should map to 12:00 UTC + + u, v = wf(lon, lat, t) + # At t=12h (index 2): u10 = -50/10 + 2*0.1 = -4.8 + np.testing.assert_allclose(float(u[0]), -4.8, atol=0.15) + + def test_descending_latitude(self) -> None: + """Loader handles ERA5 files with descending latitudes.""" + from routetools.era5.loader import load_era5_windfield + + with tempfile.TemporaryDirectory() as tmpdir: + nc_path = Path(tmpdir) / "wind_desc.nc" + _make_descending_lat_nc(nc_path) + wf = load_era5_windfield(nc_path) + + lon = jnp.array([-50.0]) + lat = jnp.array([40.0]) + t = jnp.array([0.0]) + + u, v = wf(lon, lat, t) + np.testing.assert_allclose(float(u[0]), -5.0, atol=0.1) + np.testing.assert_allclose(float(v[0]), 4.0, atol=0.1) + + def test_file_not_found(self) -> None: + """Raises FileNotFoundError for missing files.""" + from routetools.era5.loader import load_era5_windfield + + with pytest.raises(FileNotFoundError): + load_era5_windfield("/nonexistent/path.nc") + + +class TestLoadERA5Wavefield: + """Tests for load_era5_wavefield.""" + + def test_basic_load(self) -> None: + """Wavefield closure returns correct values.""" + from routetools.era5.loader import load_era5_wavefield + + with tempfile.TemporaryDirectory() as tmpdir: + nc_path = Path(tmpdir) / "waves.nc" + _make_wave_nc(nc_path) + wf = load_era5_wavefield(nc_path) + + lon = jnp.array([-50.0]) + lat = jnp.array([40.0]) + t = jnp.array([0.0]) + + hs, mwd = wf(lon, lat, t) + np.testing.assert_allclose(float(hs[0]), 2.0, atol=0.1) + np.testing.assert_allclose(float(mwd[0]), 180.0, atol=0.1) + + def test_wavefield_not_time_variant(self) -> None: + """Wavefield should NOT have is_time_variant=True.""" + from routetools.era5.loader import load_era5_wavefield + + with tempfile.TemporaryDirectory() as tmpdir: + nc_path = Path(tmpdir) / "waves.nc" + _make_wave_nc(nc_path) + wf = load_era5_wavefield(nc_path) + # wavefield closures don't get the time_variant decorator + assert not getattr(wf, "is_time_variant", False) + + +class TestLoadERA5Vectorfield: + """Tests for load_era5_vectorfield (alias of windfield).""" + + def test_alias_works(self) -> None: + """load_era5_vectorfield returns same result as load_era5_windfield.""" + from routetools.era5.loader import ( + load_era5_vectorfield, + load_era5_windfield, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + nc_path = Path(tmpdir) / "wind.nc" + _make_wind_nc(nc_path) + + vf = load_era5_vectorfield(nc_path) + wf = load_era5_windfield(nc_path) + + lon = jnp.array([-50.0]) + lat = jnp.array([40.0]) + t = jnp.array([0.0]) + + u1, v1 = vf(lon, lat, t) + u2, v2 = wf(lon, lat, t) + np.testing.assert_allclose(u1, u2) + np.testing.assert_allclose(v1, v2) + + +class TestDownloadCDS: + """Tests for the CDS download module (structure only, no actual API calls).""" + + def test_corridors_defined(self) -> None: + """Verify corridor bounding boxes are defined.""" + from routetools.era5.download_cds import CORRIDORS + + assert "atlantic" in CORRIDORS + assert "pacific" in CORRIDORS + for name, bbox in CORRIDORS.items(): + assert len(bbox) == 4, f"Corridor {name} should have 4 bounds" + + def test_variables_defined(self) -> None: + """Verify ERA5 variable names are defined.""" + from routetools.era5.download_cds import WAVE_VARIABLES, WIND_VARIABLES + + assert len(WIND_VARIABLES) == 2 + assert len(WAVE_VARIABLES) == 2 + + +class TestDownloadGCS: + """Tests for the GCS download module (structure only, no actual downloads).""" + + def test_corridors_defined(self) -> None: + """Verify corridor bounding boxes are defined.""" + from routetools.era5.download_gcs import CORRIDORS + + assert "atlantic" in CORRIDORS + assert "pacific" in CORRIDORS + + def test_gcs_path_exists(self) -> None: + """Verify GCS bucket path is configured.""" + from routetools.era5.download_gcs import GCS_ERA5_SINGLE_LEVEL + + assert "gcp-public-data" in GCS_ERA5_SINGLE_LEVEL + + +class TestLoadERA5LandMask: + """Tests for load_era5_land_mask — derives Land from wave NaN patterns.""" + + def test_basic_land_mask(self, tmp_path: Path) -> None: + """Wave NaN cells become land, valid cells become water.""" + from routetools.era5.loader import load_era5_land_mask + + # Create a synthetic wave NC with known NaN pattern + times = np.array(["2024-01-15T00:00"], dtype="datetime64[ns]") + lats = np.array([30.0, 35.0, 40.0, 45.0, 50.0]) + lons = np.array([-70.0, -60.0, -50.0, -40.0, -30.0, -20.0]) + swh = np.ones((1, 5, 6), dtype=np.float32) * 2.0 + mwd = np.ones((1, 5, 6), dtype=np.float32) * 180.0 + + # Mark some cells as land (NaN) + swh[0, 0, 0] = np.nan # (lat=30, lon=-70) → land + swh[0, 2, 3] = np.nan # (lat=40, lon=-40) → land + mwd[0, 0, 0] = np.nan + mwd[0, 2, 3] = np.nan + + ds = xr.Dataset( + { + "swh": (["time", "latitude", "longitude"], swh), + "mwd": (["time", "latitude", "longitude"], mwd), + }, + coords={"time": times, "latitude": lats, "longitude": lons}, + ) + nc_path = tmp_path / "wave_land.nc" + ds.to_netcdf(nc_path, engine="scipy") + + land = load_era5_land_mask(nc_path) + + # Land object grid should cover the file extent + assert land.xmin == -70.0 + assert land.xmax == -20.0 + assert land.ymin == 30.0 + assert land.ymax == 50.0 + + # Internal array shape should be (lon, lat) = (6, 5) + assert land._array.shape == (6, 5) + + # Check NaN → land (value > 0.5) and valid → water (value < 0.5) + arr = np.array(land._array) + assert arr[0, 0] > 0.5, "NaN cell should be land" + assert arr[3, 2] > 0.5, "NaN cell should be land" + assert arr[1, 1] < 0.5, "Valid cell should be water" + assert arr[5, 4] < 0.5, "Valid cell should be water" + + def test_missing_variable_raises(self, tmp_path: Path) -> None: + """KeyError when no wave-height variable is found.""" + from routetools.era5.loader import load_era5_land_mask + + times = np.array(["2024-01-15T00:00"], dtype="datetime64[ns]") + lats = np.array([30.0, 35.0]) + lons = np.array([-70.0, -60.0]) + ds = xr.Dataset( + { + "temperature": ( + ["time", "latitude", "longitude"], + np.ones((1, 2, 2), dtype=np.float32), + ) + }, + coords={"time": times, "latitude": lats, "longitude": lons}, + ) + nc_path = tmp_path / "no_wave.nc" + ds.to_netcdf(nc_path, engine="scipy") + + with pytest.raises(KeyError, match="Cannot find wave height"): + load_era5_land_mask(nc_path) + + +# --------------------------------------------------------------------------- +# Helpers for valid_time and multi-file tests +# --------------------------------------------------------------------------- + + +def _make_wind_nc_valid_time(path: Path) -> None: + """Create a wind NetCDF using ``valid_time`` instead of ``time``.""" + times = np.array( + [ + "2024-01-15T00:00", + "2024-01-15T06:00", + "2024-01-15T12:00", + "2024-01-15T18:00", + ], + dtype="datetime64[ns]", + ) + lats = np.array([30.0, 35.0, 40.0, 45.0, 50.0]) + lons = np.array([-70.0, -60.0, -50.0, -40.0, -30.0, -20.0]) + + u10 = np.zeros((4, 5, 6), dtype=np.float32) + v10 = np.zeros((4, 5, 6), dtype=np.float32) + for t in range(4): + for i, lat in enumerate(lats): + for j, lon in enumerate(lons): + u10[t, i, j] = lon / 10.0 + t * 0.1 + v10[t, i, j] = lat / 10.0 + t * 0.1 + + ds = xr.Dataset( + { + "u10": (["valid_time", "latitude", "longitude"], u10), + "v10": (["valid_time", "latitude", "longitude"], v10), + }, + coords={ + "valid_time": times, + "latitude": lats, + "longitude": lons, + }, + ) + ds.to_netcdf(path, engine="scipy") + + +def _make_wind_nc_part(path: Path, day: int) -> None: + """Create a 1-day wind NetCDF for multi-file concat tests. + + Parameters + ---------- + day : int + Day of month (e.g. 15 or 16) — determines timestamps and values. + """ + times = np.array( + [ + f"2024-01-{day:02d}T00:00", + f"2024-01-{day:02d}T06:00", + f"2024-01-{day:02d}T12:00", + f"2024-01-{day:02d}T18:00", + ], + dtype="datetime64[ns]", + ) + lats = np.array([30.0, 35.0, 40.0, 45.0, 50.0]) + lons = np.array([-70.0, -60.0, -50.0, -40.0, -30.0, -20.0]) + + # Use day offset so concatenated files have distinct values + u10 = np.full((4, 5, 6), float(day), dtype=np.float32) + v10 = np.full((4, 5, 6), float(day) + 0.5, dtype=np.float32) + + ds = xr.Dataset( + { + "u10": (["time", "latitude", "longitude"], u10), + "v10": (["time", "latitude", "longitude"], v10), + }, + coords={ + "time": times, + "latitude": lats, + "longitude": lons, + }, + ) + ds.to_netcdf(path, engine="scipy") + + +class TestValidTimeDimension: + """Tests for files using ``valid_time`` instead of ``time``.""" + + def test_load_windfield_valid_time(self) -> None: + """Windfield loads correctly from a file with ``valid_time`` dim.""" + from routetools.era5.loader import load_era5_windfield + + with tempfile.TemporaryDirectory() as tmpdir: + nc_path = Path(tmpdir) / "wind_vt.nc" + _make_wind_nc_valid_time(nc_path) + + wf = load_era5_windfield(nc_path) + + lon = jnp.array([-50.0]) + lat = jnp.array([40.0]) + t = jnp.array([0.0]) + + u, v = wf(lon, lat, t) + np.testing.assert_allclose(float(u[0]), -5.0, atol=0.1) + np.testing.assert_allclose(float(v[0]), 4.0, atol=0.1) + + +class TestMultiFileConcat: + """Tests for loading and concatenating multiple NetCDF files.""" + + def test_concat_two_files(self) -> None: + """Two single-day files concatenate into 8 time steps.""" + from routetools.era5.loader import _load_datasets + + with tempfile.TemporaryDirectory() as tmpdir: + p1 = Path(tmpdir) / "day15.nc" + p2 = Path(tmpdir) / "day16.nc" + _make_wind_nc_part(p1, 15) + _make_wind_nc_part(p2, 16) + + ds = _load_datasets([p1, p2]) + time_name = "valid_time" if "valid_time" in ds.dims else "time" + assert ds.sizes[time_name] == 8 + + def test_concat_windfield_values(self) -> None: + """Multi-file windfield returns values from both files.""" + from routetools.era5.loader import load_era5_windfield + + with tempfile.TemporaryDirectory() as tmpdir: + p1 = Path(tmpdir) / "day15.nc" + p2 = Path(tmpdir) / "day16.nc" + _make_wind_nc_part(p1, 15) + _make_wind_nc_part(p2, 16) + + wf = load_era5_windfield([p1, p2]) + + lon = jnp.array([-50.0]) + lat = jnp.array([40.0]) + + # t=0h maps to day-15 00:00; u10 should be 15.0 + u0, _ = wf(lon, lat, jnp.array([0.0])) + np.testing.assert_allclose(float(u0[0]), 15.0, atol=0.1) + + # t=24h maps to day-16 00:00; u10 should be 16.0 + u24, _ = wf(lon, lat, jnp.array([24.0])) + np.testing.assert_allclose(float(u24[0]), 16.0, atol=0.1) + + def test_single_path_unchanged(self) -> None: + """Passing a single Path still works (backward compat).""" + from routetools.era5.loader import load_era5_windfield + + with tempfile.TemporaryDirectory() as tmpdir: + nc_path = Path(tmpdir) / "wind.nc" + _make_wind_nc(nc_path) + + wf = load_era5_windfield(nc_path) + lon = jnp.array([-50.0]) + lat = jnp.array([40.0]) + t = jnp.array([0.0]) + u, v = wf(lon, lat, t) + np.testing.assert_allclose(float(u[0]), -5.0, atol=0.1) + + def test_mixed_time_valid_time(self) -> None: + """Files with ``time`` and ``valid_time`` dims concatenate.""" + from routetools.era5.loader import _load_datasets + + with tempfile.TemporaryDirectory() as tmpdir: + # File 1: uses "time" dimension + p1 = Path(tmpdir) / "day15.nc" + _make_wind_nc_part(p1, 15) + + # File 2: uses "valid_time" dimension + p2 = Path(tmpdir) / "day16.nc" + _make_wind_nc_valid_time(p2) + + ds = _load_datasets([p1, p2]) + time_name = "valid_time" if "valid_time" in ds.dims else "time" + assert ds.sizes[time_name] == 8 + + +class TestVoyageCoverageWarning: + """Tests for the voyage coverage warning in windfield/wavefield.""" + + def test_warning_logged_when_insufficient( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """A warning is logged when data doesn't cover the full voyage.""" + import logging + + from routetools.era5.loader import load_era5_windfield + + with tempfile.TemporaryDirectory() as tmpdir: + nc_path = Path(tmpdir) / "wind.nc" + _make_wind_nc(nc_path) # covers 18h (4 steps × 6h) + + with caplog.at_level(logging.WARNING, logger="routetools.era5.loader"): + load_era5_windfield(nc_path, voyage_hours=100.0) + + assert any( + "covers" in r.message and "voyage" in r.message for r in caplog.records + ) + + def test_no_warning_when_sufficient(self, caplog: pytest.LogCaptureFixture) -> None: + """No warning when data covers the voyage.""" + import logging + + from routetools.era5.loader import load_era5_windfield + + with tempfile.TemporaryDirectory() as tmpdir: + nc_path = Path(tmpdir) / "wind.nc" + _make_wind_nc(nc_path) # covers 18h + + with caplog.at_level(logging.WARNING, logger="routetools.era5.loader"): + load_era5_windfield(nc_path, voyage_hours=10.0) + + assert not any("covers" in r.message for r in caplog.records) diff --git a/tests/test_era5_loader_helpers.py b/tests/test_era5_loader_helpers.py new file mode 100644 index 00000000..9137f039 --- /dev/null +++ b/tests/test_era5_loader_helpers.py @@ -0,0 +1,131 @@ +"""Tests for utility helpers in routetools.era5.loader.""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +import numpy as np +import pytest +import xarray as xr + +from routetools.era5.loader import load_dataset_epoch, loadable_era5_paths + + +def _write_dataset( + path: Path, + time_coord: str, + times: np.ndarray | None = None, +) -> None: + """Write a minimal ERA5-like dataset with a configurable time coordinate.""" + times = ( + times + if times is not None + else np.array( + ["2024-01-01T12:00:00", "2024-01-01T13:00:00"], dtype="datetime64[ns]" + ) + ) + ds = xr.Dataset( + { + "u10": ( + (time_coord, "latitude", "longitude"), + np.zeros((2, 1, 1), dtype=np.float32), + ) + }, + coords={ + time_coord: times, + "latitude": np.array([0.0], dtype=np.float64), + "longitude": np.array([0.0], dtype=np.float64), + }, + ) + ds.to_netcdf(path, engine="scipy") + ds.close() + + +class TestLoadDatasetEpoch: + def test_reads_time_coordinate(self, tmp_path: Path): + nc = tmp_path / "wind_time.nc" + _write_dataset(nc, "time") + + epoch = load_dataset_epoch(nc) + assert epoch == datetime(2024, 1, 1, 12, 0, 0) + + def test_reads_valid_time_coordinate(self, tmp_path: Path): + nc = tmp_path / "wind_valid_time.nc" + _write_dataset(nc, "valid_time") + + epoch = load_dataset_epoch(nc) + assert epoch == datetime(2024, 1, 1, 12, 0, 0) + + def test_reads_earliest_time_from_multiple_files(self, tmp_path: Path): + later = tmp_path / "wind_later.nc" + earlier = tmp_path / "wind_earlier.nc" + _write_dataset( + later, + "time", + np.array( + ["2024-01-02T12:00:00", "2024-01-02T13:00:00"], dtype="datetime64[ns]" + ), + ) + _write_dataset( + earlier, + "valid_time", + np.array( + ["2024-01-01T12:00:00", "2024-01-01T13:00:00"], dtype="datetime64[ns]" + ), + ) + + epoch = load_dataset_epoch([later, earlier]) + assert epoch == datetime(2024, 1, 1, 12, 0, 0) + + def test_raises_without_time_coord(self, tmp_path: Path): + nc = tmp_path / "bad.nc" + ds = xr.Dataset( + { + "u10": (("foo", "latitude", "longitude"), np.zeros((1, 1, 1))), + }, + coords={ + "foo": np.array([0]), + "latitude": np.array([0.0]), + "longitude": np.array([0.0]), + }, + ) + ds.to_netcdf(nc, engine="scipy") + ds.close() + + with pytest.raises(KeyError, match="time"): + load_dataset_epoch(nc) + + +class TestLoadableEra5Paths: + def test_returns_only_base_when_no_continuation_file(self, tmp_path: Path): + base = tmp_path / "era5_wind_atlantic_2024.nc" + base.touch() + + assert loadable_era5_paths(base) == [base] + + def test_returns_base_plus_exact_next_year_when_present(self, tmp_path: Path): + base = tmp_path / "era5_wind_atlantic_2024.nc" + next_year = tmp_path / "era5_wind_atlantic_2025.nc" + base.touch() + next_year.touch() + + assert loadable_era5_paths(base) == [base, next_year] + + def test_returns_base_plus_monthly_glob_files(self, tmp_path: Path): + base = tmp_path / "era5_wind_pacific_2024.nc" + jan = tmp_path / "era5_wind_pacific_2025_01.nc" + feb = tmp_path / "era5_wind_pacific_2025_02.nc" + base.touch() + jan.touch() + feb.touch() + + assert loadable_era5_paths(base) == [base, jan, feb] + + def test_returns_only_base_when_filename_does_not_match_pattern( + self, tmp_path: Path + ): + base = tmp_path / "custom_weather_file.nc" + base.touch() + + assert loadable_era5_paths(base) == [base] diff --git a/tests/test_fms.py b/tests/test_fms.py new file mode 100644 index 00000000..b5ed97e6 --- /dev/null +++ b/tests/test_fms.py @@ -0,0 +1,309 @@ +"""Tests for routetools.fms constraint handling.""" + +from __future__ import annotations + +import jax.numpy as jnp +import pytest + +from routetools.fms import _apply_curve_constraints, optimize_fms +from routetools.vectorfield import vectorfield_fourvortices + + +def _curve() -> jnp.ndarray: + return jnp.array([[[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]]], dtype=jnp.float32) + + +def _violating_windfield(lon, lat, t): + return jnp.full_like(lon, 25.0), jnp.zeros_like(lon) + + +def _banded_windfield(lon, lat, t): + tws = jnp.where(lat > 0.25, 25.0, 5.0) + return tws, jnp.zeros_like(lon) + + +class _BandLand: + def __call__(self, curve): + x = curve[..., 0] + y = curve[..., 1] + return ((x > 0.75) & (x < 1.25) & (y > 0.25)).astype(jnp.int32) + + +class TestApplyCurveConstraints: + def test_no_land_passes_curve_through(self): + """Without land, the updated curve is returned unchanged.""" + curve_old = _curve() + curve_new = curve_old + jnp.array([[[0.0, 0.0], [0.0, 1.0], [0.0, 0.0]]]) + + constrained = _apply_curve_constraints(curve_new, curve_old) + + assert jnp.allclose(constrained, curve_new) + + def test_newly_invalid_weather_route_is_rolled_back(self): + """A newly weather-invalid route should revert to the prior route.""" + curve_old = _curve() + curve_new = curve_old + jnp.array([[[0.0, 0.0], [0.0, 1.0], [0.0, 0.0]]]) + + constrained = _apply_curve_constraints( + curve_new, + curve_old, + windfield=_banded_windfield, + enforce_weather_limits=True, + travel_time=2.0, + ) + + assert jnp.allclose(constrained, curve_old) + + def test_still_invalid_weather_route_keeps_new_value(self): + """An already weather-invalid route should keep moving.""" + curve_old = _curve() + jnp.array([[[0.0, 0.0], [0.0, 1.0], [0.0, 0.0]]]) + curve_new = curve_old + jnp.array([[[0.0, 0.0], [0.0, -0.1], [0.0, 0.0]]]) + + constrained = _apply_curve_constraints( + curve_new, + curve_old, + windfield=_violating_windfield, + enforce_weather_limits=True, + travel_time=2.0, + ) + + assert jnp.allclose(constrained, curve_new) + + def test_newly_invalid_land_waypoint_is_rolled_back(self): + """A valid waypoint that moves onto land should revert to the old value.""" + land = _BandLand() + curve_old = _curve() + curve_new = curve_old + jnp.array([[[0.0, 0.0], [0.0, 0.5], [0.0, 0.0]]]) + + constrained = _apply_curve_constraints( + curve_new, + curve_old, + land=land, + penalty=1.0, + ) + + assert jnp.allclose(constrained, curve_old) + + def test_still_invalid_land_waypoint_keeps_new_value(self): + """An already-invalid waypoint should keep moving even if still invalid.""" + land = _BandLand() + curve_old = _curve() + jnp.array([[[0.0, 0.0], [0.0, 0.5], [0.0, 0.0]]]) + curve_new = curve_old + jnp.array([[[0.0, 0.0], [0.0, -0.1], [0.0, 0.0]]]) + + constrained = _apply_curve_constraints( + curve_new, + curve_old, + land=land, + penalty=1.0, + ) + + assert jnp.allclose(constrained, curve_new) + + +class TestOptimizeFmsWeatherLimits: + def test_fms_escapes_violating_initial_route(self): + """When the initial route violates weather, FMS should still keep moving. + + Newly-invalid updates are rejected, but already-invalid routes are allowed + to evolve so the solver can escape the violation. + """ + src = jnp.array([0.0, 0.0]) + dst = jnp.array([6.0, 2.0]) + + call_count = {"n": 0} + + def counting_violating_windfield(lon, lat, t): + call_count["n"] += 1 + return jnp.full_like(lon, 25.0), jnp.zeros_like(lon) + + _, info = optimize_fms( + vectorfield_fourvortices, + src=src, + dst=dst, + num_curves=1, + num_points=10, + travel_time=5.0, + maxfevals=20, + patience=5, + verbose=False, + enforce_weather_limits=True, + windfield=counting_violating_windfield, + ) + + assert info["niter"] > 0 + assert call_count["n"] > 0 + + @pytest.mark.parametrize("enforce", [False, True]) + def test_fms_runs_without_weather_fields(self, enforce): + """optimize_fms must not raise when enforce_weather_limits=True but no + windfield/wavefield is given.""" + src = jnp.array([0.0, 0.0]) + dst = jnp.array([6.0, 2.0]) + + _, info = optimize_fms( + vectorfield_fourvortices, + src=src, + dst=dst, + num_curves=1, + num_points=10, + travel_time=5.0, + maxfevals=5, + patience=3, + verbose=False, + enforce_weather_limits=enforce, + ) + + assert info["niter"] <= 5 + + def test_custom_costfun_can_capture_fields_internally(self): + """Custom FMS cost closures should work without vectorfield injection.""" + src = jnp.array([0.0, 0.0]) + dst = jnp.array([6.0, 2.0]) + + def custom_cost(curve): + return jnp.sum((curve[:, 1:, :] - curve[:, :-1, :]) ** 2, axis=(1, 2)) + + _, info = optimize_fms( + vectorfield_fourvortices, + src=src, + dst=dst, + num_curves=1, + num_points=10, + travel_time=5.0, + maxfevals=2, + patience=2, + verbose=False, + costfun=custom_cost, + ) + + assert info["niter"] <= 2 + + def test_custom_costfun_accepts_explicit_kwargs(self): + """Custom FMS costs should receive forwarded explicit kwargs.""" + src = jnp.array([0.0, 0.0]) + dst = jnp.array([6.0, 2.0]) + seen: dict[str, float] = {} + + def custom_cost(*, curve, travel_time=None, scale=1.0, **kwargs): + seen["scale"] = scale + return scale * jnp.sum( + (curve[:, 1:, :] - curve[:, :-1, :]) ** 2, + axis=(1, 2), + ) + + _, info = optimize_fms( + vectorfield_fourvortices, + src=src, + dst=dst, + num_curves=1, + num_points=10, + travel_time=5.0, + maxfevals=2, + patience=2, + verbose=False, + costfun=custom_cost, + costfun_kwargs={"scale": 2.5}, + ) + + assert info["niter"] <= 2 + assert seen["scale"] == pytest.approx(2.5) + + def test_fms_snapshot_callback_receives_current_and_best_routes(self): + """FMS snapshot callback should expose current and best-so-far routes.""" + snapshots: list[dict[str, object]] = [] + + def zero_field(lon, lat, t): + return jnp.zeros_like(lon), jnp.zeros_like(lat) + + curve_init = jnp.array( + [[[0.0, 0.0], [1.0, 0.5], [2.0, 0.0]]], + dtype=jnp.float32, + ) + + _, info = optimize_fms( + zero_field, + curve=curve_init, + travel_time=2.0, + damping=0.0, + maxfevals=3, + patience=3, + verbose=False, + snapshot_callback=lambda snapshot: snapshots.append( + { + "iteration": snapshot["iteration"], + "curve_shape": tuple(snapshot["curve"].shape), + "cost_shape": tuple(snapshot["cost"].shape), + "best_curve_shape": tuple(snapshot["best_curve"].shape), + "best_cost_shape": tuple(snapshot["best_cost"].shape), + } + ), + ) + + assert len(snapshots) == info["niter"] + assert snapshots[0]["iteration"] == 1 + assert all(item["curve_shape"] == (1, 3, 2) for item in snapshots) + assert all(item["cost_shape"] == (1,) for item in snapshots) + assert all(item["best_curve_shape"] == (1, 3, 2) for item in snapshots) + assert all(item["best_cost_shape"] == (1,) for item in snapshots) + + def test_fms_accepts_initially_invalid_land_waypoint(self): + """FMS should improve an initially invalid waypoint instead of raising.""" + land = _BandLand() + curve_init = jnp.array( + [[[0.0, 0.0], [1.0, 0.5], [2.0, 0.0]]], + dtype=jnp.float32, + ) + + def zero_field(lon, lat, t): + return jnp.zeros_like(lon), jnp.zeros_like(lat) + + curve_out, info = optimize_fms( + zero_field, + curve=curve_init, + land=land, + penalty=1.0, + travel_time=2.0, + damping=0.0, + maxfevals=5, + patience=5, + verbose=False, + ) + + assert info["niter"] > 0 + assert float(curve_out[0, 1, 1]) < float(curve_init[0, 1, 1]) + + +class TestFmsConvergence: + def test_sinusoid_converges_to_straight_line(self): + """FMS with zero wind should straighten a sinusoidal route to a straight line. + + The default physics cost ‖SOG‖² (wind = 0) is minimised by the + shortest path, so the Euler-Lagrange equations drive every interior + waypoint toward the straight line connecting the two endpoints. + + Convergence rate per iteration = cos(π/(L-1)). For L=10, this is + ~0.940, so after 100 Jacobi steps (damping=0) the initial amplitude + of 0.5 is reduced to < 1e-3, well within the 0.05 tolerance. + """ + n_pts = 10 + x = jnp.linspace(0.0, 6.0, n_pts) + # Half-period sinusoid: y = 0 at both endpoints, peak amplitude 0.5 + y = 0.5 * jnp.sin(jnp.pi * x / 6.0) + curve_init = jnp.stack([x, y], axis=-1)[None, ...] # shape (1, n_pts, 2) + + def zero_field(lon, lat, t): + return jnp.zeros_like(lon), jnp.zeros_like(lat) + + curve_out, _ = optimize_fms( + zero_field, + curve=curve_init, + travel_time=6.0, + damping=0.0, + maxfevals=100, + patience=20, + verbose=False, + ) + + # All interior y-coordinates should be near 0 (straight line y = 0) + interior_y = curve_out[0, 1:-1, 1] + assert float(jnp.abs(interior_y).max()) < 0.05 diff --git a/tests/test_land.py b/tests/test_land.py index 55cd497d..6db78964 100644 --- a/tests/test_land.py +++ b/tests/test_land.py @@ -89,3 +89,56 @@ def test_distance_to_land(): ] ) assert jnp.allclose(dists, expected_dists, atol=1e3) + + +class TestDistancePenalty: + """Tests for the EDT-based distance_penalty method.""" + + @staticmethod + def _make_land() -> Land: + """Create a land with a known land patch in the centre.""" + xlim = [-5, 5] + land = Land( + xlim, + xlim, + water_level=0.5, + random_seed=1, + resolution=10, + interpolate=0, + ) + # All water, then add a land patch + land._array = land._array.at[:, :].set(0) + land._array = land._array.at[45:55, 45:55].set(1) + # Recompute EDT after modifying the land array + import numpy as np + from scipy.ndimage import distance_transform_edt + + binary_land = np.asarray(land._array > land.water_level) + land._edt = jnp.asarray(distance_transform_edt(~binary_land), dtype=jnp.float32) + return land + + def test_penalty_on_land_higher(self): + """Points on land should get a higher penalty than points in water.""" + land = self._make_land() + # Curve through land (centre) + on_land = jnp.array([[[0.0, 0.0], [0.0, 0.0]]]) + # Curve far from land (corner) + far_water = jnp.array([[[-4.0, -4.0], [-4.0, -4.0]]]) + p_land = land.distance_penalty(on_land, weight=1.0) + p_water = land.distance_penalty(far_water, weight=1.0) + assert p_land.item() > p_water.item() + + def test_weight_scaling(self): + """Penalty should scale linearly with weight.""" + land = self._make_land() + curve = jnp.array([[[-3.0, -3.0], [-2.0, -2.0]]]) + p1 = land.distance_penalty(curve, weight=1.0) + p5 = land.distance_penalty(curve, weight=5.0) + assert p5.item() == pytest.approx(p1.item() * 5.0, rel=1e-5) + + def test_non_negative(self): + """Penalty should always be non-negative.""" + land = self._make_land() + curve = jnp.array([[[-4.5, -4.5], [4.5, 4.5]]]) + p = land.distance_penalty(curve, weight=1.0) + assert p.item() >= 0.0 diff --git a/tests/test_land_waves.py b/tests/test_land_waves.py new file mode 100644 index 00000000..804c73f3 --- /dev/null +++ b/tests/test_land_waves.py @@ -0,0 +1,63 @@ +import numpy as np +import xarray as xr + +from routetools.wrr_bench.ocean import Ocean + + +def test_waves_create_land_when_geojson_missing(): + # Create a small synthetic waves dataset with a NaN blob + times = np.array(["2024-01-01", "2024-01-02"]).astype("datetime64[ns]") + lat = np.linspace(0, 3, 4) + lon = np.linspace(0, 4, 5) + + # shape (time, lat, lon) + height = np.ones((len(times), len(lat), len(lon)), dtype=float) + # Insert NaNs in a small rectangular region (time x lat x lon) + height[:, 1:3, 2:4] = np.nan + + # Build dataset by assigning DataArrays directly + ds = xr.Dataset() + ds["height"] = xr.DataArray( + height, + dims=("time", "latitude", "longitude"), + coords={"time": times, "latitude": lat, "longitude": lon}, + ) + ds["direction"] = xr.DataArray( + np.zeros_like(height), + dims=("time", "latitude", "longitude"), + coords={"time": times, "latitude": lat, "longitude": lon}, + ) + + # Instantiate Ocean with no land_file so it should use waves NaN mask + ocean = Ocean( + currents_data=None, + waves_data=ds, + wind_data=None, + currents_interpolator=object(), + waves_interpolator=object(), + wind_interpolator=object(), + land_file=None, + use_ice=True, + erode_ice=0, + prepare_geom=False, + ) + + # pick a point inside the NaN rectangle by mapping pixel center to lat/lon + height_n = len(lat) + width_n = len(lon) + y_center = 1.5 # center between pixel rows 1 and 2 + x_center = 2.5 # center between pixel cols 2 and 3 + lat_pt = lat[0] + (lat[-1] - lat[0]) * (y_center / height_n) + lon_pt = lon[0] + (lon[-1] - lon[0]) * (x_center / width_n) + + # Inside land + land_flag = ocean.get_land(np.array([lat_pt]), np.array([lon_pt]))[0] + assert land_flag == 1 + + # Outside land (pixel center between row 0 and 1, col 0 and 1) + y_out = 0.5 + x_out = 0.5 + lat_pt_out = lat[0] + (lat[-1] - lat[0]) * (y_out / height_n) + lon_pt_out = lon[0] + (lon[-1] - lon[0]) * (x_out / width_n) + land_flag_out = ocean.get_land(np.array([lat_pt_out]), np.array([lon_pt_out]))[0] + assert land_flag_out == 0 diff --git a/tests/test_parametric_model.py b/tests/test_parametric_model.py new file mode 100644 index 00000000..9b48417d --- /dev/null +++ b/tests/test_parametric_model.py @@ -0,0 +1,618 @@ +"""Test bench: routetools.performance vs SWOPP3 reference. + +Compares our closed-form parametric model (``routetools.performance``) +against the compiled SWOPP3 performance model (``predict_no_wps`` and +``predict_with_wps``) across structured grids, edge cases, and random +stress tests. +""" + +from __future__ import annotations + +import jax.numpy as jnp +import numpy as np +import pytest + +from routetools.performance import ( + K_H, + predict_power, + predict_power_batch, +) +from routetools.performance import ( + predict_power_no_wps as parametric_no_wps, +) +from routetools.performance import ( + predict_power_with_wps as parametric_with_wps, +) + +# --------------------------------------------------------------------------- +# Reference package: only skip tests that actually use SWOPP3 +# --------------------------------------------------------------------------- +try: + import swopp3_performance_model as swopp3 # type: ignore[import-untyped] +except ModuleNotFoundError: + swopp3 = None # type: ignore[assignment] + +needs_swopp3 = pytest.mark.skipif( + swopp3 is None, + reason="swopp3_performance_model wheel is not installed", +) + + +# =================================================================== +# predict_no_wps tests +# =================================================================== +@needs_swopp3 +class TestNoWPS: + """Parametric model vs reference predict_no_wps.""" + + # ------ Structured grid ------ + @pytest.mark.parametrize( + "tws", + [0, 2, 5, 10, 15, 20, 25, 30], + ) + @pytest.mark.parametrize( + "twa", + [0, 30, 60, 90, 120, 150, 180], + ) + @pytest.mark.parametrize( + "v", + [0, 2, 5, 8, 10, 12, 14], + ) + def test_structured_grid_calm_sea(self, tws: float, twa: float, v: float) -> None: + """No waves (swh=0, mwa=0): hull + wind only.""" + ref = swopp3.predict_no_wps(tws, twa, 0.0, 0.0, v) + par = parametric_no_wps(tws, twa, 0.0, 0.0, v) + assert abs(par - ref) < 0.15, ( + f"tws={tws}, twa={twa}, v={v}: ref={ref:.4f}, par={par:.4f}, " + f"err={abs(par - ref):.4f}" + ) + + @pytest.mark.parametrize( + "swh", + [0, 1, 2, 4, 6, 8], + ) + @pytest.mark.parametrize( + "mwa", + [0, 45, 90, 135, 180], + ) + @pytest.mark.parametrize( + "v", + [0, 3, 7, 10, 14], + ) + def test_structured_grid_wave_only(self, swh: float, mwa: float, v: float) -> None: + """No wind (tws=0): hull + wave only.""" + ref = swopp3.predict_no_wps(0.0, 0.0, swh, mwa, v) + par = parametric_no_wps(0.0, 0.0, swh, mwa, v) + assert abs(par - ref) < 0.15, ( + f"swh={swh}, mwa={mwa}, v={v}: ref={ref:.4f}, par={par:.4f}, " + f"err={abs(par - ref):.4f}" + ) + + @pytest.mark.parametrize( + "tws,twa,swh,mwa,v", + [ + (10, 45, 2, 30, 8), + (15, 90, 3, 90, 10), + (20, 135, 5, 150, 6), + (5, 0, 1, 0, 12), + (25, 180, 4, 180, 4), + (8, 60, 6, 45, 14), + (12, 120, 0.5, 60, 9), + (30, 0, 8, 0, 2), + ], + ) + def test_combined_representative( + self, + tws: float, + twa: float, + swh: float, + mwa: float, + v: float, + ) -> None: + """Hand-picked combined-condition cases.""" + ref = swopp3.predict_no_wps(tws, twa, swh, mwa, v) + par = parametric_no_wps(tws, twa, swh, mwa, v) + assert abs(par - ref) < 0.15, ( + f"tws={tws}, twa={twa}, swh={swh}, mwa={mwa}, v={v}: " + f"ref={ref:.4f}, par={par:.4f}, err={abs(par - ref):.4f}" + ) + + # ------ Edge cases ------ + def test_all_zeros(self) -> None: + """Zero inputs should give zero power.""" + ref = swopp3.predict_no_wps(0, 0, 0, 0, 0) + par = parametric_no_wps(0, 0, 0, 0, 0) + assert ref == 0.0 + assert par == 0.0 + + def test_zero_speed(self) -> None: + """At v=0, all power terms vanish regardless of environment.""" + for tws in [0, 10, 20, 30]: + for swh in [0, 3, 8]: + ref = swopp3.predict_no_wps(tws, 90, swh, 90, 0) + par = parametric_no_wps(tws, 90, swh, 90, 0) + assert ref == 0.0 + assert par == 0.0 + + def test_twa_symmetry(self) -> None: + """Power should be symmetric around TWA=0 (same for +/- angles).""" + for tws, twa, v in [(10, 30, 8), (15, 60, 5), (20, 90, 10)]: + ref_pos = swopp3.predict_no_wps(tws, twa, 2, 45, v) + ref_neg = swopp3.predict_no_wps(tws, -twa, 2, 45, v) + par_pos = parametric_no_wps(tws, twa, 2, 45, v) + par_neg = parametric_no_wps(tws, -twa, 2, 45, v) + assert abs(ref_pos - ref_neg) < 1e-10 + assert abs(par_pos - par_neg) < 1e-10 + + def test_mwa_symmetry(self) -> None: + """Power should be symmetric around MWA=0.""" + for mwa in [30, 60, 90, 120, 150]: + ref_pos = swopp3.predict_no_wps(10, 45, 3, mwa, 8) + ref_neg = swopp3.predict_no_wps(10, 45, 3, -mwa, 8) + par_pos = parametric_no_wps(10, 45, 3, mwa, 8) + par_neg = parametric_no_wps(10, 45, 3, -mwa, 8) + assert abs(ref_pos - ref_neg) < 1e-10 + assert abs(par_pos - par_neg) < 1e-10 + + def test_clamping_at_zero(self) -> None: + """Strong tailwind should clamp power at zero.""" + ref = swopp3.predict_no_wps(25, 180, 0, 0, 2) + par = parametric_no_wps(25, 180, 0, 0, 2) + assert ref == 0.0 + assert par == 0.0 + + # ------ Random stress test ------ + def test_random_stress_no_wps(self) -> None: + """10 000 random inputs: max absolute error < 0.15 kW.""" + rng = np.random.default_rng(42) + n = 10_000 + tws = rng.uniform(0, 30, n) + twa = rng.uniform(0, 180, n) + swh = rng.uniform(0, 10, n) + mwa = rng.uniform(0, 180, n) + v = rng.uniform(0, 14.5, n) + + ref_vals = np.array( + [ + swopp3.predict_no_wps(tws[i], twa[i], swh[i], mwa[i], v[i]) + for i in range(n) + ] + ) + par_vals = predict_power_batch(tws, twa, swh, mwa, v, wps=False) + abs_errs = np.abs(par_vals - ref_vals) + + max_err = abs_errs.max() + mean_err = abs_errs.mean() + p99_err = np.percentile(abs_errs, 99) + + # Report + print( + f"\n[no_wps random stress] n={n}, " + f"max_err={max_err:.4f} kW, mean_err={mean_err:.4f} kW, " + f"p99_err={p99_err:.4f} kW" + ) + + assert max_err < 0.15, f"Max absolute error too large: {max_err:.4f} kW" + + +# =================================================================== +# predict_with_wps tests +# =================================================================== +@needs_swopp3 +class TestWithWPS: + """Parametric model vs reference predict_with_wps.""" + + @pytest.mark.parametrize( + "tws,twa,swh,mwa,v", + [ + (10, 45, 2, 30, 8), + (15, 90, 3, 90, 10), + (20, 135, 5, 150, 6), + (5, 0, 1, 0, 12), + (25, 180, 4, 180, 4), + (8, 60, 6, 45, 14), + (12, 120, 0.5, 60, 9), + (30, 0, 8, 0, 2), + ], + ) + def test_combined_representative( + self, + tws: float, + twa: float, + swh: float, + mwa: float, + v: float, + ) -> None: + """Hand-picked combined-condition cases.""" + ref = swopp3.predict_with_wps(tws, twa, swh, mwa, v) + par = parametric_with_wps(tws, twa, swh, mwa, v) + assert abs(par - ref) < 0.1, ( + f"tws={tws}, twa={twa}, swh={swh}, mwa={mwa}, v={v}: " + f"ref={ref:.4f}, par={par:.4f}, err={abs(par - ref):.4f}" + ) + + @pytest.mark.parametrize( + "tws", + [0, 5, 10, 15, 20, 25, 30], + ) + @pytest.mark.parametrize( + "twa", + [0, 45, 90, 135, 180], + ) + @pytest.mark.parametrize( + "v", + [0, 4, 8, 12], + ) + def test_grid_on_nodes( + self, + tws: float, + twa: float, + v: float, + ) -> None: + """Structured grid: tighter tolerance (closed-form, no interp).""" + for swh, mwa in [(0, 0), (2, 45), (5, 120)]: + ref = swopp3.predict_with_wps(tws, twa, swh, mwa, v) + par = parametric_with_wps(tws, twa, swh, mwa, v) + assert abs(par - ref) < 0.1, ( + f"tws={tws}, twa={twa}, swh={swh}, mwa={mwa}, v={v}: " + f"ref={ref:.4f}, par={par:.4f}, err={abs(par - ref):.4f}" + ) + + def test_wps_always_leq_no_wps(self) -> None: + """With WPS should never exceed without WPS (sails only help).""" + rng = np.random.default_rng(123) + for _ in range(500): + tws = rng.uniform(0, 30) + twa = rng.uniform(0, 180) + swh = rng.uniform(0, 8) + mwa = rng.uniform(0, 180) + v = rng.uniform(0, 14.5) + ref_no = swopp3.predict_no_wps(tws, twa, swh, mwa, v) + ref_wp = swopp3.predict_with_wps(tws, twa, swh, mwa, v) + par_no = parametric_no_wps(tws, twa, swh, mwa, v) + par_wp = parametric_with_wps(tws, twa, swh, mwa, v) + assert ref_wp <= ref_no + 1e-10 + assert par_wp <= par_no + 1e-10 + + def test_random_stress_with_wps(self) -> None: + """10 000 random inputs: fully closed-form, tight tolerance.""" + rng = np.random.default_rng(99) + n = 10_000 + tws = rng.uniform(0, 30, n) + twa = rng.uniform(0, 180, n) + swh = rng.uniform(0, 10, n) + mwa = rng.uniform(0, 180, n) + v = rng.uniform(0, 14.5, n) + + ref_vals = np.array( + [ + swopp3.predict_with_wps(tws[i], twa[i], swh[i], mwa[i], v[i]) + for i in range(n) + ] + ) + par_vals = predict_power_batch(tws, twa, swh, mwa, v, wps=True) + abs_errs = np.abs(par_vals - ref_vals) + + max_err = abs_errs.max() + mean_err = abs_errs.mean() + p99_err = np.percentile(abs_errs, 99) + + print( + f"\n[with_wps random stress] n={n}, " + f"max_err={max_err:.4f} kW, mean_err={mean_err:.4f} kW, " + f"p99_err={p99_err:.4f} kW" + ) + + assert mean_err < 0.01, f"Mean absolute error too large: {mean_err:.4f} kW" + assert p99_err < 0.1, f"p99 absolute error too large: {p99_err:.4f} kW" + assert max_err < 0.15, f"Max absolute error too large: {max_err:.4f} kW" + + +# =================================================================== +# Decomposition / additivity property tests +# =================================================================== +@needs_swopp3 +class TestDecomposition: + """Verify structural properties of the parametric decomposition.""" + + def test_hull_cubic(self) -> None: + """P_hull = K_h · v³ (pure cubic in speed, no env dependence).""" + for v in [1, 3, 5, 8, 10, 12, 14]: + # Hull-only = no wind, no waves + ref = swopp3.predict_no_wps(0, 0, 0, 0, v) + expected = K_H * v**3 + assert ( + abs(ref - expected) < 0.01 + ), f"v={v}: ref={ref:.4f}, K_h·v³={expected:.4f}" + + def test_additivity_wind_wave(self) -> None: + """Wind and wave components are additive (no cross-terms).""" + for tws, twa, swh, mwa, v in [ + (10, 45, 3, 60, 8), + (15, 90, 2, 120, 5), + (20, 0, 5, 0, 10), + ]: + p_hull = swopp3.predict_no_wps(0, 0, 0, 0, v) + p_hull_wind = swopp3.predict_no_wps(tws, twa, 0, 0, v) + p_hull_wave = swopp3.predict_no_wps(0, 0, swh, mwa, v) + p_all = swopp3.predict_no_wps(tws, twa, swh, mwa, v) + + p_wind = p_hull_wind - p_hull + p_wave = p_hull_wave - p_hull + combined = p_hull + p_wind + p_wave + + # Only compare if not clamped + if p_all > 0 and combined > 0: + assert ( + abs(p_all - combined) < 0.01 + ), f"Additivity fail: combined={combined:.4f}, ref={p_all:.4f}" + + def test_wave_swh_squared(self) -> None: + """Wave power ∝ swh² at fixed (mwa, v).""" + v = 8.0 + mwa = 45.0 + p1 = swopp3.predict_no_wps(0, 0, 1.0, mwa, v) - swopp3.predict_no_wps( + 0, 0, 0, 0, v + ) + p2 = swopp3.predict_no_wps(0, 0, 2.0, mwa, v) - swopp3.predict_no_wps( + 0, 0, 0, 0, v + ) + p4 = swopp3.predict_no_wps(0, 0, 4.0, mwa, v) - swopp3.predict_no_wps( + 0, 0, 0, 0, v + ) + assert abs(p2 / p1 - 4.0) < 1e-6, f"ratio p2/p1 = {p2/p1:.6f}, expected 4" + assert abs(p4 / p1 - 16.0) < 1e-6, f"ratio p4/p1 = {p4/p1:.6f}, expected 16" + + def test_wave_speed_exponent(self) -> None: + """Wave power ∝ v^1.5 at fixed (swh, mwa).""" + swh = 3.0 + mwa = 60.0 + powers = [] + speeds = [4.0, 6.0, 8.0, 10.0] + for v in speeds: + p = swopp3.predict_no_wps(0, 0, swh, mwa, v) - swopp3.predict_no_wps( + 0, 0, 0, 0, v + ) + powers.append(p) + + for i in range(1, len(speeds)): + ratio = powers[i] / powers[0] + expected = (speeds[i] / speeds[0]) ** 1.5 + assert ( + abs(ratio - expected) < 1e-4 + ), f"v={speeds[i]}: ratio={ratio:.6f}, expected={expected:.6f}" + + def test_sail_wave_independent(self) -> None: + """Sail power P_sail(tws,twa,v) is independent of waves. + + Uses high-speed scenarios with head-on waves (mwa=0) to keep + total power well above zero, so the difference P_no − P_wps + reveals the true sail thrust without hitting the clamp. + All inputs stay within documented ranges (SWH ∈ [0, 10]). + """ + for tws, twa, v in [(10, 90, 8), (20, 45, 10), (15, 135, 12)]: + # Baseline sail power (moderate SWH, head-on waves) + p_no_base = swopp3.predict_no_wps(tws, twa, 5.0, 0.0, v) + p_wp_base = swopp3.predict_with_wps(tws, twa, 5.0, 0.0, v) + assert p_wp_base > 0, "Baseline should not be clamped" + p_sail_base = p_no_base - p_wp_base + + # Compare against different in-range wave conditions + for swh, mwa in [(2.0, 0), (5.0, 45), (8.0, 0)]: + p_no = swopp3.predict_no_wps(tws, twa, swh, mwa, v) + p_wp = swopp3.predict_with_wps(tws, twa, swh, mwa, v) + assert p_wp > 0, f"Should not be clamped: swh={swh}, mwa={mwa}" + p_sail = p_no - p_wp + assert abs(p_sail - p_sail_base) < 0.01, ( + f"Sail depends on waves! mwa={mwa}: " + f"p_sail={p_sail:.4f} vs base={p_sail_base:.4f}" + ) + + +# =================================================================== +# Public API tests (predict_power, predict_power_batch) +# =================================================================== +class TestPublicAPI: + """Tests for the unified predict_power and batch entry points.""" + + def test_predict_power_dispatches_no_wps(self) -> None: + """predict_power(wps=False) matches predict_power_no_wps.""" + for tws, twa, swh, mwa, v in [(10, 90, 2, 45, 8), (0, 0, 0, 0, 5)]: + expected = parametric_no_wps(tws, twa, swh, mwa, v) + result = predict_power(tws, twa, swh, mwa, v, wps=False) + assert result == expected + + def test_predict_power_dispatches_wps(self) -> None: + """predict_power(wps=True) matches predict_power_with_wps.""" + for tws, twa, swh, mwa, v in [(10, 90, 2, 45, 8), (20, 135, 5, 150, 6)]: + expected = parametric_with_wps(tws, twa, swh, mwa, v) + result = predict_power(tws, twa, swh, mwa, v, wps=True) + assert result == expected + + def test_predict_power_default_no_wps(self) -> None: + """Default wps=False.""" + result = predict_power(10, 90, 2, 45, 8) + expected = parametric_no_wps(10, 90, 2, 45, 8) + assert result == expected + + def test_batch_matches_scalar_no_wps(self) -> None: + """Batch output matches scalar loop for no-WPS mode.""" + rng = np.random.default_rng(777) + n = 100 + tws = rng.uniform(0, 30, n) + twa = rng.uniform(0, 180, n) + swh = rng.uniform(0, 10, n) + mwa = rng.uniform(0, 360, n) + v = rng.uniform(0, 14.5, n) + + batch = predict_power_batch(tws, twa, swh, mwa, v, wps=False) + for i in range(n): + scalar = parametric_no_wps(tws[i], twa[i], swh[i], mwa[i], v[i]) + assert ( + abs(batch[i] - scalar) < 1e-10 + ), f"i={i}: batch={batch[i]:.6f}, scalar={scalar:.6f}" + + def test_batch_matches_scalar_wps(self) -> None: + """Batch output matches scalar loop for WPS mode.""" + rng = np.random.default_rng(888) + n = 100 + tws = rng.uniform(0, 30, n) + twa = rng.uniform(0, 180, n) + swh = rng.uniform(0, 10, n) + mwa = rng.uniform(0, 360, n) + v = rng.uniform(0, 14.5, n) + + batch = predict_power_batch(tws, twa, swh, mwa, v, wps=True) + for i in range(n): + scalar = parametric_with_wps(tws[i], twa[i], swh[i], mwa[i], v[i]) + assert ( + abs(batch[i] - scalar) < 1e-10 + ), f"i={i}: batch={batch[i]:.6f}, scalar={scalar:.6f}" + + def test_batch_broadcasting(self) -> None: + """Batch supports broadcasting (scalar v with array tws).""" + tws = np.array([5, 10, 15, 20]) + result = predict_power_batch(tws, 90, 2, 45, 8, wps=False) + assert result.shape == (4,) + for i, tw in enumerate(tws): + expected = parametric_no_wps(tw, 90, 2, 45, 8) + assert abs(result[i] - expected) < 1e-10 + + +class TestJaxParity: + """Verify predict_power_jax matches predict_power_batch numerically.""" + + def test_parity_no_wps(self) -> None: + """JAX and NumPy outputs agree on a random grid (no WPS). + + JAX defaults to float32; tolerances reflect the difference + vs NumPy float64. + """ + from routetools.performance import predict_power_jax + + rng = np.random.default_rng(2025) + n = 500 + tws = rng.uniform(0, 30, n) + twa = rng.uniform(0, 180, n) + swh = rng.uniform(0, 10, n) + mwa = rng.uniform(0, 360, n) + v = rng.uniform(0, 14.5, n) + + np_result = predict_power_batch(tws, twa, swh, mwa, v, wps=False) + jax_result = np.asarray( + predict_power_jax( + jnp.array(tws), + jnp.array(twa), + jnp.array(swh), + jnp.array(mwa), + jnp.array(v), + wps=False, + ) + ) + + np.testing.assert_allclose( + jax_result, + np_result, + rtol=1e-5, + atol=0.02, + err_msg="predict_power_jax (no WPS) diverges from predict_power_batch", + ) + + def test_parity_with_wps(self) -> None: + """JAX and NumPy outputs agree on a random grid (with WPS). + + JAX defaults to float32; tolerances reflect the difference + vs NumPy float64. + """ + from routetools.performance import predict_power_jax + + rng = np.random.default_rng(2026) + n = 500 + tws = rng.uniform(0, 30, n) + twa = rng.uniform(0, 180, n) + swh = rng.uniform(0, 10, n) + mwa = rng.uniform(0, 360, n) + v = rng.uniform(0, 14.5, n) + + np_result = predict_power_batch(tws, twa, swh, mwa, v, wps=True) + jax_result = np.asarray( + predict_power_jax( + jnp.array(tws), + jnp.array(twa), + jnp.array(swh), + jnp.array(mwa), + jnp.array(v), + wps=True, + ) + ) + + np.testing.assert_allclose( + jax_result, + np_result, + rtol=1e-5, + atol=0.02, + err_msg="predict_power_jax (WPS) diverges from predict_power_batch", + ) + + +# =================================================================== +# MWA wrapping regression test +# =================================================================== +class TestMWAWrapping: + """Regression tests for MWA angle wrapping (GH issue: wave term + vanished for MWA near 360° because radians(356°) = 6.2 rad made + exp(-K_W·|6.2|³) ≈ 0).""" + + @pytest.mark.parametrize( + "mwa_pair", + [ + (10, 350), + (5, 355), + (1, 359), + (30, 330), + (0, 360), + ], + ) + def test_mwa_symmetry_scalar(self, mwa_pair: tuple[float, float]) -> None: + """MWA=x and MWA=(360-x) must give identical power (symmetry).""" + mwa_a, mwa_b = mwa_pair + for wps in (True, False): + fn = parametric_with_wps if wps else parametric_no_wps + pa = fn(10.0, 60.0, 4.0, mwa_a, 6.0) + pb = fn(10.0, 60.0, 4.0, mwa_b, 6.0) + assert abs(pa - pb) < 1e-10, ( + f"wps={wps}, mwa={mwa_a} vs {mwa_b}: " + f"power_a={pa:.4f}, power_b={pb:.4f}" + ) + + def test_mwa_near_360_has_wave_contribution(self) -> None: + """MWA=356° (nearly following seas) must have non-zero wave power.""" + # swh=5 m ensures a large wave term when MWA is near 0/360 + p = parametric_no_wps(10.0, 60.0, 5.0, 356.0, 6.0) + p_no_wave = parametric_no_wps(10.0, 60.0, 0.0, 0.0, 6.0) + assert p > p_no_wave + 100.0, ( + f"MWA=356° with swh=5 must add substantial wave resistance, " + f"got p={p:.1f} vs no-wave={p_no_wave:.1f}" + ) + + def test_mwa_batch_symmetry(self) -> None: + """Batch version: MWA and (360-MWA) give same results.""" + mwa_a = np.array([10, 5, 1, 30, 0], dtype=np.float64) + mwa_b = 360.0 - mwa_a + tws = np.full(5, 10.0) + twa = np.full(5, 60.0) + swh = np.full(5, 4.0) + v = np.full(5, 6.0) + pa = predict_power_batch(tws, twa, swh, mwa_a, v, wps=False) + pb = predict_power_batch(tws, twa, swh, mwa_b, v, wps=False) + np.testing.assert_allclose(pa, pb, atol=1e-10) + + @needs_swopp3 + @pytest.mark.parametrize("mwa", [330, 340, 350, 355, 358, 359]) + def test_mwa_high_angles_vs_wheel(self, mwa: float) -> None: + """Parametric model matches wheel for MWA > 180°.""" + ref = swopp3.predict_no_wps(12.0, 90.0, 4.0, mwa, 7.0) + par = parametric_no_wps(12.0, 90.0, 4.0, mwa, 7.0) + assert ( + abs(par - ref) < 0.15 + ), f"mwa={mwa}: ref={ref:.4f}, par={par:.4f}, err={abs(par - ref):.4f}" diff --git a/tests/test_resample_track.py b/tests/test_resample_track.py new file mode 100644 index 00000000..b2e90f84 --- /dev/null +++ b/tests/test_resample_track.py @@ -0,0 +1,773 @@ +"""Tests for track resampling with geodesic interpolation. + +Tests the resample_track() function that will be used by the CodaBench scorer +to decouple waypoint density (Δt₁) from energy integration accuracy (Δt₂). + +Two test categories: +1. Unit tests for resample_track itself (deterministic, no ERA5 data). +2. Integration tests for convergence and invariance (require ERA5 data). +""" + +from __future__ import annotations + +import csv +import math +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import numpy as np +import pytest + +# netCDF4 may warn about numpy binary incompatibility on some platforms +pytestmark = pytest.mark.filterwarnings( + "ignore:numpy.ndarray size changed:RuntimeWarning", +) + + +# ── resample_track implementation (to be moved to scorer later) ────── + + +def slerp( + lat1: float, + lon1: float, + lat2: float, + lon2: float, + f: float, +) -> tuple[float, float]: + """Spherical linear interpolation at fraction *f* in [0, 1]. + + Parameters and return values are in **degrees**. + """ + phi1, lam1 = math.radians(lat1), math.radians(lon1) + phi2, lam2 = math.radians(lat2), math.radians(lon2) + + # To Cartesian unit vectors + p1 = np.array( + [ + math.cos(phi1) * math.cos(lam1), + math.cos(phi1) * math.sin(lam1), + math.sin(phi1), + ] + ) + p2 = np.array( + [ + math.cos(phi2) * math.cos(lam2), + math.cos(phi2) * math.sin(lam2), + math.sin(phi2), + ] + ) + + dot = float(np.clip(np.dot(p1, p2), -1.0, 1.0)) + sigma = math.acos(dot) + + if sigma < 1e-12: + # Nearly coincident – linear fallback + lat = lat1 + f * (lat2 - lat1) + lon = lon1 + f * ((lon2 - lon1 + 180) % 360 - 180) + return lat, lon + + a = math.sin((1 - f) * sigma) / math.sin(sigma) + b = math.sin(f * sigma) / math.sin(sigma) + p = a * p1 + b * p2 + + lat = math.degrees(math.atan2(p[2], math.sqrt(p[0] ** 2 + p[1] ** 2))) + lon = math.degrees(math.atan2(p[1], p[0])) + return lat, lon + + +def resample_track( + waypoints: list[tuple[datetime, float, float]], + dt_minutes: float = 15.0, +) -> list[tuple[datetime, float, float]]: + """Resample a track to uniform temporal spacing via geodesic interpolation. + + Parameters + ---------- + waypoints + List of ``(time, lat_deg, lon_deg)`` tuples — the original track. + dt_minutes + Target sub-segment interval in minutes. + + Returns + ------- + list[tuple[datetime, float, float]] + Resampled waypoints at approximately uniform Δt₂. + The first and last points are always preserved exactly. + """ + if len(waypoints) < 2: + return list(waypoints) + + result: list[tuple[datetime, float, float]] = [] + + for i in range(len(waypoints) - 1): + t0, lat0, lon0 = waypoints[i] + t1, lat1, lon1 = waypoints[i + 1] + + seg_seconds = (t1 - t0).total_seconds() + if seg_seconds <= 0: + result.append((t0, lat0, lon0)) + continue + + dt_seconds = dt_minutes * 60.0 + n_sub = max(1, math.ceil(seg_seconds / dt_seconds)) + + for j in range(n_sub): + f = j / n_sub + t = t0 + timedelta(seconds=f * seg_seconds) + lat, lon = slerp(lat0, lon0, lat1, lon1, f) + result.append((t, lat, lon)) + + # Always append the final waypoint + result.append(waypoints[-1]) + return result + + +# ── Helper: load a track CSV ──────────────────────────────────────── + + +def _load_track_csv( + path: Path, +) -> list[tuple[datetime, float, float]]: + """Load track CSV (time_utc, lat_deg, lon_deg) -> list of tuples.""" + with path.open() as f: + reader = csv.DictReader(f) + return [ + ( + datetime.strptime(row["time_utc"], "%Y-%m-%d %H:%M:%S").replace( + tzinfo=UTC, + ), + float(row["lat_deg"]), + float(row["lon_deg"]), + ) + for row in reader + ] + + +def _downsample_waypoints( + waypoints: list[tuple[datetime, float, float]], + factor: int, +) -> list[tuple[datetime, float, float]]: + """Keep every *factor*-th waypoint plus the last one.""" + result = waypoints[::factor] + if result[-1] != waypoints[-1]: + result.append(waypoints[-1]) + return result + + +# ══════════════════════════════════════════════════════════════════════ +# Unit tests (no data required) +# ══════════════════════════════════════════════════════════════════════ + + +class TestSlerp: + """Spherical linear interpolation.""" + + def test_endpoints(self) -> None: + lat, lon = slerp(34.8, 140.0, 34.4, -121.0, 0.0) + assert lat == pytest.approx(34.8, abs=1e-10) + assert lon == pytest.approx(140.0, abs=1e-10) + + lat, lon = slerp(34.8, 140.0, 34.4, -121.0, 1.0) + assert lat == pytest.approx(34.4, abs=1e-10) + assert lon == pytest.approx(-121.0, abs=1e-10) + + def test_midpoint_on_great_circle(self) -> None: + """Midpoint should be north of linear midpoint for east-west routes.""" + lat_mid, _ = slerp(34.8, 140.0, 34.4, -121.0, 0.5) + linear_lat = (34.8 + 34.4) / 2 + # Great circle from Japan to California goes north + assert lat_mid > linear_lat + 1.0 + + def test_coincident_points(self) -> None: + lat, lon = slerp(45.0, 10.0, 45.0, 10.0, 0.5) + assert lat == pytest.approx(45.0, abs=1e-6) + assert lon == pytest.approx(10.0, abs=1e-6) + + def test_antimeridian_crossing(self) -> None: + """Interpolation across the antimeridian should take the short way.""" + lat, lon = slerp(40.0, 170.0, 40.0, -170.0, 0.5) + # Should cross at lon ≈ 180, not go the long way around + assert abs(lon) > 170.0 + + def test_returns_degrees(self) -> None: + lat, lon = slerp(0.0, 0.0, 0.0, 90.0, 0.5) + assert -90 <= lat <= 90 + assert -180 <= lon <= 360 + + +class TestResampleTrack: + """resample_track() function.""" + + @pytest.fixture() + def simple_track(self) -> list[tuple[datetime, float, float]]: + """Two-point track: 2 hours apart.""" + t0 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) + return [ + (t0, 34.8, 140.0), + (t0 + timedelta(hours=2), 34.6, 139.0), + ] + + def test_preserves_endpoints(self, simple_track) -> None: + result = resample_track(simple_track, dt_minutes=15) + assert result[0] == simple_track[0] + assert result[-1] == simple_track[-1] + + def test_correct_point_count(self, simple_track) -> None: + # 2 hours = 120 min; dt=15 → ceil(120/15) = 8 sub-segments + final + result = resample_track(simple_track, dt_minutes=15) + assert len(result) == 9 # 8 sub-points from first segment + 1 final + + def test_uniform_spacing(self, simple_track) -> None: + result = resample_track(simple_track, dt_minutes=30) + # 120 min / 30 min = 4 sub-segments + times = [r[0] for r in result] + dts = [(times[i + 1] - times[i]).total_seconds() for i in range(len(times) - 1)] + # All should be 30 min = 1800 s + for dt_val in dts: + assert dt_val == pytest.approx(1800.0, abs=0.1) + + def test_single_waypoint_passthrough(self) -> None: + t0 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) + wps = [(t0, 34.8, 140.0)] + assert resample_track(wps) == wps + + def test_dt_larger_than_segment(self) -> None: + """If dt > segment duration, keep at least one sub-segment.""" + t0 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) + wps = [ + (t0, 34.8, 140.0), + (t0 + timedelta(minutes=5), 34.81, 140.01), + ] + result = resample_track(wps, dt_minutes=60) + # n_sub = max(1, ceil(5/60)) = 1 → start + final + assert len(result) == 2 + + def test_multi_segment(self) -> None: + """Three-point track: each segment resampled independently.""" + t0 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) + wps = [ + (t0, 34.8, 140.0), + (t0 + timedelta(hours=1), 35.0, 141.0), + (t0 + timedelta(hours=2), 35.2, 142.0), + ] + result = resample_track(wps, dt_minutes=30) + # Seg 1: 60 min / 30 = 2 sub -> 2 points + # Seg 2: 60 min / 30 = 2 sub -> 2 points + # + final = 5 + assert len(result) == 5 + + def test_all_lats_within_range(self) -> None: + """Pacific crossing should keep all latitudes in [-90, 90].""" + t0 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) + wps = [ + (t0, 34.8, 140.0), + (t0 + timedelta(hours=583), 34.4, -121.0), + ] + result = resample_track(wps, dt_minutes=60) + for _, lat, _lon in result: + assert -90 <= lat <= 90 + + +class TestCircularInterpolation: + """Circular (sin/cos) interpolation for angular variables like MWD.""" + + def _make_tiny_grid(self, mwd_values: np.ndarray) -> dict: + """Build a minimal 1×2×1 wave grid with given MWD at two time steps.""" + mwd = mwd_values.reshape(2, 1, 1).astype(np.float32) + mwd_rad = np.radians(mwd) + return { + "data": { + "mwd": mwd, + "mwd_sin": np.sin(mwd_rad).astype(np.float32), + "mwd_cos": np.cos(mwd_rad).astype(np.float32), + }, + "lat": np.array([40.0]), + "lon": np.array([10.0]), + "times_h": np.array([0.0, 1.0]), + "t0": np.datetime64("2024-01-01T00:00"), + } + + def test_no_wrap_matches_linear(self) -> None: + """Away from 0/360, circular and linear interp should agree.""" + grid = self._make_tiny_grid(np.array([100.0, 120.0])) + lat = np.array([40.0]) + lon = np.array([10.0]) + t_h = np.array([0.5]) + + linear = _interp_era5_trilinear(grid, "mwd", lat, lon, t_h) + circular = _interp_era5_angle_trilinear(grid, "mwd", lat, lon, t_h) + assert circular[0] == pytest.approx(linear[0], abs=0.5) + assert circular[0] == pytest.approx(110.0, abs=0.5) + + def test_wrap_around_360(self) -> None: + """Midpoint of 350° and 10° should be ~0° (360°), not 180°.""" + grid = self._make_tiny_grid(np.array([350.0, 10.0])) + lat = np.array([40.0]) + lon = np.array([10.0]) + t_h = np.array([0.5]) + + circular = _interp_era5_angle_trilinear(grid, "mwd", lat, lon, t_h) + linear = _interp_era5_trilinear(grid, "mwd", lat, lon, t_h) + + # Circular should give ~0° (i.e. 360°) + circ_centered = (circular[0] + 180) % 360 - 180 # center around 0 + assert abs(circ_centered) < 5.0, f"Circular gave {circular[0]}°, expected ~0°" + + # Linear gives ~180° — the wrong answer + assert abs(linear[0] - 180.0) < 1.0, f"Linear gave {linear[0]}°, expected ~180°" + + def test_wrap_around_near_north(self) -> None: + """5° and 355° should average to ~0°, not 180°.""" + grid = self._make_tiny_grid(np.array([5.0, 355.0])) + lat = np.array([40.0]) + lon = np.array([10.0]) + t_h = np.array([0.5]) + + circular = _interp_era5_angle_trilinear(grid, "mwd", lat, lon, t_h) + circ_centered = (circular[0] + 180) % 360 - 180 + assert abs(circ_centered) < 5.0 + + +# ══════════════════════════════════════════════════════════════════════ +# Integration tests (require ERA5 data + routetools) +# ══════════════════════════════════════════════════════════════════════ + +_TRACK_DIR = Path("output/swopp3_gpu/tracks") +_DATA_DIR = Path("data/era5") + +# Use Atlantic corridor (smaller ~3.4GB vs ~5.1GB Pacific) +_REF_TRACK = _TRACK_DIR / "IEUniversity-1-AO_WPS-20240701.csv" +_WIND_NC = _DATA_DIR / "era5_wind_atlantic_2024.nc" +_WAVE_NC = _DATA_DIR / "era5_waves_atlantic_2024.nc" +_PASSAGE_HOURS = 354.0 + +_requires_era5 = pytest.mark.skipif( + not (_REF_TRACK.exists() and _WIND_NC.exists() and _WAVE_NC.exists()), + reason="ERA5 data or reference tracks not available", +) + + +def _load_era5_numpy(nc_path: Path) -> dict: + """Load a single ERA5 NetCDF file into a numpy dict. + + Returns dict with keys: data, lat, lon, times_h, t0. + Mirrors ``_load_era5_grid`` from the CodaBench scorer. + """ + import netCDF4 + + ds = netCDF4.Dataset(str(nc_path), "r") + + lat_name = "latitude" if "latitude" in ds.variables else "lat" + lon_name = "longitude" if "longitude" in ds.variables else "lon" + time_name = "valid_time" if "valid_time" in ds.variables else "time" + + lat = np.array(ds.variables[lat_name][:], dtype=np.float64) + lon = np.array(ds.variables[lon_name][:], dtype=np.float64) + + coord_names = {lat_name, lon_name, time_name} + var_names = [v for v in ds.variables if v not in coord_names] + + _LONG_TO_SHORT = { + "10m_u_component_of_wind": "u10", + "10m_v_component_of_wind": "v10", + "significant_height_of_combined_wind_waves_and_swell": "swh", + "mean_wave_direction": "mwd", + } + + t_var = ds.variables[time_name] + cal = getattr(t_var, "calendar", "standard") + times = netCDF4.num2date(t_var[:], t_var.units, cal) + + t0 = np.datetime64(times[0]) + times_np = np.array([np.datetime64(t) for t in times]) + times_h = ((times_np - t0) / np.timedelta64(1, "h")).astype(np.float64) + + data = {} + for v in var_names: + short = _LONG_TO_SHORT.get(v, v) + arr = np.array(ds.variables[v][:], dtype=np.float32) + np.nan_to_num(arr, copy=False, nan=0.0) + data[short] = arr + + ds.close() + + if lat[0] > lat[-1]: + lat = lat[::-1] + for v in data: + data[v] = data[v][:, ::-1, :] + + if lon[0] > lon[-1]: + lon = lon[::-1] + for v in data: + data[v] = data[v][:, :, ::-1] + + # Decompose angular variables into sin/cos for correct interpolation + if "mwd" in data: + mwd_rad = np.radians(data["mwd"]) + data["mwd_sin"] = np.sin(mwd_rad).astype(np.float32) + data["mwd_cos"] = np.cos(mwd_rad).astype(np.float32) + + return {"data": data, "lat": lat, "lon": lon, "times_h": times_h, "t0": t0} + + +def _interp_era5_trilinear( + grid: dict, + var_name: str, + query_lat: np.ndarray, + query_lon: np.ndarray, + query_t_h: np.ndarray, +) -> np.ndarray: + """Trilinear interpolation of an ERA5 variable at query points.""" + arr = grid["data"][var_name] + lat, lon, times_h = grid["lat"], grid["lon"], grid["times_h"] + + dt = times_h[1] - times_h[0] if len(times_h) > 1 else 1.0 + dlat = lat[1] - lat[0] if len(lat) > 1 else 1.0 + dlon = lon[1] - lon[0] if len(lon) > 1 else 1.0 + + fi_t = np.clip((query_t_h - times_h[0]) / dt, 0, len(times_h) - 1) + fi_lat = np.clip((query_lat - lat[0]) / dlat, 0, len(lat) - 1) + fi_lon = np.clip((query_lon - lon[0]) / dlon, 0, len(lon) - 1) + + i0_t = np.clip(np.floor(fi_t).astype(int), 0, len(times_h) - 2) + i0_lat = np.clip(np.floor(fi_lat).astype(int), 0, len(lat) - 2) + i0_lon = np.clip(np.floor(fi_lon).astype(int), 0, len(lon) - 2) + + wt = (fi_t - i0_t).astype(np.float32) + wlat = (fi_lat - i0_lat).astype(np.float32) + wlon = (fi_lon - i0_lon).astype(np.float32) + + result = np.zeros(len(query_lat), dtype=np.float32) + for dt_off in (0, 1): + for dlat_off in (0, 1): + for dlon_off in (0, 1): + w = ( + ((1 - wt) if dt_off == 0 else wt) + * ((1 - wlat) if dlat_off == 0 else wlat) + * ((1 - wlon) if dlon_off == 0 else wlon) + ) + it = np.clip(i0_t + dt_off, 0, arr.shape[0] - 1) + ila = np.clip(i0_lat + dlat_off, 0, arr.shape[1] - 1) + ilo = np.clip(i0_lon + dlon_off, 0, arr.shape[2] - 1) + result += w * arr[it, ila, ilo] + + return result.astype(np.float64) + + +def _interp_era5_angle_trilinear( + grid: dict, + var_name: str, + query_lat: np.ndarray, + query_lon: np.ndarray, + query_t_h: np.ndarray, +) -> np.ndarray: + """Interpolate an angular ERA5 variable using sin/cos decomposition.""" + sin_vals = _interp_era5_trilinear( + grid, f"{var_name}_sin", query_lat, query_lon, query_t_h + ) + cos_vals = _interp_era5_trilinear( + grid, f"{var_name}_cos", query_lat, query_lon, query_t_h + ) + return np.mod(np.degrees(np.arctan2(sin_vals, cos_vals)), 360.0) + + +# RISE performance model constants (must match codabench scorer) +_KH = 969.0 / 226.0 +_KA = 49.0 / 320.0 +_AW = 11.1395 +_KW = 125.0 / 432.0 +_KS = 27489.0 / 32000.0 +_DEAD_ZONE_DEG = 10.0 + + +def _rise_power_np(tws, twa_deg, swh, mwa_deg, v, wps): + """RISE power (kW) — numpy arrays.""" + twa_rad = np.radians(twa_deg) + p_hull = _KH * v**3 + ux = tws * np.cos(twa_rad) + v + uy = tws * np.sin(twa_rad) + vr = np.sqrt(ux**2 + uy**2) + p_wind = _KA * v * (vr * ux - v**2) + mwa_rad = np.radians(mwa_deg) + p_wave = _AW * swh**2 * v**1.5 * np.exp(-_KW * np.abs(mwa_rad) ** 3) + power = p_hull + p_wind + p_wave + if wps: + awa_deg = np.degrees(np.arctan2(np.abs(uy), ux)) + sail_active = awa_deg >= _DEAD_ZONE_DEG + alpha = np.where(sail_active, np.radians(awa_deg - _DEAD_ZONE_DEG), 0.0) + sin_a = np.sin(alpha) + p_sail = _KS * sin_a * (1.0 + 0.15 * sin_a**2) * vr**2 * v + power = power - np.where(sail_active, p_sail, 0.0) + return np.maximum(power, 0.0) + + +def _np_haversine_m(lat1, lon1, lat2, lon2): + """Haversine distance in metres.""" + R = 6_371_000.0 + dlat = np.radians(lat2 - lat1) + dlon = np.radians(lon2 - lon1) + lat1r, lat2r = np.radians(lat1), np.radians(lat2) + a = np.sin(dlat / 2) ** 2 + np.cos(lat1r) * np.cos(lat2r) * np.sin(dlon / 2) ** 2 + return R * 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a)) + + +def _np_forward_bearing_deg(lat1, lon1, lat2, lon2): + """Forward bearing in degrees [0, 360).""" + lat1r, lat2r = np.radians(lat1), np.radians(lat2) + dlon = np.radians(lon2 - lon1) + x = np.sin(dlon) * np.cos(lat2r) + y = np.cos(lat1r) * np.sin(lat2r) - np.sin(lat1r) * np.cos(lat2r) * np.cos(dlon) + return np.mod(np.degrees(np.arctan2(x, y)), 360.0) + + +def _evaluate_energy( + waypoints: list[tuple[datetime, float, float]], + passage_hours: float, + wps: bool, + wind_grid: dict, + wave_grid: dict, +) -> float: + """Evaluate route energy using numpy-only RISE + trapezoidal rule. + + Mirrors the CodaBench scorer pipeline without JAX dependency. + *passage_hours* is accepted for call-site compatibility but unused — + segment durations are derived from waypoint timestamps. + """ + n_wp = len(waypoints) + if n_wp < 2: + return 0.0 + + lats = np.array([wp[1] for wp in waypoints]) + lons = np.array([wp[2] for wp in waypoints]) + + wp_times = np.array( + [np.datetime64(wp[0].replace(tzinfo=None)) for wp in waypoints], + dtype="datetime64[s]", + ) + seg_dt_h = ((wp_times[1:] - wp_times[:-1]) / np.timedelta64(1, "h")).astype( + np.float64 + ) + seg_dt_h = np.maximum(seg_dt_h, 1e-6) + + # Normalize lons to ERA5 grid convention [0, 360) + grid_lon = wind_grid["lon"] + if grid_lon[0] >= 0 and grid_lon[-1] > 180: + lons = np.where(lons < 0, lons + 360, lons) + + dep_dt64 = wp_times[0] + dep_offset_h = float((dep_dt64 - wind_grid["t0"]) / np.timedelta64(1, "h")) + wp_times_h = np.zeros(n_wp, dtype=np.float64) + wp_times_h[0] = dep_offset_h + wp_times_h[1:] = dep_offset_h + np.cumsum(seg_dt_h) + + # Interpolate weather at all waypoints (trapezoidal endpoints) + u10 = _interp_era5_trilinear(wind_grid, "u10", lats, lons, wp_times_h) + v10 = _interp_era5_trilinear(wind_grid, "v10", lats, lons, wp_times_h) + swh = _interp_era5_trilinear(wave_grid, "swh", lats, lons, wp_times_h) + mwd = _interp_era5_angle_trilinear(wave_grid, "mwd", lats, lons, wp_times_h) + + tws_all = np.sqrt(u10**2 + v10**2) + + seg_dist_m = _np_haversine_m(lats[:-1], lons[:-1], lats[1:], lons[1:]) + v_mps = seg_dist_m / (seg_dt_h * 3600.0) + bearing_deg = _np_forward_bearing_deg(lats[:-1], lons[:-1], lats[1:], lons[1:]) + + # RISE power at segment start and end points + wind_from_s = np.mod(180.0 + np.degrees(np.arctan2(u10[:-1], v10[:-1])), 360.0) + twa_s = np.mod(wind_from_s - bearing_deg, 360.0) + mwa_s = np.mod(mwd[:-1] - bearing_deg, 360.0) + power_s = _rise_power_np(tws_all[:-1], twa_s, swh[:-1], mwa_s, v_mps, wps) + + wind_from_e = np.mod(180.0 + np.degrees(np.arctan2(u10[1:], v10[1:])), 360.0) + twa_e = np.mod(wind_from_e - bearing_deg, 360.0) + mwa_e = np.mod(mwd[1:] - bearing_deg, 360.0) + power_e = _rise_power_np(tws_all[1:], twa_e, swh[1:], mwa_e, v_mps, wps) + + power_avg = (power_s + power_e) / 2.0 + return float(np.sum(power_avg * seg_dt_h) / 1000.0) + + +# Share a single ERA5 load across ALL integration tests (module scope) +# to avoid loading data twice. Uses numpy only — no JAX. +@pytest.fixture(scope="module") +def era5_fields(): + """Load ERA5 wind + wave grids as numpy dicts (no JAX).""" + wind_grid = _load_era5_numpy(_WIND_NC) + wave_grid = _load_era5_numpy(_WAVE_NC) + return wind_grid, wave_grid + + +@pytest.fixture(scope="module") +def reference_track(): + """Load the reference track once.""" + return _load_track_csv(_REF_TRACK) + + +@_requires_era5 +class TestConvergence: + """Energy should converge as Δt₂ decreases. + + Take a native L=584 track (Δt₁ ≈ 1h), resample to Δt₂ = 60, 30, 15, + 10, 5 min, and verify that energy values converge (decreasing successive + differences). + """ + + def test_convergence_wps(self, era5_fields, reference_track) -> None: + wind_grid, wave_grid = era5_fields + + dt_values = [60, 30, 15, 10, 5] + energies = {} + + for dt_min in dt_values: + resampled = resample_track(reference_track, dt_minutes=dt_min) + e = _evaluate_energy( + resampled, + _PASSAGE_HOURS, + wps=True, + wind_grid=wind_grid, + wave_grid=wave_grid, + ) + energies[dt_min] = e + + diffs = [ + abs(energies[dt_values[i]] - energies[dt_values[i + 1]]) + for i in range(len(dt_values) - 1) + ] + + print("\nConvergence test (WPS):") + for dt_min in dt_values: + print(f" Δt₂={dt_min:>3}min → {energies[dt_min]:.4f} MWh") + print("Successive diffs:", [f"{d:.4f}" for d in diffs]) + + # Energy values should stabilize: total spread < 0.5% + all_e = list(energies.values()) + pct_range = (max(all_e) - min(all_e)) / np.mean(all_e) * 100 + assert ( + pct_range < 0.5 + ), f"Energy spread {pct_range:.2f}% exceeds 0.5% across resolutions" + + # At dt=15min and dt=5min, energy should agree within 2% + ref = energies[5] + pct_15 = abs(energies[15] - ref) / ref * 100 + assert pct_15 < 2.0, f"dt=15min vs dt=5min: {pct_15:.2f}% difference" + + def test_convergence_nowps(self, era5_fields, reference_track) -> None: + wind_grid, wave_grid = era5_fields + + dt_values = [60, 30, 15, 10, 5] + energies = {} + + for dt_min in dt_values: + resampled = resample_track(reference_track, dt_minutes=dt_min) + e = _evaluate_energy( + resampled, + _PASSAGE_HOURS, + wps=False, + wind_grid=wind_grid, + wave_grid=wave_grid, + ) + energies[dt_min] = e + + diffs = [ + abs(energies[dt_values[i]] - energies[dt_values[i + 1]]) + for i in range(len(dt_values) - 1) + ] + + print("\nConvergence test (noWPS):") + for dt_min in dt_values: + print(f" Δt₂={dt_min:>3}min → {energies[dt_min]:.4f} MWh") + print("Successive diffs:", [f"{d:.4f}" for d in diffs]) + + assert diffs[-1] < diffs[0] + + ref = energies[5] + pct_15 = abs(energies[15] - ref) / ref * 100 + assert pct_15 < 2.0, f"dt=15min vs dt=5min: {pct_15:.2f}% difference" + + +@_requires_era5 +class TestInvariance: + """Energy should be invariant to submission waypoint density after resampling. + + Take the native track, downsample to L≈50, L≈100, L≈200, resample all + back to Δt₂=15min, and verify that energy values agree within tolerance. + """ + + def test_invariance_nowps(self, era5_fields, reference_track) -> None: + wind_grid, wave_grid = era5_fields + + ref_resampled = resample_track(reference_track, dt_minutes=15) + ref_energy = _evaluate_energy( + ref_resampled, + _PASSAGE_HOURS, + wps=False, + wind_grid=wind_grid, + wave_grid=wave_grid, + ) + + factors = [12, 6, 3] # → ~49, ~97, ~195 waypoints + energies = {"native(584)": ref_energy} + + for factor in factors: + sparse = _downsample_waypoints(reference_track, factor) + resampled = resample_track(sparse, dt_minutes=15) + e = _evaluate_energy( + resampled, + _PASSAGE_HOURS, + wps=False, + wind_grid=wind_grid, + wave_grid=wave_grid, + ) + energies[f"L≈{len(sparse)}"] = e + + print("\nInvariance test (noWPS, dt=15min):") + for label, e in energies.items(): + pct = abs(e - ref_energy) / ref_energy * 100 + print(f" {label:>15} → {e:.4f} MWh ({pct:+.2f}%)") + + for label, e in energies.items(): + pct = abs(e - ref_energy) / ref_energy * 100 + assert pct < 3.0, ( + f"{label}: {e:.4f} MWh deviates {pct:.2f}% from " + f"reference {ref_energy:.4f} MWh" + ) + + def test_invariance_wps(self, era5_fields, reference_track) -> None: + wind_grid, wave_grid = era5_fields + + ref_resampled = resample_track(reference_track, dt_minutes=15) + ref_energy = _evaluate_energy( + ref_resampled, + _PASSAGE_HOURS, + wps=True, + wind_grid=wind_grid, + wave_grid=wave_grid, + ) + + factors = [12, 6, 3] + energies = {"native(584)": ref_energy} + + for factor in factors: + sparse = _downsample_waypoints(reference_track, factor) + resampled = resample_track(sparse, dt_minutes=15) + e = _evaluate_energy( + resampled, + _PASSAGE_HOURS, + wps=True, + wind_grid=wind_grid, + wave_grid=wave_grid, + ) + energies[f"L≈{len(sparse)}"] = e + + print("\nInvariance test (WPS, dt=15min):") + for label, e in energies.items(): + pct = abs(e - ref_energy) / ref_energy * 100 + print(f" {label:>15} → {e:.4f} MWh ({pct:+.2f}%)") + + # WPS is more sensitive — allow 5% tolerance + for label, e in energies.items(): + pct = abs(e - ref_energy) / ref_energy * 100 + assert pct < 5.0, ( + f"{label}: {e:.4f} MWh deviates {pct:.2f}% from " + f"reference {ref_energy:.4f} MWh" + ) diff --git a/tests/test_swopp3.py b/tests/test_swopp3.py new file mode 100644 index 00000000..e4564f21 --- /dev/null +++ b/tests/test_swopp3.py @@ -0,0 +1,275 @@ +"""Tests for routetools.swopp3 — SWOPP3 configuration and helpers.""" + +from datetime import UTC, datetime + +import jax.numpy as jnp +import pytest + +from routetools.swopp3 import ( + PORTS, + ROUTE_ATLANTIC, + ROUTE_PACIFIC, + SWOPP3_CASES, + case_endpoints, + case_travel_time_seconds, + departure_strings, + departures_2024, + great_circle_route, +) + + +# --------------------------------------------------------------------------- +# Port definitions +# --------------------------------------------------------------------------- +class TestPorts: + def test_all_four_ports_defined(self): + assert set(PORTS.keys()) == {"ESSDR", "USNYS", "JPTYO", "USLAX"} + + def test_santander_coords(self): + assert PORTS["ESSDR"]["lat"] == 43.6 + assert PORTS["ESSDR"]["lon"] == -4.0 + + def test_new_york_coords(self): + assert PORTS["USNYS"]["lat"] == 40.6 + assert PORTS["USNYS"]["lon"] == -69.0 + + def test_tokyo_coords(self): + assert PORTS["JPTYO"]["lat"] == 34.8 + assert PORTS["JPTYO"]["lon"] == 140.0 + + def test_la_coords(self): + assert PORTS["USLAX"]["lat"] == 34.4 + assert PORTS["USLAX"]["lon"] == -121.0 + + +# --------------------------------------------------------------------------- +# Route definitions +# --------------------------------------------------------------------------- +class TestRoutes: + def test_atlantic_passage(self): + assert ROUTE_ATLANTIC["passage_hours"] == 354 + + def test_pacific_passage(self): + assert ROUTE_PACIFIC["passage_hours"] == 583 + + def test_atlantic_ports(self): + assert ROUTE_ATLANTIC["src_port"] == "ESSDR" + assert ROUTE_ATLANTIC["dst_port"] == "USNYS" + + def test_pacific_ports(self): + assert ROUTE_PACIFIC["src_port"] == "JPTYO" + assert ROUTE_PACIFIC["dst_port"] == "USLAX" + + +# --------------------------------------------------------------------------- +# 8 SWOPP3 cases +# --------------------------------------------------------------------------- +_CASE_IDS = [ + "AO_WPS", + "AO_noWPS", + "AGC_WPS", + "AGC_noWPS", + "PO_WPS", + "PO_noWPS", + "PGC_WPS", + "PGC_noWPS", +] + +_ATLANTIC_CASES = ["AO_WPS", "AO_noWPS", "AGC_WPS", "AGC_noWPS"] +_PACIFIC_CASES = ["PO_WPS", "PO_noWPS", "PGC_WPS", "PGC_noWPS"] + + +class TestCases: + def test_eight_cases(self): + assert len(SWOPP3_CASES) == 8 + + def test_case_ids(self): + assert set(SWOPP3_CASES.keys()) == set(_CASE_IDS) + + @pytest.mark.parametrize("cid", _CASE_IDS) + def test_case_has_required_keys(self, cid): + case = SWOPP3_CASES[cid] + for key in ( + "name", + "label", + "src_port", + "dst_port", + "passage_hours", + "route", + "strategy", + "wps", + ): + assert key in case, f"{cid} missing key {key}" + + @pytest.mark.parametrize("cid", _CASE_IDS) + def test_strategy_values(self, cid): + assert SWOPP3_CASES[cid]["strategy"] in ("optimised", "gc") + + def test_optimised_cases(self): + for cid in ("AO_WPS", "AO_noWPS", "PO_WPS", "PO_noWPS"): + assert SWOPP3_CASES[cid]["strategy"] == "optimised" + + def test_gc_cases(self): + for cid in ("AGC_WPS", "AGC_noWPS", "PGC_WPS", "PGC_noWPS"): + assert SWOPP3_CASES[cid]["strategy"] == "gc" + + def test_wps_flag(self): + for cid in ("AO_WPS", "AGC_WPS", "PO_WPS", "PGC_WPS"): + assert SWOPP3_CASES[cid]["wps"] is True + for cid in ("AO_noWPS", "AGC_noWPS", "PO_noWPS", "PGC_noWPS"): + assert SWOPP3_CASES[cid]["wps"] is False + + def test_atlantic_passage_hours(self): + for cid in _ATLANTIC_CASES: + assert SWOPP3_CASES[cid]["passage_hours"] == 354 + + def test_pacific_passage_hours(self): + for cid in _PACIFIC_CASES: + assert SWOPP3_CASES[cid]["passage_hours"] == 583 + + def test_atlantic_route(self): + for cid in _ATLANTIC_CASES: + assert SWOPP3_CASES[cid]["src_port"] == "ESSDR" + assert SWOPP3_CASES[cid]["dst_port"] == "USNYS" + + def test_pacific_route(self): + for cid in _PACIFIC_CASES: + assert SWOPP3_CASES[cid]["src_port"] == "JPTYO" + assert SWOPP3_CASES[cid]["dst_port"] == "USLAX" + + +# --------------------------------------------------------------------------- +# Departures +# --------------------------------------------------------------------------- +class TestDepartures: + def test_366_departures(self): + deps = departures_2024() + assert len(deps) == 366 + + def test_first_departure(self): + deps = departures_2024() + expected = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) + assert deps[0] == expected + + def test_last_departure(self): + deps = departures_2024() + expected = datetime(2024, 12, 31, 12, 0, 0, tzinfo=UTC) + assert deps[-1] == expected + + def test_all_noon_utc(self): + for d in departures_2024(): + assert d.hour == 12 + assert d.minute == 0 + assert d.second == 0 + + def test_consecutive_days(self): + deps = departures_2024() + for i in range(1, len(deps)): + delta = deps[i] - deps[i - 1] + assert delta.days == 1 + assert delta.seconds == 0 + + def test_departure_strings_iso(self): + strings = departure_strings() + assert len(strings) == 366 + assert strings[0] == "2024-01-01T12:00:00" + assert strings[-1] == "2024-12-31T12:00:00" + + def test_departure_strings_custom_format(self): + strings = departure_strings(fmt="%Y-%m-%d") + assert strings[0] == "2024-01-01" + + +# --------------------------------------------------------------------------- +# case_endpoints +# --------------------------------------------------------------------------- +class TestCaseEndpoints: + def test_ao_wps_src_dst(self): + src, dst = case_endpoints("AO_WPS") + assert src.shape == (2,) + assert dst.shape == (2,) + # src should be Santander (lon, lat) + assert jnp.allclose(src, jnp.array([-4.0, 43.6])) + # dst should be New York (lon, lat) + assert jnp.allclose(dst, jnp.array([-69.0, 40.6])) + + def test_all_atlantic_same_endpoints(self): + src_ref, dst_ref = case_endpoints("AO_WPS") + for cid in _ATLANTIC_CASES: + src, dst = case_endpoints(cid) + assert jnp.allclose(src, src_ref) + assert jnp.allclose(dst, dst_ref) + + def test_po_wps_pacific(self): + src, dst = case_endpoints("PO_WPS") + # Tokyo + assert jnp.allclose(src, jnp.array([140.0, 34.8])) + # LA + assert jnp.allclose(dst, jnp.array([-121.0, 34.4])) + + def test_all_pacific_same_endpoints(self): + src_ref, dst_ref = case_endpoints("PO_WPS") + for cid in _PACIFIC_CASES: + src, dst = case_endpoints(cid) + assert jnp.allclose(src, src_ref) + assert jnp.allclose(dst, dst_ref) + + def test_invalid_case_raises(self): + with pytest.raises(KeyError): + case_endpoints("case99") + + +# --------------------------------------------------------------------------- +# case_travel_time_seconds +# --------------------------------------------------------------------------- +class TestCaseTravelTime: + def test_atlantic(self): + assert case_travel_time_seconds("AO_WPS") == 354 * 3600.0 + + def test_pacific(self): + assert case_travel_time_seconds("PO_WPS") == 583 * 3600.0 + + +# --------------------------------------------------------------------------- +# Great-circle route +# --------------------------------------------------------------------------- +class TestGreatCircle: + def test_endpoints_match(self): + src = jnp.array([-4.0, 43.6]) + dst = jnp.array([-69.0, 40.6]) + route = great_circle_route(src, dst, n_points=50) + assert route.shape == (50, 2) + assert jnp.allclose(route[0], src, atol=1e-4) + assert jnp.allclose(route[-1], dst, atol=1e-4) + + def test_pacific_antimeridian(self): + """Pacific route crosses the antimeridian — no wrapping artifact.""" + src = jnp.array([140.0, 34.8]) # Tokyo + dst = jnp.array([-121.0, 34.4]) # LA + route = great_circle_route(src, dst, n_points=200) + # Route should go eastward across the Pacific (longitudes > 140 or < -121) + # The key test: no sudden 360° jumps between consecutive points + dlon = jnp.diff(route[:, 0]) + assert jnp.all( + jnp.abs(dlon) < 10.0 + ), "Large longitude jump detected — antimeridian bug" + + def test_coincident_points(self): + """Degenerate case: src == dst.""" + pt = jnp.array([10.0, 50.0]) + route = great_circle_route(pt, pt, n_points=10) + assert route.shape == (10, 2) + # All points should be at the same location + for i in range(10): + assert jnp.allclose(route[i], pt, atol=1e-4) + + def test_route_latitude_range(self): + """Atlantic GC route should curve northward (higher than both endpoints).""" + src = jnp.array([-4.0, 43.6]) # Santander + dst = jnp.array([-69.0, 40.6]) # USNYS + route = great_circle_route(src, dst, n_points=100) + max_lat = jnp.max(route[:, 1]) + # GC route from Santander to USNYS curves north — max latitude > both endpoints + assert max_lat > max( + 43.6, 40.6 + ), f"max_lat {max_lat} should exceed endpoint lats" diff --git a/tests/test_swopp3_analysis.py b/tests/test_swopp3_analysis.py new file mode 100644 index 00000000..43ed7b78 --- /dev/null +++ b/tests/test_swopp3_analysis.py @@ -0,0 +1,181 @@ +"""Tests for SWOPP3 analysis script helpers.""" + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import matplotlib as mpl +import matplotlib.pyplot as plt + +from routetools.analysis_config import ( + AnalysisPaths, + _configured_output_dirs, + _experiment_folder, +) + + +def _load_swopp3_analysis_module(): + """Load the plotting script directly from scripts/swopp3_analysis.py.""" + module_path = Path(__file__).resolve().parents[1] / "scripts" / "swopp3_analysis.py" + spec = importlib.util.spec_from_file_location("swopp3_analysis", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load module spec for {module_path}") + + cartopy = ModuleType("cartopy") + cartopy_crs = ModuleType("cartopy.crs") + cartopy_feature = ModuleType("cartopy.feature") + cartopy_crs.PlateCarree = lambda *args, **kwargs: None + cartopy_feature.LAND = object() + cartopy_feature.OCEAN = object() + cartopy_feature.COASTLINE = object() + cartopy_feature.BORDERS = object() + cartopy.crs = cartopy_crs + cartopy.feature = cartopy_feature + sys.modules.setdefault("cartopy", cartopy) + sys.modules.setdefault("cartopy.crs", cartopy_crs) + sys.modules.setdefault("cartopy.feature", cartopy_feature) + sys.modules.setdefault( + "routetools.violations", + SimpleNamespace(find_team_prefix=lambda *_args, **_kwargs: "IEUniversity-1"), + ) + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_swopp3_analysis = _load_swopp3_analysis_module() + + +def test_configured_output_dirs_reads_swopp3_profiles(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text( + """ +[swopp3.experiments.no_penalty] +output_dir = "output/swopp3_no_penalty" + +[swopp3.experiments.split_penalty] +output_dir = "output/swopp3_split_penalty" +""".strip() + ) + + output_dirs = _configured_output_dirs(config_path) + + assert output_dirs == { + "no_penalty": "swopp3_no_penalty", + "split_penalty": "swopp3_split_penalty", + } + + +def test_experiment_folder_prefers_existing_legacy_output_when_config_target_missing( + tmp_path: Path, +) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text( + """ +[swopp3.experiments.split_penalty] +output_dir = "output/swopp3_split_penalty" +""".strip() + ) + (tmp_path / "output" / "swopp3_penalty").mkdir(parents=True) + + paths = AnalysisPaths( + output_dir=tmp_path / "output", + figs_dir=tmp_path / "analysis", + config_path=config_path, + ) + + assert _experiment_folder("penalty", paths) == "swopp3_penalty" + + +def test_experiment_folder_uses_configured_folder_when_present(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text( + """ +[swopp3.experiments.no_penalty] +output_dir = "output/swopp3_no_penalty" +""".strip() + ) + (tmp_path / "output" / "swopp3_no_penalty").mkdir(parents=True) + + paths = AnalysisPaths( + output_dir=tmp_path / "output", + figs_dir=tmp_path / "analysis", + config_path=config_path, + ) + + assert _experiment_folder("no_penalty", paths) == "swopp3_no_penalty" + + +def test_setup_style_uses_transparent_backgrounds() -> None: + """Plot style should default figure and axes backgrounds to transparent.""" + with mpl.rc_context(): + _swopp3_analysis.setup_style() + + assert mpl.rcParams["figure.facecolor"] == "none" + assert mpl.rcParams["axes.facecolor"] == "none" + assert mpl.rcParams["savefig.facecolor"] == "none" + assert mpl.rcParams["savefig.transparent"] is True + + +def test_save_figure_outputs_writes_pdf_png_and_removes_stale_tikz( + monkeypatch, + tmp_path: Path, +) -> None: + """Figure export helper should emit transparent PDF/PNG and remove stale TikZ.""" + fig, ax = plt.subplots() + ax.plot([0, 1], [1, 0], label="line") + + savefig_calls: list[tuple[Path, dict[str, object]]] = [] + + def _fake_savefig(path: str | Path, **kwargs: object) -> None: + savefig_calls.append((Path(path), kwargs)) + + monkeypatch.setattr(fig, "savefig", _fake_savefig) + + out = tmp_path / "figure.pdf" + out.with_suffix(".tikz").write_text("stale") + _swopp3_analysis._save_figure_outputs(fig, out, bbox_inches="tight") + + assert [path for path, _ in savefig_calls] == [out, out.with_suffix(".png")] + assert all(kwargs["transparent"] is True for _, kwargs in savefig_calls) + assert all(kwargs["bbox_inches"] == "tight" for _, kwargs in savefig_calls) + assert not out.with_suffix(".tikz").exists() + assert fig.patch.get_alpha() == 0 + assert ax.get_facecolor()[-1] == 0 + + plt.close(fig) + + +def test_save_figure_outputs_hides_suptitle_and_source_for_png_only( + monkeypatch, + tmp_path: Path, +) -> None: + """PNG hides suptitle/source while PDF keeps them visible; state is restored.""" + fig, ax = plt.subplots() + fig.suptitle("Main title") + ax.set_title("Panel title") + source_text = fig.text(0.01, -0.01, "Source note") + + visibility_by_suffix: dict[str, tuple[bool, bool]] = {} + + def _fake_savefig(path: str | Path, **kwargs: object) -> None: + suffix = Path(path).suffix + visibility_by_suffix[suffix] = ( + fig._suptitle is not None and fig._suptitle.get_visible(), + source_text.get_visible(), + ) + + monkeypatch.setattr(fig, "savefig", _fake_savefig) + + _swopp3_analysis._save_figure_outputs(fig, tmp_path / "figure.pdf") + + assert visibility_by_suffix[".pdf"] == (True, True) + assert visibility_by_suffix[".png"] == (False, False) + assert fig._suptitle is not None and fig._suptitle.get_text() == "Main title" + assert fig._suptitle is not None and fig._suptitle.get_visible() is True + assert ax.get_title() == "Panel title" + assert source_text.get_visible() is True + + plt.close(fig) diff --git a/tests/test_swopp3_output.py b/tests/test_swopp3_output.py new file mode 100644 index 00000000..2816bc3d --- /dev/null +++ b/tests/test_swopp3_output.py @@ -0,0 +1,253 @@ +"""Tests for routetools.swopp3_output — File A / File B formatters.""" + +import csv +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import jax.numpy as jnp +import pytest + +from routetools.swopp3_output import ( + _FILE_A_COLUMNS, + _FILE_B_COLUMNS, + TEAM, + file_a_name, + file_a_row, + file_b_name, + read_file_a_dataframe, + read_file_b_dataframe, + resolve_file_a_path, + resolve_file_b_path, + sailed_distance_nm, + waypoint_times, + write_file_a, + write_file_b, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def _straight_curve(n: int = 10) -> jnp.ndarray: + """Straight line from (0, 0) to (1, 0) — ~111 km ≈ 60 nm.""" + src = jnp.array([0.0, 0.0]) + dst = jnp.array([1.0, 0.0]) + return jnp.linspace(src, dst, n) + + +_DEP = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) + + +# --------------------------------------------------------------------------- +# sailed_distance_nm +# --------------------------------------------------------------------------- +class TestSailedDistance: + def test_positive(self): + curve = _straight_curve() + d = sailed_distance_nm(curve) + assert d > 0 + + def test_equator_one_degree(self): + """1° of longitude at the equator ≈ 60 nm.""" + curve = _straight_curve(100) + d = sailed_distance_nm(curve) + assert 59.0 < d < 61.0, f"Expected ~60 nm, got {d}" + + +# --------------------------------------------------------------------------- +# waypoint_times +# --------------------------------------------------------------------------- +class TestWaypointTimes: + def test_count(self): + curve = _straight_curve(50) + times = waypoint_times(curve, _DEP, passage_hours=354) + assert len(times) == 50 + + def test_single_waypoint(self): + curve = jnp.array([[10.0, 50.0]]) + times = waypoint_times(curve, _DEP, passage_hours=10) + assert times == [_DEP] + + def test_empty_curve_raises(self): + curve = jnp.empty((0, 2)) + with pytest.raises(ValueError): + waypoint_times(curve, _DEP, passage_hours=10) + + def test_first_is_departure(self): + curve = _straight_curve() + times = waypoint_times(curve, _DEP, passage_hours=10) + assert times[0] == _DEP + + def test_last_is_arrival(self): + curve = _straight_curve() + times = waypoint_times(curve, _DEP, passage_hours=10) + expected = _DEP + timedelta(hours=10) + # Allow tiny floating-point rounding + delta = abs((times[-1] - expected).total_seconds()) + assert delta < 1.0, f"Last time off by {delta}s" + + def test_uniform_spacing(self): + curve = _straight_curve(11) + times = waypoint_times(curve, _DEP, passage_hours=10) + deltas = [(times[i + 1] - times[i]).total_seconds() for i in range(10)] + assert all(abs(d - deltas[0]) < 0.01 for d in deltas) + + +# --------------------------------------------------------------------------- +# file_a_row +# --------------------------------------------------------------------------- +class TestFileARow: + def test_keys(self): + row = file_a_row( + departure=_DEP, + passage_hours=354, + energy_mwh=12.5, + max_wind_mps=18.0, + max_hs_m=5.0, + distance_nm=2800.0, + details_filename="test.csv", + ) + assert set(row.keys()) == set(_FILE_A_COLUMNS) + + def test_departure_format(self): + row = file_a_row(_DEP, 354, 1.0, 1.0, 1.0, 1.0, "x.csv") + assert row["departure_time_utc"] == "2024-01-01 12:00:00" + + def test_arrival_format(self): + row = file_a_row(_DEP, 354, 1.0, 1.0, 1.0, 1.0, "x.csv") + expected_arrival = _DEP + timedelta(hours=354) + assert row["arrival_time_utc"] == expected_arrival.strftime("%Y-%m-%d %H:%M:%S") + + def test_details_filename_passthrough(self): + row = file_a_row(_DEP, 354, 1.0, 1.0, 1.0, 1.0, "my_track.csv") + assert row["details_filename"] == "my_track.csv" + + +# --------------------------------------------------------------------------- +# file_a_name / file_b_name +# --------------------------------------------------------------------------- +class TestNaming: + def test_file_a_name(self): + assert file_a_name(1, "AO_WPS") == "IEUniversity-1-AO_WPS.csv" + + def test_file_b_name(self): + name = file_b_name(1, "AO_WPS", _DEP) + assert name == "IEUniversity-1-AO_WPS-20240101.csv" + + def test_team(self): + assert TEAM == "IEUniversity" + + +# --------------------------------------------------------------------------- +# write_file_a +# --------------------------------------------------------------------------- +class TestWriteFileA: + def test_roundtrip(self, tmp_path: Path): + rows = [ + file_a_row(_DEP, 354, 12.5, 18.0, 5.1, 2800.0, "track1.csv"), + file_a_row( + _DEP + timedelta(days=1), 354, 13.0, 19.0, 6.0, 2810.0, "track2.csv" + ), + ] + out = write_file_a(rows, tmp_path / "output" / "file_a.csv") + assert out.exists() + + # Parse back + with out.open() as f: + reader = csv.DictReader(f) + parsed = list(reader) + assert len(parsed) == 2 + assert parsed[0]["departure_time_utc"] == "2024-01-01 12:00:00" + assert parsed[1]["energy_cons_mwh"] == "13.000000" + assert set(reader.fieldnames) == set(_FILE_A_COLUMNS) + + def test_creates_parent_dirs(self, tmp_path: Path): + rows = [file_a_row(_DEP, 10, 1.0, 1.0, 1.0, 100.0, "x.csv")] + out = write_file_a(rows, tmp_path / "a" / "b" / "c" / "file.csv") + assert out.exists() + + +class TestFileAReaders: + def test_resolve_submission(self, tmp_path: Path): + rows = [file_a_row(_DEP, 10, 1.0, 1.0, 1.0, 100.0, "x.csv")] + p1 = tmp_path / file_a_name(1, "AO_WPS") + p2 = tmp_path / file_a_name(2, "AO_WPS") + write_file_a(rows, p1) + write_file_a(rows, p2) + + assert resolve_file_a_path(tmp_path, "AO_WPS", submission=1) == p1 + assert resolve_file_a_path(tmp_path, "AO_WPS") == p2 + + def test_read_file_a_dataframe(self, tmp_path: Path): + rows = [ + file_a_row(_DEP, 10, 1.0, 1.0, 1.0, 100.0, "x.csv"), + file_a_row(_DEP + timedelta(days=1), 10, 2.0, 1.0, 1.0, 100.0, "y.csv"), + ] + write_file_a(rows, tmp_path / file_a_name(1, "AO_WPS")) + + df = read_file_a_dataframe(tmp_path, "AO_WPS", submission=1) + assert len(df) == 2 + assert "departure_time_utc" in df.columns + assert str(df["departure_time_utc"].dtype).startswith("datetime64") + + +# --------------------------------------------------------------------------- +# write_file_b +# --------------------------------------------------------------------------- +class TestWriteFileB: + def test_roundtrip(self, tmp_path: Path): + curve = _straight_curve(5) + times = waypoint_times(curve, _DEP, passage_hours=10) + out = write_file_b(curve, times, tmp_path / "track.csv") + assert out.exists() + + with out.open() as f: + reader = csv.DictReader(f) + parsed = list(reader) + assert len(parsed) == 5 + assert set(reader.fieldnames) == set(_FILE_B_COLUMNS) + # First row: time is departure, lat=0, lon=0 + assert parsed[0]["time_utc"] == "2024-01-01 12:00:00" + assert float(parsed[0]["lat_deg"]) == pytest.approx(0.0, abs=1e-4) + assert float(parsed[0]["lon_deg"]) == pytest.approx(0.0, abs=1e-4) + # Last row: lon=1, lat=0 + assert float(parsed[-1]["lon_deg"]) == pytest.approx(1.0, abs=1e-4) + + def test_mismatched_lengths_raises(self, tmp_path: Path): + curve = _straight_curve(5) + times = waypoint_times(curve, _DEP, passage_hours=10) + with pytest.raises(ValueError): + write_file_b(curve, times[:3], tmp_path / "bad.csv") + + def test_lon_lat_order(self, tmp_path: Path): + """Curve is (lon, lat) but File B columns are lat_deg, lon_deg.""" + # Point with lon=10, lat=50 + curve = jnp.array([[10.0, 50.0], [11.0, 51.0]]) + times = [_DEP, _DEP + timedelta(hours=5)] + out = write_file_b(curve, times, tmp_path / "coords.csv") + with out.open() as f: + reader = csv.DictReader(f) + row = next(reader) + assert float(row["lon_deg"]) == pytest.approx(10.0, abs=1e-4) + assert float(row["lat_deg"]) == pytest.approx(50.0, abs=1e-4) + + +class TestFileBReaders: + def test_resolve_file_b_path(self, tmp_path: Path): + track = tmp_path / "tracks" / "track.csv" + curve = _straight_curve(2) + times = waypoint_times(curve, _DEP, passage_hours=1) + write_file_b(curve, times, track) + + assert resolve_file_b_path(tmp_path, "track.csv") == track + + def test_read_file_b_dataframe(self, tmp_path: Path): + track = tmp_path / "tracks" / "track.csv" + curve = _straight_curve(3) + times = waypoint_times(curve, _DEP, passage_hours=2) + write_file_b(curve, times, track) + + df = read_file_b_dataframe(tmp_path, "track.csv") + assert len(df) == 3 + assert "time_utc" in df.columns + assert str(df["time_utc"].dtype).startswith("datetime64") diff --git a/tests/test_swopp3_run.py b/tests/test_swopp3_run.py new file mode 100644 index 00000000..3eb011de --- /dev/null +++ b/tests/test_swopp3_run.py @@ -0,0 +1,352 @@ +"""Tests for the SWOPP3 CLI input-validation helpers.""" + +import importlib.util +import json +from datetime import datetime +from pathlib import Path + +import pytest +from typer.testing import CliRunner + + +def _load_swopp3_run_module(): + """Load the CLI module directly from scripts/swopp3_run.py.""" + module_path = Path(__file__).resolve().parents[1] / "scripts" / "swopp3_run.py" + spec = importlib.util.spec_from_file_location("swopp3_run", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load module spec for {module_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_swopp3_run = _load_swopp3_run_module() +_loadable_era5_paths = _swopp3_run._loadable_era5_paths +_load_experiment_profile = _swopp3_run._load_experiment_profile +_resolve_case_ids = _swopp3_run._resolve_case_ids +_resolve_config_value_path = _swopp3_run._resolve_config_value_path +_validate_required_data_paths = _swopp3_run._validate_required_data_paths +_write_experiment_manifest = _swopp3_run._write_experiment_manifest +_runner = CliRunner() + + +def _write_config(tmp_path: Path, content: str) -> Path: + """Create a temporary SWOPP3 experiment config file.""" + config_dir = tmp_path / "configs" + config_dir.mkdir() + config_path = config_dir / "experiments.toml" + config_path.write_text(content) + return config_path + + +def test_loadable_era5_paths_adds_next_year_continuation(tmp_path: Path): + """Runner should pick up next-year continuation files automatically.""" + base = tmp_path / "era5_wind_pacific_2024.nc" + jan = tmp_path / "era5_wind_pacific_2025_01.nc" + feb = tmp_path / "era5_wind_pacific_2025_02.nc" + base.touch() + jan.touch() + feb.touch() + + assert _loadable_era5_paths(base) == [base, jan, feb] + + +def test_resolve_config_value_path_anchors_relative_paths_to_config_dir(tmp_path: Path): + """Relative config paths should resolve from the TOML directory.""" + config_path = tmp_path / "configs" / "experiments.toml" + config_path.parent.mkdir() + + resolved = _resolve_config_value_path(config_path, "../data/example.nc") + + assert resolved == (tmp_path / "data" / "example.nc").resolve() + + +def test_load_experiment_profile_merges_defaults_and_resolves_paths(tmp_path: Path): + """Profile loading should merge defaults with per-run overrides.""" + config_path = _write_config( + tmp_path, + """ +[swopp3.experiments.demo] +description = "Demo profile" +source_script = "scripts/swopp3_slurm.sh" +output_dir = "../output/demo" + +[swopp3.experiments.demo.defaults] +wind_path_atlantic = "../data/default_wind.nc" +wave_path_atlantic = "../data/default_wave.nc" +n_points = 178 +submission = 2 + +[[swopp3.experiments.demo.runs]] +name = "atlantic" +cases = ["AO_WPS"] + +[[swopp3.experiments.demo.runs]] +name = "override" +cases = "PO_WPS" +wind_path_atlantic = "../data/override_wind.nc" +""".strip(), + ) + + profile = _load_experiment_profile(config_path, "demo") + + assert profile["name"] == "demo" + assert profile["output_dir"] == (tmp_path / "output" / "demo").resolve() + assert len(profile["runs"]) == 2 + assert profile["runs"][0]["n_points"] == 178 + assert profile["runs"][0]["submission"] == 2 + assert ( + profile["runs"][0]["wind_path_atlantic"] + == (tmp_path / "data" / "default_wind.nc").resolve() + ) + assert ( + profile["runs"][1]["wind_path_atlantic"] + == (tmp_path / "data" / "override_wind.nc").resolve() + ) + assert profile["runs"][1]["cases"] == ["PO_WPS"] + + +def test_load_experiment_profile_raises_for_unknown_name(tmp_path: Path): + """Unknown experiment names should list the available profiles.""" + config_path = _write_config( + tmp_path, + """ +[swopp3.experiments.demo] +[[swopp3.experiments.demo.runs]] +name = "run" +cases = ["AO_WPS"] +""".strip(), + ) + + with pytest.raises(KeyError, match="Unknown SWOPP3 experiment 'missing'"): + _load_experiment_profile(config_path, "missing") + + +def test_load_experiment_profile_raises_for_empty_runs(tmp_path: Path): + """Profiles without runs should fail with a clear error.""" + config_path = _write_config( + tmp_path, + """ +[swopp3.experiments.demo] +description = "Demo profile" +""".strip(), + ) + + with pytest.raises(ValueError, match="does not define any runs"): + _load_experiment_profile(config_path, "demo") + + +def test_write_experiment_manifest_serializes_resolved_paths(tmp_path: Path): + """Manifest writing should preserve resolved paths as JSON strings.""" + config_path = tmp_path / "configs" / "experiments.toml" + config_path.parent.mkdir() + output_dir = tmp_path / "output" / "demo" + profile = { + "name": "demo", + "description": "Demo profile", + "source_script": "scripts/swopp3_slurm.sh", + "output_dir": output_dir, + "runs": [ + { + "name": "atlantic", + "cases": ["AO_WPS"], + "wind_path_atlantic": tmp_path / "data" / "wind.nc", + } + ], + } + + manifest_path = _write_experiment_manifest( + config_path=config_path, + profile=profile, + ) + + manifest = json.loads(manifest_path.read_text()) + assert manifest["experiment"] == "demo" + assert manifest["config_path"] == str(config_path) + assert manifest["output_dir"] == str(output_dir) + assert manifest["runs"][0]["wind_path_atlantic"] == str( + tmp_path / "data" / "wind.nc" + ) + + +def test_resolve_case_ids_filters_selected_strategy(): + """Strategy filtering should keep only matching case IDs.""" + case_ids = _resolve_case_ids(None, "optimised") + + assert case_ids + assert all(case_id.endswith(("WPS", "noWPS")) for case_id in case_ids) + assert all( + case_id in {"AO_WPS", "AO_noWPS", "PO_WPS", "PO_noWPS"} for case_id in case_ids + ) + + +def test_resolve_case_ids_raises_for_empty_strategy_match(): + """Unknown strategy filters should fail with a clear error.""" + with pytest.raises(ValueError, match="No cases match strategy 'missing'"): + _resolve_case_ids(None, "missing") + + +def test_run_configuration_raises_for_mismatched_weather_epochs( + tmp_path: Path, + monkeypatch, +): + """Runner should fail fast when wind and wave dataset epochs differ.""" + import routetools.era5.loader as loader + import routetools.swopp3 as swopp3 + import routetools.swopp3_runner as swopp3_runner + + wind_path = tmp_path / "era5_wind_atlantic_2024.nc" + wave_path = tmp_path / "era5_waves_atlantic_2024.nc" + wind_path.touch() + wave_path.touch() + + monkeypatch.setattr( + loader, + "load_dataset_epoch", + lambda target: datetime(2024, 1, 1) + if "wind" in str(target) + else datetime(2024, 1, 2), + ) + monkeypatch.setattr(loader, "load_era5_windfield", lambda target: object()) + monkeypatch.setattr(loader, "load_era5_wavefield", lambda target: object()) + monkeypatch.setattr( + loader, + "load_era5_vectorfield", + lambda target: pytest.fail("vectorfield should not load when epochs differ"), + ) + monkeypatch.setattr( + loader, + "load_natural_earth_land_mask", + lambda lon_range, lat_range: pytest.fail( + "land mask should not load when epochs differ" + ), + ) + monkeypatch.setattr(swopp3, "departures_2024", lambda: [datetime(2024, 1, 1)]) + monkeypatch.setattr( + swopp3_runner, + "run_case", + lambda *args, **kwargs: pytest.fail( + "run_case should not execute when epochs differ" + ), + ) + + with pytest.raises(ValueError, match="Wind and wave dataset epochs differ"): + _swopp3_run._run_swopp3_configuration( + cases=["AGC_WPS"], + strategy=None, + wind_path=None, + wave_path=None, + wind_path_atlantic=wind_path, + wave_path_atlantic=wave_path, + wind_path_pacific=None, + wave_path_pacific=None, + output_dir=tmp_path / "output", + submission=1, + n_points=100, + max_departures=1, + weather_penalty_weight=0.0, + wind_penalty_weight=0.0, + wave_penalty_weight=0.0, + distance_penalty_weight=0.0, + dt_eval_minutes=0.0, + cmaes_k=10, + sigma0=0.1, + popsize=200, + maxfevals=25000, + cmaes_verbose=False, + quiet=True, + ) + + +def test_shared_cli_paths_override_default_corridor_paths(monkeypatch): + """Shared CLI paths should override the built-in corridor defaults.""" + + class StopCli(Exception): + pass + + captured: dict[str, dict[str, Path]] = {} + + def fake_validate(case_ids, corridor_wind, corridor_wave): + captured["wind"] = corridor_wind.copy() + captured["wave"] = corridor_wave.copy() + raise StopCli() + + monkeypatch.setattr(_swopp3_run, "_validate_required_data_paths", fake_validate) + + with pytest.raises(StopCli): + _runner.invoke( + _swopp3_run.app, + [ + "--cases", + "AGC_WPS", + "--cases", + "PGC_WPS", + "--wind-path", + "shared_wind.nc", + "--wave-path", + "shared_wave.nc", + "--wind-path-atlantic", + "data/era5/era5_wind_atlantic_2024.nc", + "--wave-path-atlantic", + "data/era5/era5_waves_atlantic_2024.nc", + "--wind-path-pacific", + "data/era5/era5_wind_pacific_2024.nc", + "--wave-path-pacific", + "data/era5/era5_waves_pacific_2024.nc", + "--output-dir", + "output/swopp3", + "--submission", + "1", + "--n-points", + "100", + "--max-departures", + "1", + "--quiet", + ], + catch_exceptions=False, + ) + + assert captured["wind"] == { + "atlantic": Path("shared_wind.nc"), + "pacific": Path("shared_wind.nc"), + } + assert captured["wave"] == { + "atlantic": Path("shared_wave.nc"), + "pacific": Path("shared_wave.nc"), + } + + +def test_validate_required_data_paths_reports_missing_files(tmp_path: Path): + """Validation error should explain which datasets are missing and why.""" + wind_path = tmp_path / "era5_wind_atlantic_2024.nc" + wave_path = tmp_path / "era5_waves_atlantic_2024.nc" + + with pytest.raises( + FileNotFoundError, + match="SWOPP3 input validation failed", + ) as exc_info: + _validate_required_data_paths( + ["AGC_WPS"], + {"atlantic": wind_path}, + {"atlantic": wave_path}, + ) + + message = str(exc_info.value) + assert str(wind_path) in message + assert str(wave_path) in message + assert "there is no fallback to GC or no-weather mode" in message + assert "uv run scripts/download_era5.py" in message + + +def test_validate_required_data_paths_accepts_existing_files(tmp_path: Path): + """Validation should pass when the required files already exist.""" + wind_path = tmp_path / "era5_wind_atlantic_2024.nc" + wave_path = tmp_path / "era5_waves_atlantic_2024.nc" + wind_path.touch() + wave_path.touch() + + _validate_required_data_paths( + ["AGC_WPS"], + {"atlantic": wind_path}, + {"atlantic": wave_path}, + ) diff --git a/tests/test_swopp3_runner.py b/tests/test_swopp3_runner.py new file mode 100644 index 00000000..4f8926bc --- /dev/null +++ b/tests/test_swopp3_runner.py @@ -0,0 +1,436 @@ +"""Tests for routetools.swopp3_runner — case runner and energy evaluation.""" + +from __future__ import annotations + +import csv +from datetime import UTC, datetime, timedelta +from pathlib import Path +from unittest.mock import patch + +import jax.numpy as jnp +import numpy as np +import pytest + +from routetools.swopp3 import SWOPP3_CASES, great_circle_route +from routetools.swopp3_runner import ( + DepartureResult, + evaluate_energy, + run_case, + run_gc_departure, + run_optimised_departure, + segment_bearings_deg, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +_DEP = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) +_N = 50 # waypoints for tests + + +def _atlantic_gc(n: int = _N) -> jnp.ndarray: + """Great circle route Santander → New York.""" + src = jnp.array([-4.0, 43.6]) + dst = jnp.array([-69.0, 40.6]) + return great_circle_route(src, dst, n_points=n) + + +def _zero_windfield(lon, lat, t): + """Wind field that returns zero everywhere.""" + return jnp.zeros_like(lon), jnp.zeros_like(lon) + + +def _constant_windfield(tws: float = 10.0, direction_deg: float = 270.0): + """Wind field with constant speed and direction (FROM).""" + dir_rad = np.radians(direction_deg) + # Wind FROM direction_deg → components point opposite + u10 = -tws * np.sin(dir_rad) + v10 = -tws * np.cos(dir_rad) + + def _field(lon, lat, t): + return jnp.full_like(lon, u10), jnp.full_like(lon, v10) + + return _field + + +def _constant_wavefield(hs: float = 2.0, mwd: float = 270.0): + """Wave field with constant hs and direction.""" + + def _field(lon, lat, t): + return jnp.full_like(lon, hs), jnp.full_like(lon, mwd) + + return _field + + +# --------------------------------------------------------------------------- +# segment_bearings_deg +# --------------------------------------------------------------------------- +class TestSegmentBearings: + def test_eastward(self): + """Eastward route → bearing ≈ 90°.""" + curve = jnp.array([[0.0, 0.0], [1.0, 0.0]]) + b = segment_bearings_deg(curve) + assert len(b) == 1 + assert abs(b[0] - 90.0) < 1.0 + + def test_northward(self): + """Northward route → bearing ≈ 0° (or 360°).""" + curve = jnp.array([[0.0, 0.0], [0.0, 1.0]]) + b = segment_bearings_deg(curve) + assert abs(b[0] % 360.0) < 1.0 + + def test_westward(self): + curve = jnp.array([[1.0, 0.0], [0.0, 0.0]]) + b = segment_bearings_deg(curve) + assert abs(b[0] - 270.0) < 1.0 + + def test_multiple_segments(self): + curve = jnp.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]) + b = segment_bearings_deg(curve) + assert len(b) == 2 + assert abs(b[0] - 90.0) < 1.0 # east + assert abs(b[1] % 360.0) < 1.0 # north + + +# --------------------------------------------------------------------------- +# evaluate_energy +# --------------------------------------------------------------------------- +class TestEvaluateEnergy: + def test_zero_wind_returns_hull_drag_only(self): + """With zero wind and waves, energy should be pure hull drag.""" + curve = _atlantic_gc() + energy, max_tws, max_hs = evaluate_energy( + curve, + _DEP, + 354.0, + wps=False, + windfield=_zero_windfield, + ) + assert energy > 0, "Hull drag should produce positive energy" + assert max_tws == pytest.approx(0.0, abs=1e-3) + assert max_hs == 0.0 + + def test_no_fields_no_energy(self): + """With no fields at all (None), everything is zero wind/wave.""" + curve = _atlantic_gc() + energy, max_tws, max_hs = evaluate_energy( + curve, + _DEP, + 354.0, + wps=False, + ) + assert energy > 0 # hull drag only + + def test_wps_reduces_energy(self): + """WPS (wingsails) should never increase energy.""" + curve = _atlantic_gc() + wf = _constant_windfield(tws=15.0, direction_deg=180.0) + + e_no_wps, _, _ = evaluate_energy( + curve, + _DEP, + 354.0, + wps=False, + windfield=wf, + ) + e_wps, _, _ = evaluate_energy( + curve, + _DEP, + 354.0, + wps=True, + windfield=wf, + ) + assert ( + e_wps <= e_no_wps + 1e-6 + ), f"WPS energy {e_wps} should be ≤ noWPS energy {e_no_wps}" + + def test_with_wavefield(self): + """Wavefield should increase energy (added resistance).""" + curve = _atlantic_gc() + + e_calm, _, _ = evaluate_energy( + curve, + _DEP, + 354.0, + wps=False, + ) + wv = _constant_wavefield(hs=4.0, mwd=270.0) + e_waves, _, max_hs = evaluate_energy( + curve, + _DEP, + 354.0, + wps=False, + wavefield=wv, + ) + assert e_waves > e_calm, "Waves should add resistance" + assert max_hs == pytest.approx(4.0, abs=0.1) + + def test_max_tws_correct(self): + curve = _atlantic_gc(20) + wf = _constant_windfield(tws=12.5, direction_deg=0.0) + _, max_tws, _ = evaluate_energy( + curve, + _DEP, + 354.0, + wps=False, + windfield=wf, + ) + assert max_tws == pytest.approx(12.5, abs=0.5) + + def test_curve_with_single_point_raises(self): + """Routes with fewer than two points are invalid for segment-based energy.""" + curve = jnp.array([[0.0, 0.0]]) + with pytest.raises(ValueError, match="at least 2 points"): + evaluate_energy(curve, _DEP, 354.0, wps=False) + + +# --------------------------------------------------------------------------- +# run_gc_departure +# --------------------------------------------------------------------------- +class TestRunGCDeparture: + def test_returns_result(self): + result = run_gc_departure("AGC_WPS", _DEP, n_points=20) + assert isinstance(result, DepartureResult) + assert result.departure == _DEP + assert result.curve.shape == (20, 2) + assert result.distance_nm > 0 + assert result.energy_mwh > 0 + + def test_gc_distance_reasonable(self): + """Atlantic GC distance should be roughly 2800-3000 nm.""" + result = run_gc_departure("AGC_noWPS", _DEP, n_points=200) + assert 2700 < result.distance_nm < 3200 + + def test_pacific_distance(self): + result = run_gc_departure("PGC_WPS", _DEP, n_points=200) + assert 4500 < result.distance_nm < 5000 + + +# --------------------------------------------------------------------------- +# run_optimised_departure +# --------------------------------------------------------------------------- +class TestRunOptimisedDeparture: + def test_requires_vectorfield(self): + """Optimised departures should fail fast when vectorfield is missing.""" + with pytest.raises(ValueError, match="requires a vectorfield"): + run_optimised_departure("AO_WPS", _DEP, n_points=20) + + def test_vectorfield_defaults_to_windfield_for_rise_cost(self, monkeypatch): + """When windfield is missing, vectorfield should feed RISE energy cost.""" + captured: dict[str, object] = {} + + def fake_cost_function_rise( + *, + windfield, + curve, + travel_time, + wavefield, + wps, + time_offset, + ): + captured["windfield"] = windfield + return jnp.zeros(curve.shape[0], dtype=jnp.float32) + + def fake_optimize(*, vectorfield, src, dst, land=None, **kwargs): + # Force one evaluation of the injected cost closure. + _ = kwargs["cost_fn"](jnp.zeros((1, kwargs["L"], 2), dtype=jnp.float32)) + return great_circle_route(src, dst, n_points=kwargs["L"]), {"cost": 0.0} + + monkeypatch.setattr( + "routetools.cost.cost_function_rise", + fake_cost_function_rise, + ) + monkeypatch.setattr("routetools.cmaes.optimize", fake_optimize) + + with pytest.warns( + UserWarning, + match="defaulting windfield to vectorfield", + ): + result = run_optimised_departure( + "AO_WPS", + _DEP, + vectorfield=_zero_windfield, + windfield=None, + n_points=20, + ) + + assert isinstance(result, DepartureResult) + assert captured["windfield"] is _zero_windfield + + +# --------------------------------------------------------------------------- +# run_case +# --------------------------------------------------------------------------- +class TestRunCase: + def test_gc_case_no_output(self): + """Run a GC case with 2 departures, no output dir.""" + deps = [_DEP, _DEP + timedelta(days=1)] + results = run_case( + "AGC_noWPS", + deps, + n_points=20, + verbose=False, + ) + assert len(results) == 2 + assert all(isinstance(r, DepartureResult) for r in results) + + def test_gc_case_with_output(self, tmp_path: Path): + """Run a GC case and write output files.""" + deps = [_DEP, _DEP + timedelta(days=1)] + run_case( + "AGC_noWPS", + deps, + output_dir=tmp_path, + submission=1, + n_points=20, + verbose=False, + ) + # File A should exist + fa = tmp_path / "IEUniversity-1-AGC_noWPS.csv" + assert fa.exists(), f"File A not found: {fa}" + with fa.open() as f: + reader = csv.DictReader(f) + rows = list(reader) + assert len(rows) == 2 + + # File B should exist for each departure + fb_dir = tmp_path / "tracks" + assert fb_dir.exists() + fb_files = list(fb_dir.glob("*.csv")) + assert len(fb_files) == 2 + + def test_optimised_case_with_vectorfield(self, tmp_path: Path): + """Optimised case writes output when the required vectorfield is provided.""" + deps = [_DEP] + results = run_case( + "AO_WPS", + deps, + vectorfield=_zero_windfield, + windfield=_zero_windfield, + output_dir=tmp_path, + submission=1, + n_points=20, + verbose=False, + ) + assert len(results) == 1 + fa = tmp_path / "IEUniversity-1-AO_WPS.csv" + assert fa.exists() + + def test_optimised_case_requires_vectorfield(self): + """Optimised cases should fail fast instead of silently degrading to GC.""" + with pytest.raises(ValueError, match="requires a vectorfield"): + run_case( + "AO_WPS", + [_DEP], + n_points=20, + verbose=False, + ) + + def test_all_cases_runnable(self): + """Smoke test: every case can run with 1 departure.""" + for case_id in SWOPP3_CASES: + kwargs = {} + if SWOPP3_CASES[case_id]["strategy"] == "optimised": + kwargs["vectorfield"] = _zero_windfield + kwargs["windfield"] = _zero_windfield + results = run_case( + case_id, + [_DEP], + n_points=10, + verbose=False, + **kwargs, + ) + assert len(results) == 1 + assert results[0].energy_mwh > 0 + + +# --------------------------------------------------------------------------- +# time_offset forwarding +# --------------------------------------------------------------------------- +class TestTimeOffsetForwarding: + """Verify that run_optimised_departure passes departure_offset_h to CMA-ES.""" + + def test_time_offset_forwarded_to_cmaes(self): + """CMA-ES optimize() must receive time_offset=departure_offset_h.""" + captured_kwargs: dict = {} + + def _spy_optimize(**kwargs): + captured_kwargs.update(kwargs) + # Return a dummy curve + info so the runner can proceed. + from routetools.swopp3 import great_circle_route + + n = kwargs.get("L", 20) + src = jnp.array([-4.0, 43.6]) + dst = jnp.array([-69.0, 40.6]) + curve = great_circle_route(src, dst, n_points=n) + return curve, {"cost": 100.0, "niter": 1, "comp_time": 0} + + dep = datetime(2024, 7, 15, 0, 0, 0, tzinfo=UTC) + epoch = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + expected_offset_h = (dep - epoch).total_seconds() / 3600.0 + + with patch("routetools.cmaes.optimize", side_effect=_spy_optimize): + # Use run_case which computes departure_offset_h from dataset_epoch + run_case( + "AO_WPS", + [dep], + vectorfield=_zero_windfield, + windfield=_zero_windfield, + n_points=20, + verbose=False, + dataset_epoch=epoch, + wind_penalty_weight=100.0, + ) + + assert ( + "time_offset" in captured_kwargs + ), "time_offset was not passed to cmaes.optimize" + assert abs(captured_kwargs["time_offset"] - expected_offset_h) < 0.01, ( + f"time_offset={captured_kwargs['time_offset']:.1f} but expected " + f"{expected_offset_h:.1f} hours from epoch" + ) + + def test_windfield_wavefield_forwarded_to_cmaes(self): + """CMA-ES optimize() must receive the actual windfield/wavefield closures.""" + captured_kwargs: dict = {} + + def _spy_optimize(**kwargs): + captured_kwargs.update(kwargs) + from routetools.swopp3 import great_circle_route + + n = kwargs.get("L", 20) + src = jnp.array([-4.0, 43.6]) + dst = jnp.array([-69.0, 40.6]) + curve = great_circle_route(src, dst, n_points=n) + return curve, {"cost": 100.0, "niter": 1, "comp_time": 0} + + wf = _constant_windfield(tws=15.0) + wavef = _constant_wavefield(hs=3.0) + + with patch("routetools.cmaes.optimize", side_effect=_spy_optimize): + run_case( + "AO_WPS", + [_DEP], + vectorfield=_zero_windfield, + windfield=wf, + wavefield=wavef, + n_points=20, + verbose=False, + wind_penalty_weight=100.0, + wave_penalty_weight=100.0, + ) + + assert captured_kwargs.get("windfield") is not None, ( + "windfield was not passed to cmaes.optimize — " + "penalty guards (windfield is not None) will always be False" + ) + assert captured_kwargs.get("wavefield") is not None, ( + "wavefield was not passed to cmaes.optimize — " + "penalty guards (wavefield is not None) will always be False" + ) + assert ( + captured_kwargs["windfield"] is wf + ), "windfield passed to cmaes.optimize is not the same object" + assert ( + captured_kwargs["wavefield"] is wavef + ), "wavefield passed to cmaes.optimize is not the same object" diff --git a/tests/test_swopp3_validate.py b/tests/test_swopp3_validate.py new file mode 100644 index 00000000..8fb1462e --- /dev/null +++ b/tests/test_swopp3_validate.py @@ -0,0 +1,313 @@ +"""Tests for routetools.swopp3_validate — output validation.""" + +from __future__ import annotations + +import csv +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from routetools.swopp3_validate import ( + validate_case_pair_strategy, + validate_case_pair_wps, + validate_file_a, + validate_file_b, + validate_submission_dir, +) + +_DTFMT = "%Y-%m-%d %H:%M:%S" +_DEP = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) + +_FILE_A_COLS = [ + "departure_time_utc", + "arrival_time_utc", + "energy_cons_mwh", + "max_wind_mps", + "max_hs_m", + "sailed_distance_nm", + "details_filename", +] + +_FILE_B_COLS = ["time_utc", "lat_deg", "lon_deg"] + + +def _write_file_a(path: Path, n: int = 2) -> None: + """Write a valid File A CSV for testing.""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=_FILE_A_COLS) + writer.writeheader() + for i in range(n): + dep = _DEP + timedelta(days=i) + arr = dep + timedelta(hours=354) + writer.writerow( + { + "departure_time_utc": dep.strftime(_DTFMT), + "arrival_time_utc": arr.strftime(_DTFMT), + "energy_cons_mwh": f"{10.0 + i:.6f}", + "max_wind_mps": f"{15.0:.4f}", + "max_hs_m": f"{3.0:.4f}", + "sailed_distance_nm": f"{2800.0:.4f}", + "details_filename": ( + f"IEUniversity-1-TEST-{dep.strftime('%Y%m%d')}.csv" + ), + } + ) + + +def _write_file_b(path: Path, n_waypoints: int = 5) -> None: + """Write a valid File B CSV for testing.""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=_FILE_B_COLS) + writer.writeheader() + for i in range(n_waypoints): + t = _DEP + timedelta(hours=i * 10) + writer.writerow( + { + "time_utc": t.strftime(_DTFMT), + "lat_deg": f"{43.6 - i * 0.5:.6f}", + "lon_deg": f"{-4.0 - i * 10:.6f}", + } + ) + + +# --------------------------------------------------------------------------- +# validate_file_a +# --------------------------------------------------------------------------- +class TestValidateFileA: + def test_valid_file(self, tmp_path: Path): + fa = tmp_path / "IEUniversity-1-AGC_WPS.csv" + _write_file_a(fa) + errs = validate_file_a(fa, expected_rows=2) + assert not errs, errs + + def test_missing_file(self, tmp_path: Path): + fa = tmp_path / "IEUniversity-1-AGC_WPS.csv" + errs = validate_file_a(fa) + assert any("not found" in e.message for e in errs) + + def test_wrong_row_count(self, tmp_path: Path): + fa = tmp_path / "IEUniversity-1-AGC_WPS.csv" + _write_file_a(fa, n=3) + errs = validate_file_a(fa, expected_rows=5) + assert any("Expected 5 rows" in e.message for e in errs) + + def test_missing_column(self, tmp_path: Path): + fa = tmp_path / "IEUniversity-1-AGC_WPS.csv" + fa.parent.mkdir(parents=True, exist_ok=True) + with fa.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["departure_time_utc"]) + writer.writeheader() + writer.writerow({"departure_time_utc": "2024-01-01 12:00:00"}) + errs = validate_file_a(fa, expected_rows=1) + assert any("Missing columns" in e.message for e in errs) + + def test_bad_datetime(self, tmp_path: Path): + fa = tmp_path / "IEUniversity-1-AGC_WPS.csv" + fa.parent.mkdir(parents=True, exist_ok=True) + with fa.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=_FILE_A_COLS) + writer.writeheader() + writer.writerow( + { + "departure_time_utc": "not-a-date", + "arrival_time_utc": "2024-01-01 12:00:00", + "energy_cons_mwh": "10.0", + "max_wind_mps": "5.0", + "max_hs_m": "2.0", + "sailed_distance_nm": "2800", + "details_filename": "track.csv", + } + ) + errs = validate_file_a(fa, expected_rows=1) + assert any("Bad datetime" in e.message for e in errs) + + def test_nan_energy(self, tmp_path: Path): + fa = tmp_path / "IEUniversity-1-AGC_WPS.csv" + fa.parent.mkdir(parents=True, exist_ok=True) + with fa.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=_FILE_A_COLS) + writer.writeheader() + writer.writerow( + { + "departure_time_utc": "2024-01-01 12:00:00", + "arrival_time_utc": "2024-01-16 18:00:00", + "energy_cons_mwh": "nan", + "max_wind_mps": "5.0", + "max_hs_m": "2.0", + "sailed_distance_nm": "2800", + "details_filename": "track.csv", + } + ) + errs = validate_file_a(fa, expected_rows=1) + assert any("NaN" in e.message for e in errs) + + def test_naming_convention(self, tmp_path: Path): + fa = tmp_path / "wrong_name.csv" + _write_file_a(fa) + errs = validate_file_a(fa, expected_rows=2) + assert any("doesn't match pattern" in e.message for e in errs) + + +# --------------------------------------------------------------------------- +# validate_file_b +# --------------------------------------------------------------------------- +class TestValidateFileB: + def test_valid_file(self, tmp_path: Path): + fb = tmp_path / "track.csv" + _write_file_b(fb) + errs = validate_file_b(fb) + assert not errs, errs + + def test_missing_file(self, tmp_path: Path): + fb = tmp_path / "missing.csv" + errs = validate_file_b(fb) + assert any("not found" in e.message for e in errs) + + def test_too_few_waypoints(self, tmp_path: Path): + fb = tmp_path / "short.csv" + _write_file_b(fb, n_waypoints=1) + errs = validate_file_b(fb, min_waypoints=5) + assert any("waypoints" in e.message for e in errs) + + def test_non_increasing_time(self, tmp_path: Path): + fb = tmp_path / "bad_time.csv" + fb.parent.mkdir(parents=True, exist_ok=True) + with fb.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=_FILE_B_COLS) + writer.writeheader() + writer.writerow( + {"time_utc": "2024-01-02 12:00:00", "lat_deg": "43", "lon_deg": "-4"} + ) + writer.writerow( + {"time_utc": "2024-01-01 12:00:00", "lat_deg": "42", "lon_deg": "-5"} + ) + errs = validate_file_b(fb) + assert any("not strictly increasing" in e.message for e in errs) + + def test_lat_out_of_range(self, tmp_path: Path): + fb = tmp_path / "bad_lat.csv" + fb.parent.mkdir(parents=True, exist_ok=True) + with fb.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=_FILE_B_COLS) + writer.writeheader() + writer.writerow( + {"time_utc": "2024-01-01 12:00:00", "lat_deg": "100", "lon_deg": "-4"} + ) + errs = validate_file_b(fb) + assert any("out of range" in e.message for e in errs) + + +# --------------------------------------------------------------------------- +# validate_case_pair_wps +# --------------------------------------------------------------------------- +class TestValidateCasePairWPS: + def test_wps_leq_nowps(self, tmp_path: Path): + """WPS energy ≤ noWPS → no errors.""" + wps = tmp_path / "IEUniversity-1-AGC_WPS.csv" + nowps = tmp_path / "IEUniversity-1-AGC_noWPS.csv" + # WPS has lower energy + _write_file_a_custom(wps, [8.0, 9.0]) + _write_file_a_custom(nowps, [10.0, 11.0]) + errs = validate_case_pair_wps(wps, nowps) + assert not errs + + def test_wps_greater_nowps(self, tmp_path: Path): + """WPS energy > noWPS → one error.""" + wps = tmp_path / "IEUniversity-1-AGC_WPS.csv" + nowps = tmp_path / "IEUniversity-1-AGC_noWPS.csv" + _write_file_a_custom(wps, [12.0, 13.0]) + _write_file_a_custom(nowps, [10.0, 11.0]) + errs = validate_case_pair_wps(wps, nowps) + assert len(errs) == 1 + assert "WPS energy > noWPS" in errs[0].message + + def test_missing_file_returns_validation_error(self, tmp_path: Path): + """Missing input should be reported as ValidationError, not exception.""" + wps = tmp_path / "IEUniversity-1-AGC_WPS.csv" + nowps = tmp_path / "IEUniversity-1-AGC_noWPS.csv" + _write_file_a_custom(wps, [12.0, 13.0]) + errs = validate_case_pair_wps(wps, nowps) + assert len(errs) == 1 + assert "Cannot load energies" in errs[0].message + + +def _write_file_a_custom(path: Path, energies: list[float]) -> None: + """Helper: write File A with specified energies.""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=_FILE_A_COLS) + writer.writeheader() + for i, e in enumerate(energies): + dep = _DEP + timedelta(days=i) + arr = dep + timedelta(hours=354) + writer.writerow( + { + "departure_time_utc": dep.strftime(_DTFMT), + "arrival_time_utc": arr.strftime(_DTFMT), + "energy_cons_mwh": f"{e:.6f}", + "max_wind_mps": "15.0000", + "max_hs_m": "3.0000", + "sailed_distance_nm": "2800.0000", + "details_filename": f"track_{i}.csv", + } + ) + + +# --------------------------------------------------------------------------- +# validate_submission_dir (integration test) +# --------------------------------------------------------------------------- +class TestValidateSubmissionDir: + def test_empty_dir_reports_missing(self, tmp_path: Path): + errs = validate_submission_dir(tmp_path, expected_departures=2, verbose=False) + # Should report missing File A for all 8 cases + missing = [e for e in errs if "not found" in e.message] + assert len(missing) == 8 + + +# --------------------------------------------------------------------------- +# validate_case_pair_strategy (optimised vs GC baseline) +# --------------------------------------------------------------------------- +class TestValidateCasePairStrategy: + def test_optimised_always_better_no_errors(self, tmp_path: Path): + """All departures have optimised ≤ GC → no errors.""" + opt = tmp_path / "opt.csv" + gc = tmp_path / "gc.csv" + _write_file_a_custom(opt, [90.0, 85.0, 80.0]) + _write_file_a_custom(gc, [100.0, 100.0, 100.0]) + errs = validate_case_pair_strategy(opt, gc) + assert errs == [] + + def test_under_threshold_no_errors(self, tmp_path: Path): + """Exactly 10% worse departures (1 of 10) → allowed, no errors.""" + opt_energies = [90.0] * 9 + [110.0] # 1 out of 10 worse + gc_energies = [100.0] * 10 + opt = tmp_path / "opt.csv" + gc = tmp_path / "gc.csv" + _write_file_a_custom(opt, opt_energies) + _write_file_a_custom(gc, gc_energies) + errs = validate_case_pair_strategy(opt, gc) + assert errs == [] + + def test_over_threshold_reports_error(self, tmp_path: Path): + """More than 10% worse departures → returns errors.""" + opt_energies = [110.0] * 10 # all worse + gc_energies = [100.0] * 10 + opt = tmp_path / "opt.csv" + gc = tmp_path / "gc.csv" + _write_file_a_custom(opt, opt_energies) + _write_file_a_custom(gc, gc_energies) + errs = validate_case_pair_strategy(opt, gc) + assert len(errs) == 1 + assert ( + "worse" in errs[0].message.lower() or "optimised" in errs[0].message.lower() + ) + + def test_missing_file_returns_validation_error(self, tmp_path: Path): + """Missing baseline file should be reported as ValidationError.""" + opt = tmp_path / "opt.csv" + gc = tmp_path / "gc.csv" + _write_file_a_custom(opt, [90.0, 85.0, 80.0]) + errs = validate_case_pair_strategy(opt, gc) + assert len(errs) == 1 + assert "Cannot load energies" in errs[0].message diff --git a/tests/test_validate_routes.py b/tests/test_validate_routes.py new file mode 100644 index 00000000..717c41f9 --- /dev/null +++ b/tests/test_validate_routes.py @@ -0,0 +1,144 @@ +"""Tests for scripts/validate_routes.py land-intersection validation.""" + +from __future__ import annotations + +# Import the functions under test +import importlib.util +import sys +from pathlib import Path + +import numpy as np +import pytest +from shapely.geometry import Polygon + +_spec = importlib.util.spec_from_file_location( + "validate_routes", + Path(__file__).resolve().parent.parent / "scripts" / "validate_routes.py", +) +_mod = importlib.util.module_from_spec(_spec) +sys.modules["validate_routes"] = _mod +_spec.loader.exec_module(_mod) + +validate_track = _mod.validate_track +_interpolate_segment = _mod._interpolate_segment + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- +@pytest.fixture +def square_land(): + """A simple square land polygon: lon ∈ [1, 3], lat ∈ [1, 3].""" + return Polygon([(1, 1), (3, 1), (3, 3), (1, 3)]) + + +# --------------------------------------------------------------------------- +# Tests for _interpolate_segment +# --------------------------------------------------------------------------- +class TestInterpolateSegment: + def test_endpoints_included(self): + p1 = np.array([0.0, 0.0]) + p2 = np.array([10.0, 0.0]) + pts = _interpolate_segment(p1, p2, density=5) + assert pts.shape == (6, 2) + np.testing.assert_allclose(pts[0], p1) + np.testing.assert_allclose(pts[-1], p2) + + def test_density_one(self): + p1 = np.array([0.0, 0.0]) + p2 = np.array([4.0, 0.0]) + pts = _interpolate_segment(p1, p2, density=1) + assert pts.shape == (2, 2) + + +# --------------------------------------------------------------------------- +# Tests for validate_track +# --------------------------------------------------------------------------- +class TestValidateTrack: + def test_no_land_crossing(self, square_land): + """Route passes entirely outside the land polygon.""" + waypoints = np.array( + [ + [0.0, 0.0], + [0.0, 5.0], + [5.0, 5.0], + ] + ) + violations = validate_track(waypoints, square_land, density=20) + assert violations == [] + + def test_clear_land_crossing(self, square_land): + """Route goes straight through the land polygon.""" + waypoints = np.array( + [ + [0.0, 2.0], + [5.0, 2.0], + ] + ) + violations = validate_track(waypoints, square_land, density=50) + assert len(violations) == 1 + assert violations[0]["segment"] == 0 + assert violations[0]["land_fraction"] > 0 + + def test_multiple_segments_one_violation(self, square_land): + """Only the segment crossing land is flagged.""" + waypoints = np.array( + [ + [-1.0, 2.0], # before land + [0.0, 2.0], # still before land + [5.0, 2.0], # crosses land + [6.0, 2.0], # after land + ] + ) + violations = validate_track(waypoints, square_land, density=50) + # Only segment 1→2 (from [0,2] to [5,2]) crosses land + assert len(violations) == 1 + assert violations[0]["from_idx"] == 1 + + def test_boundary_touching(self, square_land): + """Segment touching the land boundary should be detected.""" + waypoints = np.array( + [ + [1.0, 0.0], + [1.0, 2.0], # runs along the left edge of the square + ] + ) + violations = validate_track(waypoints, square_land, density=50) + # Endpoint at (1, 2) is on the boundary — covers() should catch it + assert len(violations) >= 1 + + def test_entirely_on_land(self, square_land): + """Segment entirely inside land polygon.""" + waypoints = np.array( + [ + [1.5, 1.5], + [2.5, 2.5], + ] + ) + violations = validate_track(waypoints, square_land, density=20) + assert len(violations) == 1 + assert violations[0]["land_fraction"] == 1.0 + + def test_single_point(self, square_land): + """Single waypoint — no segments to check.""" + waypoints = np.array([[2.0, 2.0]]) + violations = validate_track(waypoints, square_land, density=10) + assert violations == [] + + def test_returns_correct_keys(self, square_land): + """Violation dict has all expected keys.""" + waypoints = np.array([[0.0, 2.0], [5.0, 2.0]]) + violations = validate_track(waypoints, square_land, density=10) + assert len(violations) == 1 + v = violations[0] + expected_keys = { + "segment", + "from_idx", + "to_idx", + "from_coord", + "to_coord", + "land_points", + "total_points", + "land_fraction", + } + assert set(v.keys()) == expected_keys diff --git a/tests/test_violations.py b/tests/test_violations.py new file mode 100644 index 00000000..82116399 --- /dev/null +++ b/tests/test_violations.py @@ -0,0 +1,234 @@ +"""Tests for the routetools.violations module.""" + +import csv +from datetime import datetime, timedelta +from pathlib import Path + +import pytest + +from routetools.violations import ( + ScenarioViolationCounts, + count_folder_violations, + count_land_violations, + count_summary_weather_violations, + find_team_prefix, + format_grouped_violation_table, + is_gc_case, + write_grouped_violation_csv, +) + +_DTFMT = "%Y-%m-%d %H:%M:%S" +_DEP = datetime(2024, 1, 1, 12, 0, 0) +_FILE_A_COLS = [ + "departure_time_utc", + "arrival_time_utc", + "energy_cons_mwh", + "max_wind_mps", + "max_hs_m", + "sailed_distance_nm", + "details_filename", +] +_FILE_B_COLS = ["time_utc", "lat_deg", "lon_deg"] + + +def _write_file_a(path: Path, rows: list[dict[str, str]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=_FILE_A_COLS) + writer.writeheader() + writer.writerows(rows) + + +def _write_file_b(path: Path, rows: list[dict[str, str]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=_FILE_B_COLS) + writer.writeheader() + writer.writerows(rows) + + +def _summary_row( + *, dep: datetime, wind: float, wave: float, details: str +) -> dict[str, str]: + return { + "departure_time_utc": dep.strftime(_DTFMT), + "arrival_time_utc": (dep + timedelta(hours=10)).strftime(_DTFMT), + "energy_cons_mwh": "100.0", + "max_wind_mps": f"{wind:.1f}", + "max_hs_m": f"{wave:.1f}", + "sailed_distance_nm": "2800.0", + "details_filename": details, + } + + +def _track_rows(n_waypoints: int = 6) -> list[dict[str, str]]: + rows = [] + for index in range(n_waypoints): + rows.append( + { + "time_utc": (_DEP + timedelta(hours=index)).strftime(_DTFMT), + "lat_deg": f"{40.0 + index:.6f}", + "lon_deg": f"{-10.0 - index:.6f}", + } + ) + return rows + + +def test_count_summary_weather_violations(tmp_path: Path) -> None: + summary_path = tmp_path / "IEUniversity-1-AO_WPS.csv" + _write_file_a( + summary_path, + [ + _summary_row(dep=_DEP, wind=19.0, wave=6.5, details="track_a.csv"), + _summary_row( + dep=_DEP + timedelta(days=1), wind=21.0, wave=6.0, details="track_b.csv" + ), + _summary_row( + dep=_DEP + timedelta(days=2), wind=18.0, wave=7.5, details="track_c.csv" + ), + ], + ) + + wind_violations, wave_violations = count_summary_weather_violations( + summary_path, + ) + + assert wind_violations == 1 + assert wave_violations == 1 + + +def test_count_land_violations_uses_land_checker(tmp_path: Path) -> None: + track_path = tmp_path / "track.csv" + _write_file_b(track_path, _track_rows(n_waypoints=5)) + + def land_checker(lat: float, lon: float) -> bool: + return lat >= 43.0 + + assert count_land_violations(track_path, land_checker) == 2 + + +def test_count_folder_violations_counts_one_folder(tmp_path: Path) -> None: + input_dir = tmp_path / "swopp3_penalty" + tracks_dir = input_dir / "tracks" + + _write_file_a( + input_dir / "IEUniversity-1-AO_WPS.csv", + [ + _summary_row(dep=_DEP, wind=21.0, wave=8.0, details="ao_track.csv"), + ], + ) + _write_file_b(tracks_dir / "ao_track.csv", _track_rows(n_waypoints=4)) + + _write_file_a( + input_dir / "IEUniversity-1-AGC_WPS.csv", + [ + _summary_row(dep=_DEP, wind=21.0, wave=8.0, details="gc_track.csv"), + ], + ) + _write_file_b(tracks_dir / "gc_track.csv", _track_rows(n_waypoints=4)) + + rows = count_folder_violations( + input_dir, + land_checker=lambda lat, lon: lat >= 42.0, + include_gc=False, + ) + + assert len(rows) == 1 + assert rows[0].folder == "swopp3_penalty" + assert rows[0].case_id == "AO_WPS" + assert rows[0].wind_violations == 1 + assert rows[0].wave_violations == 1 + assert rows[0].land_violations == 2 + + +def test_find_team_prefix_detects_known_pattern(tmp_path: Path) -> None: + _write_file_a( + tmp_path / "IEUniversity-7-AO_WPS.csv", + [_summary_row(dep=_DEP, wind=10.0, wave=2.0, details="track.csv")], + ) + + assert find_team_prefix(tmp_path) == "IEUniversity-7" + + +def test_find_team_prefix_raises_when_missing(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError, match="No SWOPP3 File A CSV files found"): + find_team_prefix(tmp_path) + + +@pytest.mark.parametrize( + ("case_id", "expected"), + [ + ("AGC_WPS", True), + ("AGC_noWPS", True), + ("AO_WPS", False), + ("PO_noWPS", False), + ], +) +def test_is_gc_case(case_id: str, expected: bool) -> None: + assert is_gc_case(case_id) is expected + + +@pytest.mark.parametrize( + ("wind", "wave", "land", "wind_pen", "wave_pen"), + [ + (0, 0, 0, 0.0, 0.0), + (1, 2, 3, 4.5, 5.5), + (10, 20, 30, 1.25, 2.75), + ], +) +def test_scenario_violation_counts_totals( + wind: int, + wave: int, + land: int, + wind_pen: float, + wave_pen: float, +) -> None: + row = ScenarioViolationCounts( + folder="folder", + case_id="AO_WPS", + wind_violations=wind, + wave_violations=wave, + land_violations=land, + wind_penalty=wind_pen, + wave_penalty=wave_pen, + ) + + assert row.total_violations == wind + wave + land + assert row.total_penalty == pytest.approx(wind_pen + wave_pen) + + +def test_grouped_violation_table_and_csv_roundtrip(tmp_path: Path) -> None: + rows = [ + ScenarioViolationCounts( + folder="swopp3_no_penalty", + case_id="AO_WPS", + wind_violations=1, + wave_violations=2, + land_violations=3, + wind_penalty=4.0, + wave_penalty=5.0, + ), + ScenarioViolationCounts( + folder="swopp3_penalty", + case_id="AO_WPS", + wind_violations=0, + wave_violations=1, + land_violations=1, + wind_penalty=1.5, + wave_penalty=2.5, + ), + ] + + table = format_grouped_violation_table(rows) + output_path = write_grouped_violation_csv(rows, tmp_path / "violations.csv") + + with output_path.open(newline="") as handle: + exported = list(csv.DictReader(handle)) + + assert "swopp3_no_penalty" in table + assert "swopp3_penalty" in table + assert any(row["case_id"] == "AO_WPS" for row in exported) + total_rows = [row for row in exported if row["case_id"] == "TOTAL"] + assert len(total_rows) == 2 + assert total_rows[0]["total_violations"] == "6" + assert total_rows[1]["total_penalty"] == "4.0" diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 00000000..367bd982 --- /dev/null +++ b/tests/test_weather.py @@ -0,0 +1,569 @@ +"""Tests for routetools.weather — weather constraint penalties.""" + +import jax.numpy as jnp +import pytest + +from routetools.weather import ( + DEFAULT_HS_LIMIT, + DEFAULT_TWS_LIMIT, + RouteWeatherStats, + evaluate_weather, + wave_penalty_smooth, + weather_penalty, + weather_penalty_smooth, + wind_penalty_smooth, +) + + +# --------------------------------------------------------------------------- +# Synthetic field helpers +# --------------------------------------------------------------------------- +def _constant_windfield(u: float, v: float): + """Return a windfield closure producing constant (u, v).""" + + def _field(lon, lat, t): + return jnp.full_like(lon, u), jnp.full_like(lon, v) + + return _field + + +def _constant_wavefield(hs: float, mwd: float = 0.0): + """Return a wavefield closure producing constant (hs, mwd).""" + + def _field(lon, lat, t): + return jnp.full_like(lon, hs), jnp.full_like(lon, mwd) + + return _field + + +def _make_curve(n_routes: int = 1, n_points: int = 10) -> jnp.ndarray: + """Create a batch of straight-line curves from (0,0) to (1,0).""" + src = jnp.array([0.0, 0.0]) + dst = jnp.array([1.0, 0.0]) + single = jnp.linspace(src, dst, n_points) # (L, 2) + return jnp.tile(single[jnp.newaxis, :, :], (n_routes, 1, 1)) # (B, L, 2) + + +# --------------------------------------------------------------------------- +# Tests for default constants +# --------------------------------------------------------------------------- +class TestDefaults: + def test_tws_limit(self): + assert DEFAULT_TWS_LIMIT == 20.0 + + def test_hs_limit(self): + assert DEFAULT_HS_LIMIT == 7.0 + + +# --------------------------------------------------------------------------- +# Tests for RouteWeatherStats +# --------------------------------------------------------------------------- +class TestRouteWeatherStats: + def test_frozen(self): + stats = RouteWeatherStats( + max_tws=jnp.array([10.0]), + max_hs=jnp.array([3.0]), + tws_exceeded=jnp.array([False]), + hs_exceeded=jnp.array([False]), + ) + with pytest.raises(AttributeError): + stats.max_tws = jnp.array([99.0]) + + def test_fields(self): + stats = RouteWeatherStats( + max_tws=jnp.array([10.0]), + max_hs=jnp.array([3.0]), + tws_exceeded=jnp.array([False]), + hs_exceeded=jnp.array([False]), + ) + assert stats.max_tws.shape == (1,) + assert stats.max_hs.shape == (1,) + assert stats.tws_exceeded.shape == (1,) + assert stats.hs_exceeded.shape == (1,) + + +# --------------------------------------------------------------------------- +# Tests for evaluate_weather +# --------------------------------------------------------------------------- +class TestEvaluateWeather: + """Test evaluate_weather with synthetic fields.""" + + def test_no_fields_returns_zeros(self): + curve = _make_curve() + stats = evaluate_weather(curve) + assert jnp.allclose(stats.max_tws, 0.0) + assert jnp.allclose(stats.max_hs, 0.0) + assert not stats.tws_exceeded.any() + assert not stats.hs_exceeded.any() + + def test_within_limits(self): + curve = _make_curve() + wf = _constant_windfield(10.0, 0.0) # TWS = 10 < 20 + wvf = _constant_wavefield(3.0) # Hs = 3 < 7 + stats = evaluate_weather(curve, windfield=wf, wavefield=wvf) + assert jnp.allclose(stats.max_tws, 10.0, atol=1e-5) + assert jnp.allclose(stats.max_hs, 3.0, atol=1e-5) + assert not stats.tws_exceeded.any() + assert not stats.hs_exceeded.any() + + def test_tws_exceeded(self): + curve = _make_curve() + wf = _constant_windfield(15.0, 15.0) # TWS ≈ 21.2 > 20 + stats = evaluate_weather(curve, windfield=wf) + assert stats.tws_exceeded.all() + + def test_hs_exceeded(self): + curve = _make_curve() + wvf = _constant_wavefield(8.0) # Hs = 8 > 7 + stats = evaluate_weather(curve, wavefield=wvf) + assert stats.hs_exceeded.all() + + def test_batch_dimension(self): + curve = _make_curve(n_routes=5) + wf = _constant_windfield(10.0, 0.0) + stats = evaluate_weather(curve, windfield=wf) + assert stats.max_tws.shape == (5,) + assert stats.tws_exceeded.shape == (5,) + + def test_custom_limits(self): + curve = _make_curve() + wf = _constant_windfield(10.0, 0.0) # TWS = 10 + stats = evaluate_weather(curve, windfield=wf, tws_limit=5.0) + assert stats.tws_exceeded.all() # 10 > 5 + + def test_wind_only(self): + curve = _make_curve() + wf = _constant_windfield(25.0, 0.0) + stats = evaluate_weather(curve, windfield=wf) + assert jnp.allclose(stats.max_tws, 25.0, atol=1e-5) + assert jnp.allclose(stats.max_hs, 0.0) + + def test_wave_only(self): + curve = _make_curve() + wvf = _constant_wavefield(5.0) + stats = evaluate_weather(curve, wavefield=wvf) + assert jnp.allclose(stats.max_tws, 0.0) + assert jnp.allclose(stats.max_hs, 5.0, atol=1e-5) + + def test_tws_from_vector_components(self): + """TWS = sqrt(u² + v²) for diagonal wind.""" + curve = _make_curve() + wf = _constant_windfield(12.0, 16.0) # TWS = 20 exactly + stats = evaluate_weather(curve, windfield=wf) + assert jnp.allclose(stats.max_tws, 20.0, atol=1e-5) + # Exactly at limit — not exceeded + assert not stats.tws_exceeded.any() + + +# --------------------------------------------------------------------------- +# Tests for weather_penalty (hard step) +# --------------------------------------------------------------------------- +class TestWeatherPenalty: + """Test the hard (step) weather penalty.""" + + def test_zero_when_within_limits(self): + curve = _make_curve() + wf = _constant_windfield(10.0, 0.0) + wvf = _constant_wavefield(3.0) + pen = weather_penalty(curve, windfield=wf, wavefield=wvf) + assert jnp.allclose(pen, 0.0) + + def test_nonzero_when_tws_exceeded(self): + curve = _make_curve(n_points=10) # 9 segments + wf = _constant_windfield(25.0, 0.0) + pen = weather_penalty(curve, windfield=wf, penalty=10.0) + # All 9 segments violate → 9 * 10 = 90 + assert jnp.allclose(pen, 90.0) + + def test_nonzero_when_hs_exceeded(self): + curve = _make_curve(n_points=10) + wvf = _constant_wavefield(10.0) + pen = weather_penalty(curve, wavefield=wvf, penalty=5.0) + assert jnp.allclose(pen, 45.0) # 9 * 5 + + def test_combined_violations(self): + curve = _make_curve(n_points=10) + wf = _constant_windfield(25.0, 0.0) + wvf = _constant_wavefield(10.0) + pen = weather_penalty(curve, windfield=wf, wavefield=wvf, penalty=1.0) + # 9 TWS violations + 9 Hs violations = 18 + assert jnp.allclose(pen, 18.0) + + def test_no_fields_returns_zero(self): + curve = _make_curve() + pen = weather_penalty(curve) + assert jnp.allclose(pen, 0.0) + + def test_batch(self): + curve = _make_curve(n_routes=3) + wf = _constant_windfield(25.0, 0.0) + pen = weather_penalty(curve, windfield=wf, penalty=1.0) + assert pen.shape == (3,) + assert jnp.all(pen > 0) + + def test_custom_limits(self): + curve = _make_curve(n_points=10) + wf = _constant_windfield(10.0, 0.0) # TWS = 10 + pen = weather_penalty(curve, windfield=wf, tws_limit=5.0, penalty=1.0) + assert jnp.allclose(pen, 9.0) # 9 segments × penalty 1 + + def test_zero_penalty_weight(self): + curve = _make_curve() + wf = _constant_windfield(25.0, 0.0) + pen = weather_penalty(curve, windfield=wf, penalty=0.0) + assert jnp.allclose(pen, 0.0) + + +# --------------------------------------------------------------------------- +# Tests for weather_penalty_smooth +# --------------------------------------------------------------------------- +class TestWeatherPenaltySmooth: + """Test the smooth (differentiable) weather penalty.""" + + def test_zero_when_within_limits(self): + curve = _make_curve() + wf = _constant_windfield(10.0, 0.0) + wvf = _constant_wavefield(3.0) + pen = weather_penalty_smooth(curve, windfield=wf, wavefield=wvf) + assert jnp.allclose(pen, 0.0) + + def test_positive_when_exceeded(self): + curve = _make_curve() + wf = _constant_windfield(25.0, 0.0) # TWS = 25 > 20 + pen = weather_penalty_smooth(curve, windfield=wf) + assert jnp.all(pen > 0) + + def test_increases_with_excess(self): + curve = _make_curve() + wf_21 = _constant_windfield(21.0, 0.0) # excess = 1 + wf_25 = _constant_windfield(25.0, 0.0) # excess = 5 + pen_21 = weather_penalty_smooth(curve, windfield=wf_21) + pen_25 = weather_penalty_smooth(curve, windfield=wf_25) + assert jnp.all(pen_25 > pen_21) + + def test_quadratic_growth(self): + """Penalty grows as (excess)² — doubling excess quadruples penalty.""" + curve = _make_curve() + wf_22 = _constant_windfield(22.0, 0.0) # excess = 2 + wf_24 = _constant_windfield(24.0, 0.0) # excess = 4 + pen_22 = weather_penalty_smooth( + curve, windfield=wf_22, penalty=1.0, sharpness=1.0 + ) + pen_24 = weather_penalty_smooth( + curve, windfield=wf_24, penalty=1.0, sharpness=1.0 + ) + ratio = pen_24 / pen_22 + expected_ratio = (4.0**2) / (2.0**2) # 16/4 = 4 + assert jnp.allclose(ratio, expected_ratio, atol=1e-4) + + def test_sharpness_scales_penalty(self): + curve = _make_curve() + wf = _constant_windfield(25.0, 0.0) + pen_s1 = weather_penalty_smooth(curve, windfield=wf, sharpness=1.0) + pen_s5 = weather_penalty_smooth(curve, windfield=wf, sharpness=5.0) + assert jnp.allclose(pen_s5, pen_s1 * 5.0, atol=1e-4) + + def test_no_fields_returns_zero(self): + curve = _make_curve() + pen = weather_penalty_smooth(curve) + assert jnp.allclose(pen, 0.0) + + def test_batch(self): + curve = _make_curve(n_routes=4) + wvf = _constant_wavefield(10.0) + pen = weather_penalty_smooth(curve, wavefield=wvf) + assert pen.shape == (4,) + assert jnp.all(pen > 0) + + def test_at_limit_is_zero(self): + """Exactly at the limit → zero penalty (excess = 0).""" + curve = _make_curve() + wf = _constant_windfield(20.0, 0.0) # TWS = 20, limit = 20 + pen = weather_penalty_smooth(curve, windfield=wf) + assert jnp.allclose(pen, 0.0) + + +# --------------------------------------------------------------------------- +# Tests for resolution-independent normalization (max, not sum) +# --------------------------------------------------------------------------- +class TestPenaltyNormalization: + """Smooth penalties use max over segments, so they're resolution-independent.""" + + def test_wind_penalty_resolution_independent(self): + """wind_penalty_smooth should give the same value at different L.""" + wf = _constant_windfield(25.0, 0.0) # TWS = 25, excess = 5 + pen_10 = wind_penalty_smooth(_make_curve(n_points=10), windfield=wf, weight=1.0) + pen_50 = wind_penalty_smooth(_make_curve(n_points=50), windfield=wf, weight=1.0) + pen_100 = wind_penalty_smooth( + _make_curve(n_points=100), windfield=wf, weight=1.0 + ) + assert jnp.allclose(pen_10, pen_50, rtol=1e-3) + assert jnp.allclose(pen_10, pen_100, rtol=1e-3) + + def test_wave_penalty_resolution_independent(self): + """wave_penalty_smooth should give the same value at different L.""" + wvf = _constant_wavefield(10.0) # Hs = 10, excess = 3 + pen_10 = wave_penalty_smooth( + _make_curve(n_points=10), wavefield=wvf, weight=1.0 + ) + pen_50 = wave_penalty_smooth( + _make_curve(n_points=50), wavefield=wvf, weight=1.0 + ) + pen_100 = wave_penalty_smooth( + _make_curve(n_points=100), wavefield=wvf, weight=1.0 + ) + assert jnp.allclose(pen_10, pen_50, rtol=1e-3) + assert jnp.allclose(pen_10, pen_100, rtol=1e-3) + + def test_weather_penalty_smooth_resolution_independent(self): + """Combined smooth penalty should be resolution-independent.""" + wf = _constant_windfield(25.0, 0.0) + wvf = _constant_wavefield(10.0) + pen_10 = weather_penalty_smooth( + _make_curve(n_points=10), + windfield=wf, + wavefield=wvf, + penalty=1.0, + sharpness=1.0, + ) + pen_100 = weather_penalty_smooth( + _make_curve(n_points=100), + windfield=wf, + wavefield=wvf, + penalty=1.0, + sharpness=1.0, + ) + assert jnp.allclose(pen_10, pen_100, rtol=1e-3) + + def test_wind_penalty_exact_value(self): + """With constant wind, max(excess²) = excess² regardless of L.""" + wf = _constant_windfield(25.0, 0.0) # excess = 5 + pen = wind_penalty_smooth(_make_curve(n_points=10), windfield=wf, weight=1.0) + # max of 9 identical values of 25.0 = 25.0 + assert jnp.allclose(pen, 25.0, atol=1e-4) + + def test_wave_penalty_exact_value(self): + """With constant waves, max(excess²) = excess² regardless of L.""" + wvf = _constant_wavefield(10.0) # excess = 3 + pen = wave_penalty_smooth(_make_curve(n_points=10), wavefield=wvf, weight=1.0) + # max of 9 identical values of 9.0 = 9.0 + assert jnp.allclose(pen, 9.0, atol=1e-4) + + +class TestTimeVariation: + """Verify that weather is evaluated at the correct elapsed time.""" + + @staticmethod + def _time_varying_windfield(): + """Wind field that varies only in time (not space). + + TWS profile (piecewise-constant in time): + t < 1 day → TWS = 10 (below 20 m/s limit) + 1 ≤ t < 2 → TWS = 30 (above limit) + t ≥ 2 days → TWS = 10 (below limit) + + Implementation: u = TWS, v = 0 so TWS = |u|. + """ + day = 86400.0 # seconds + + def _field(lon, lat, t): + # t is elapsed time in seconds, shape (B, S) + high = (t >= 1 * day) & (t < 2 * day) + u = jnp.where(high, 30.0, 10.0) + v = jnp.zeros_like(u) + return u, v + + return _field + + def test_all_time_zero_misses_violation(self): + """Without travel info, all segments query t=0 → no violation.""" + # 4 points → 3 segments, each ~1 day apart at constant speed + curve = _make_curve(n_points=4) + wf = self._time_varying_windfield() + # No travel info → t=0 for all segments → TWS=10 → no penalty + pen = weather_penalty(curve, windfield=wf, penalty=1.0) + assert jnp.allclose(pen, 0.0) + + def test_with_travel_time_catches_violation(self): + """With travel_time, middle segment is at day 1 → violation.""" + # 4 equally spaced points → 3 equal segments + curve = _make_curve(n_points=4) + wf = self._time_varying_windfield() + day = 86400.0 + total_time = 3.0 * day # 3 days total trip + # Segment midpoints at t = 0.5, 1.5, 2.5 days + # Only t=1.5 days (segment 2) → TWS=30 → 1 violation + pen = weather_penalty( + curve, + windfield=wf, + penalty=1.0, + travel_time=total_time, + spherical_correction=False, + ) + assert jnp.allclose(pen, 1.0) + + def test_with_travel_stw_catches_violation(self): + """With travel_stw, middle segment triggers violation.""" + # 4 points from (0,0) to (3,0) → 3 segments of length 1° + src = jnp.array([0.0, 0.0]) + dst = jnp.array([3.0, 0.0]) + curve = jnp.linspace(src, dst, 4)[jnp.newaxis] # (1, 4, 2) + wf = self._time_varying_windfield() + day = 86400.0 + # speed such that each segment of 1° takes exactly 1 day + # segment length = 1° (unitless, no spherical correction) + stw = 1.0 / day # degrees per second + # Midpoints at t = 0.5, 1.5, 2.5 days → middle violates + pen = weather_penalty( + curve, + windfield=wf, + penalty=1.0, + travel_stw=stw, + spherical_correction=False, + ) + assert jnp.allclose(pen, 1.0) + + def test_smooth_penalty_time_variation(self): + """Smooth penalty is non-zero only from the violating segment.""" + curve = _make_curve(n_points=4) + wf = self._time_varying_windfield() + day = 86400.0 + total_time = 3.0 * day + # Without time info → all at t=0 → TWS=10 → pen=0 + pen_no_time = weather_penalty_smooth( + curve, + windfield=wf, + penalty=1.0, + sharpness=1.0, + ) + # With time info → middle segment at t=1.5d → TWS=30 → pen>0 + pen_with_time = weather_penalty_smooth( + curve, + windfield=wf, + penalty=1.0, + sharpness=1.0, + travel_time=total_time, + spherical_correction=False, + ) + assert jnp.allclose(pen_no_time, 0.0) + assert pen_with_time > 0 + # Expected: excess = 30 - 20 = 10, one of 3 segments violates + # max(excess²) = 10² = 100, × penalty=1 × sharpness=1 + assert jnp.allclose(pen_with_time, 100.0, atol=1e-3) + + def test_evaluate_weather_time_variation(self): + """evaluate_weather reports correct max TWS with time info.""" + curve = _make_curve(n_points=4) + wf = self._time_varying_windfield() + day = 86400.0 + total_time = 3.0 * day + stats_no_time = evaluate_weather(curve, windfield=wf) + stats_with_time = evaluate_weather( + curve, + windfield=wf, + travel_time=total_time, + spherical_correction=False, + ) + # Without time: all at t=0 → TWS=10 → not exceeded + assert jnp.allclose(stats_no_time.max_tws, 10.0, atol=1e-5) + assert not stats_no_time.tws_exceeded[0] + # With time: max TWS = 30 → exceeded + assert jnp.allclose(stats_with_time.max_tws, 30.0, atol=1e-5) + assert stats_with_time.tws_exceeded[0] + + +class TestEdgeCases: + """Edge cases for curve shapes.""" + + def test_single_point_evaluate(self): + """Curve with a single point (no segments) returns zeros.""" + curve = jnp.array([[[0.0, 0.0]]]) # (1, 1, 2) + wf = _constant_windfield(25.0, 0.0) + stats = evaluate_weather(curve, windfield=wf) + assert stats.max_tws.shape == (1,) + assert jnp.allclose(stats.max_tws, 0.0) + assert not stats.tws_exceeded[0] + + def test_single_point_penalty(self): + """weather_penalty returns zero for single-point curve.""" + curve = jnp.array([[[0.0, 0.0]]]) + wf = _constant_windfield(25.0, 0.0) + pen = weather_penalty(curve, windfield=wf) + assert jnp.allclose(pen, 0.0) + + def test_single_point_smooth(self): + """weather_penalty_smooth returns zero for single-point curve.""" + curve = jnp.array([[[0.0, 0.0]]]) + wf = _constant_windfield(25.0, 0.0) + pen = weather_penalty_smooth(curve, windfield=wf) + assert jnp.allclose(pen, 0.0) + + +# --------------------------------------------------------------------------- +# Tests for split penalties (wind_penalty_smooth, wave_penalty_smooth) +# --------------------------------------------------------------------------- +class TestWindPenaltySmooth: + """Test the split wind-only smooth penalty.""" + + def test_zero_within_limits(self): + curve = _make_curve() + wf = _constant_windfield(10.0, 0.0) + pen = wind_penalty_smooth(curve, windfield=wf) + assert jnp.allclose(pen, 0.0) + + def test_nonzero_when_exceeded(self): + curve = _make_curve(n_points=10) + wf = _constant_windfield(25.0, 0.0) # TWS=25 > 20 + pen = wind_penalty_smooth(curve, windfield=wf, weight=1.0) + assert pen.item() > 0.0 + + def test_weight_scaling(self): + curve = _make_curve(n_points=10) + wf = _constant_windfield(25.0, 0.0) + p1 = wind_penalty_smooth(curve, windfield=wf, weight=1.0) + p5 = wind_penalty_smooth(curve, windfield=wf, weight=5.0) + assert jnp.allclose(p5, p1 * 5.0, atol=1e-5) + + def test_batch(self): + curve = _make_curve(n_routes=3, n_points=10) + wf = _constant_windfield(25.0, 0.0) + pen = wind_penalty_smooth(curve, windfield=wf, weight=1.0) + assert pen.shape == (3,) + assert jnp.all(pen > 0) + + +class TestWavePenaltySmooth: + """Test the split wave-only smooth penalty.""" + + def test_zero_within_limits(self): + curve = _make_curve() + wvf = _constant_wavefield(3.0) + pen = wave_penalty_smooth(curve, wavefield=wvf) + assert jnp.allclose(pen, 0.0) + + def test_nonzero_when_exceeded(self): + curve = _make_curve(n_points=10) + wvf = _constant_wavefield(10.0) # Hs=10 > 7 + pen = wave_penalty_smooth(curve, wavefield=wvf, weight=1.0) + assert pen.item() > 0.0 + + def test_weight_scaling(self): + curve = _make_curve(n_points=10) + wvf = _constant_wavefield(10.0) + p1 = wave_penalty_smooth(curve, wavefield=wvf, weight=1.0) + p5 = wave_penalty_smooth(curve, wavefield=wvf, weight=5.0) + assert jnp.allclose(p5, p1 * 5.0, atol=1e-5) + + def test_batch(self): + curve = _make_curve(n_routes=3, n_points=10) + wvf = _constant_wavefield(10.0) + pen = wave_penalty_smooth(curve, wavefield=wvf, weight=1.0) + assert pen.shape == (3,) + assert jnp.all(pen > 0) + + def test_independent_from_wind(self): + """Wave penalty should not depend on wind field presence.""" + curve = _make_curve(n_points=10) + wvf = _constant_wavefield(10.0) + pen = wave_penalty_smooth(curve, wavefield=wvf, weight=1.0) + assert pen.item() > 0.0 diff --git a/uv.lock b/uv.lock index 82c36c7e..06ae2cc3 100644 --- a/uv.lock +++ b/uv.lock @@ -4,50 +4,64 @@ requires-python = "==3.12.*" resolution-markers = [ "sys_platform == 'darwin'", "platform_machine == 'aarch64' and sys_platform == 'linux'", - "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')", + "platform_machine == 'ARM64' and sys_platform == 'win32'", + "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')", ] [[package]] -name = "affine" -version = "2.4.0" +name = "aiohappyeyeballs" +version = "2.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/98/d2f0bb06385069e799fc7d2870d9e078cfa0fa396dc8a2b81227d0da08b9/affine-2.4.0.tar.gz", hash = "sha256:a24d818d6a836c131976d22f8c27b8d3ca32d0af64c1d8d29deb7bafa4da1eea", size = 17132, upload-time = "2023-01-19T23:44:30.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/f7/85273299ab57117850cc0a936c64151171fac4da49bc6fba0dad984a7c5f/affine-2.4.0-py3-none-any.whl", hash = "sha256:8a3df80e2b2378aef598a83c1392efd47967afec4242021a0b06b4c7cbc61a92", size = 15662, upload-time = "2023-01-19T23:44:28.833Z" }, + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, ] [[package]] -name = "altair" -version = "5.5.0" +name = "aiohttp" +version = "3.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jinja2" }, - { name = "jsonschema" }, - { name = "narwhals" }, - { name = "packaging" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/16/b1/f2969c7bdb8ad8bbdda031687defdce2c19afba2aa2c8e1d2a17f78376d8/altair-5.5.0.tar.gz", hash = "sha256:d960ebe6178c56de3855a68c47b516be38640b73fb3b5111c2a9ca90546dd73d", size = 705305, upload-time = "2024-11-23T23:39:58.542Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/f3/0b6ced594e51cc95d8c1fc1640d3623770d01e4969d29c0bd09945fafefa/altair-5.5.0-py3-none-any.whl", hash = "sha256:91a310b926508d560fe0148d02a194f38b824122641ef528113d029fcd129f8c", size = 731200, upload-time = "2024-11-23T23:39:56.4Z" }, -] - -[[package]] -name = "annotated-doc" -version = "0.0.4" + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, ] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] @@ -137,44 +151,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] -[[package]] -name = "basemap" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "basemap-data" }, - { name = "matplotlib" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pyproj" }, - { name = "pyshp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/e1/f097da590e050be87ba0f9073b0e1bd706a5b8bd446347af28e95effc8e5/basemap-2.0.0.tar.gz", hash = "sha256:923a643a26db6e1704fb7ca45a7636fb190dbc123ad8d23401057a8f5b884bd8", size = 150239, upload-time = "2025-06-13T14:38:23.569Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/ea/7e9ce423011f9106009111b7a6d39cde85573cb4f51c0f6ad313e6682886/basemap-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51c2be63c04e4da698b51f0773d4f9a711dd60afe842045dc9a5e8774c9f5bc4", size = 657716, upload-time = "2025-06-13T14:38:01.935Z" }, - { url = "https://files.pythonhosted.org/packages/c8/34/489aa67ace0887ef4ed96e0ab88101caa96da240ab80b9cb486e33d06b29/basemap-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba5b0b7d234b15dd20026f64886da2aafb90be3282410006d8c6c7d8b4cc0798", size = 581826, upload-time = "2025-06-13T14:38:03.227Z" }, - { url = "https://files.pythonhosted.org/packages/3a/74/71345ca9757aaf63a29606550d5c23b1dc951ea1f72f97975fed3f4225bf/basemap-2.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:688e35e2049d8a469f6d7dc0a7ce9af396e06cec299de59ba2e2788bd9a39c70", size = 1096452, upload-time = "2025-06-13T14:38:04.488Z" }, - { url = "https://files.pythonhosted.org/packages/e9/af/4503d6cce2dd492972b8b437b15819aab8529cb373252840f8f98734c8fc/basemap-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:6b07a126fe0faa128379d5dde2f8007ceb94eaea2d80dc42aebfc039ce51299c", size = 439832, upload-time = "2025-06-13T14:38:05.816Z" }, -] - -[[package]] -name = "basemap-data" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/92/e19148e23ec439eaf15b42b20449a6cf1e3aacd74ba24639d38ce1c62817/basemap_data-2.0.0.tar.gz", hash = "sha256:4cc8e682b26881337dca510df31bdaaf8af6dbceee5c073693283dd90b6c90eb", size = 30517819, upload-time = "2025-06-13T14:38:25.161Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/2a/b99e7e00092e3f2d659e6e7203985cdd2f78c9c68786a41316a1e81bdb05/basemap_data-2.0.0-py3-none-any.whl", hash = "sha256:56103a0dfa411c77bec1888b232bb70e7ceb089f83679531fbb4bb6efafa5035", size = 30534764, upload-time = "2025-06-13T14:38:16.919Z" }, -] - -[[package]] -name = "basemap-data-hires" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/b7/82f74fe5349d3e7a60a461ab32f88e926fc9d4d6b205387d317830d4a390/basemap_data_hires-2.0.0.tar.gz", hash = "sha256:c3ae28e56e2d638db1748a320ac6ff60c9f88a91de7e106dc92be48f9d4fa853", size = 91046155, upload-time = "2025-06-13T14:38:28.718Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/b0/b33ae36283180fea2baaeb19ef963685c0b01bb48a0a2350f7b72b0a6a36/basemap_data_hires-2.0.0-py3-none-any.whl", hash = "sha256:d38c431e9ab348bfda72bc8cfef6b45814c1e5cec69f4aff0754ebb10fedcf43", size = 91059161, upload-time = "2025-06-13T14:38:19.99Z" }, -] - [[package]] name = "beautifulsoup4" version = "4.14.2" @@ -188,27 +164,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, ] -[[package]] -name = "black" -version = "25.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "pytokens" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/12/5c35e600b515f35ffd737da7febdb2ab66bb8c24d88560d5e3ef3d28c3fd/black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac", size = 1772831, upload-time = "2025-11-10T02:03:47Z" }, - { url = "https://files.pythonhosted.org/packages/1a/75/b3896bec5a2bb9ed2f989a970ea40e7062f8936f95425879bbe162746fe5/black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96", size = 1608520, upload-time = "2025-11-10T01:58:46.895Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b5/2bfc18330eddbcfb5aab8d2d720663cd410f51b2ed01375f5be3751595b0/black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd", size = 1682719, upload-time = "2025-11-10T01:56:55.24Z" }, - { url = "https://files.pythonhosted.org/packages/96/fb/f7dc2793a22cdf74a72114b5ed77fe3349a2e09ef34565857a2f917abdf2/black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409", size = 1362684, upload-time = "2025-11-10T01:57:07.639Z" }, - { url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" }, -] - [[package]] name = "bleach" version = "6.3.0" @@ -226,15 +181,6 @@ css = [ { name = "tinycss2" }, ] -[[package]] -name = "blinker" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, -] - [[package]] name = "borb" version = "3.0.1" @@ -248,92 +194,46 @@ sdist = { hash = "sha256:610a20720b5e2d0749d2867d1b18d82c69e9b140ac28552477a7dc3 requires-dist = [{ name = "setuptools", specifier = ">=51.1.1" }] [[package]] -name = "boto3" -version = "1.40.73" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore" }, - { name = "jmespath" }, - { name = "s3transfer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d0/d1/3be32bf855200fb2defb4ab2b251aba4d05e787235adbf3ae1ba2099545b/boto3-1.40.73.tar.gz", hash = "sha256:3716703cb8b126607533853d7e2a85f0bb23b0b9d4805c69170abead33d725ef", size = 111607, upload-time = "2025-11-13T20:27:24.969Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b7/350cf5d2ed824c75a596499e6a77c5ec6d35e9437b8c10a936900461c6e3/boto3-1.40.73-py3-none-any.whl", hash = "sha256:85172e11e3b8d5a09504bc532b6589730ac68845410403ca3793d037b8a5d445", size = 139360, upload-time = "2025-11-13T20:27:22.689Z" }, -] - -[[package]] -name = "botocore" -version = "1.40.73" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jmespath" }, - { name = "python-dateutil" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/51/83/4afe8a1fdd4b5200ceff986b1e72be16c55010980bf337360535733d85c3/botocore-1.40.73.tar.gz", hash = "sha256:0650ceada268824282da9af8615f3e4cf2453be8bf85b820f9207eff958d56d0", size = 14452167, upload-time = "2025-11-13T20:27:13.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/9c/f5085b99a4e0ec8454ab2d1b9492f7e10c0d008578cb26a856d7a8240b40/botocore-1.40.73-py3-none-any.whl", hash = "sha256:87524c5fe552ecceaea72f51163b37ab35eb82aaa6a64eb80489ade7340c1d23", size = 14118004, upload-time = "2025-11-13T20:27:09.245Z" }, -] - -[[package]] -name = "bottleneck" -version = "1.6.0" +name = "cartopy" +version = "0.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "matplotlib" }, { name = "numpy" }, + { name = "packaging" }, + { name = "pyproj" }, + { name = "pyshp" }, + { name = "shapely" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/d8/6d641573e210768816023a64966d66463f2ce9fc9945fa03290c8a18f87c/bottleneck-1.6.0.tar.gz", hash = "sha256:028d46ee4b025ad9ab4d79924113816f825f62b17b87c9e1d0d8ce144a4a0e31", size = 104311, upload-time = "2025-09-08T16:30:38.617Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/72/7e3593a2a3dd69ec831a9981a7b1443647acb66a5aec34c1620a5f7f8498/bottleneck-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3bb16a16a86a655fdbb34df672109a8a227bb5f9c9cf5bb8ae400a639bc52fa3", size = 100515, upload-time = "2025-09-08T16:29:55.141Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d4/e7bbea08f4c0f0bab819d38c1a613da5f194fba7b19aae3e2b3a27e78886/bottleneck-1.6.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0fbf5d0787af9aee6cef4db9cdd14975ce24bd02e0cc30155a51411ebe2ff35f", size = 377451, upload-time = "2025-09-08T16:29:56.718Z" }, - { url = "https://files.pythonhosted.org/packages/fe/80/a6da430e3b1a12fd85f9fe90d3ad8fe9a527ecb046644c37b4b3f4baacfc/bottleneck-1.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d08966f4a22384862258940346a72087a6f7cebb19038fbf3a3f6690ee7fd39f", size = 368303, upload-time = "2025-09-08T16:29:57.834Z" }, - { url = "https://files.pythonhosted.org/packages/30/11/abd30a49f3251f4538430e5f876df96f2b39dabf49e05c5836820d2c31fe/bottleneck-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:604f0b898b43b7bc631c564630e936a8759d2d952641c8b02f71e31dbcd9deaa", size = 361232, upload-time = "2025-09-08T16:29:59.104Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ac/1c0e09d8d92b9951f675bd42463ce76c3c3657b31c5bf53ca1f6dd9eccff/bottleneck-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d33720bad761e642abc18eda5f188ff2841191c9f63f9d0c052245decc0faeb9", size = 373234, upload-time = "2025-09-08T16:30:00.488Z" }, - { url = "https://files.pythonhosted.org/packages/fb/ea/382c572ae3057ba885d484726bb63629d1f63abedf91c6cd23974eb35a9b/bottleneck-1.6.0-cp312-cp312-win32.whl", hash = "sha256:a1e5907ec2714efbe7075d9207b58c22ab6984a59102e4ecd78dced80dab8374", size = 108020, upload-time = "2025-09-08T16:30:01.773Z" }, - { url = "https://files.pythonhosted.org/packages/48/ad/d71da675eef85ac153eef5111ca0caa924548c9591da00939bcabba8de8e/bottleneck-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:81e3822499f057a917b7d3972ebc631ac63c6bbcc79ad3542a66c4c40634e3a6", size = 113493, upload-time = "2025-09-08T16:30:02.872Z" }, -] - -[[package]] -name = "branca" -version = "0.8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jinja2" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/32/14/9d409124bda3f4ab7af3802aba07181d1fd56aa96cc4b999faea6a27a0d2/branca-0.8.2.tar.gz", hash = "sha256:e5040f4c286e973658c27de9225c1a5a7356dd0702a7c8d84c0f0dfbde388fe7", size = 27890, upload-time = "2025-10-06T10:28:20.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/50/fc9680058e63161f2f63165b84c957a0df1415431104c408e8104a3a18ef/branca-0.8.2-py3-none-any.whl", hash = "sha256:2ebaef3983e3312733c1ae2b793b0a8ba3e1c4edeb7598e10328505280cf2f7c", size = 26193, upload-time = "2025-10-06T10:28:19.255Z" }, -] - -[[package]] -name = "cachetools" -version = "6.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/ec3dee34237b696a486d566a6d3ae6550ae821836e0412bafdcbbec2cfd2/cartopy-0.25.0.tar.gz", hash = "sha256:55f1a390e5f3f075b221c7d91fb10258ad978db786c7930eba06eb45d28753fe", size = 10767728, upload-time = "2025-08-01T12:44:16.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" }, + { url = "https://files.pythonhosted.org/packages/63/35/b19901cbe7f1b118dccbb9e655cda7d01a31ee1ecd67e5d2d8afe119f6d3/cartopy-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:060a7b835c0c4222c1067b6ffb2f9c18458abaa35b6624573a3aa37ecf55f4bf", size = 11006900, upload-time = "2025-08-01T12:43:57.708Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4f/09e824f86be09152ec0f1fa1fe69affbd34eac7a13b545e2e08b9b6bc8ff/cartopy-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:57717cb603aecff03ecfee1bc153bb4022c054fcd51a4214a1bb53e5a6f74465", size = 10994813, upload-time = "2025-08-01T12:44:00.069Z" }, + { url = "https://files.pythonhosted.org/packages/b9/30/7465b650110514fc5c9c3b59935264c35ab56f876322de34efa55367ee4e/cartopy-0.25.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c256351433155ef51dde976557212f4e230b8cca4e5d0d9b9a2737ad92959d", size = 11799069, upload-time = "2025-08-01T12:44:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/1d/52/3a57ecb4598c33ee06b512d3686e46b3983e65abd6ec94c5262d01930ed9/cartopy-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:efedb82f38409b72becdfee02231126952816d33a68b1c584bd2136713036bfb", size = 10983127, upload-time = "2025-08-01T12:44:04.441Z" }, ] [[package]] -name = "cattrs" -version = "25.3.0" +name = "cdsapi" +version = "0.7.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "typing-extensions" }, + { name = "ecmwf-datastores-client" }, + { name = "requests" }, + { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/00/2432bb2d445b39b5407f0a90e01b9a271475eea7caf913d7a86bcb956385/cattrs-25.3.0.tar.gz", hash = "sha256:1ac88d9e5eda10436c4517e390a4142d88638fe682c436c93db7ce4a277b884a", size = 509321, upload-time = "2025-10-07T12:26:08.737Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/f3/6cb5b4bf077c441978c5d5be3a568d37e1f07f3e7177a17fa66aec2594b6/cdsapi-0.7.7.tar.gz", hash = "sha256:bc0cf807c1b78aceba6a11c3a5180f885f47f71a4e58205e324cfedcee16f10b", size = 13322, upload-time = "2025-09-30T19:11:22.404Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/2b/a40e1488fdfa02d3f9a653a61a5935ea08b3c2225ee818db6a76c7ba9695/cattrs-25.3.0-py3-none-any.whl", hash = "sha256:9896e84e0a5bf723bc7b4b68f4481785367ce07a8a02e7e9ee6eb2819bc306ff", size = 70738, upload-time = "2025-10-07T12:26:06.603Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f4/4a65460d5cb6784128019fd707a87993f378db25e796eba01400a0903f62/cdsapi-0.7.7-py2.py3-none-any.whl", hash = "sha256:384c1658572d6dc53f4111f6dd46fcdfe6fea54a688af9756d71f6fe9118b66d", size = 12293, upload-time = "2025-09-30T19:11:21.184Z" }, ] [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -359,21 +259,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, ] -[[package]] -name = "cfgrib" -version = "0.9.15.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "click" }, - { name = "eccodes" }, - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4a/51/cace2747a517667bbbe5fcab1f35958ad05c778251a452c461b8b3649dbe/cfgrib-0.9.15.1.tar.gz", hash = "sha256:d959d8b97e55a63646fa86686b297905ff7f2918a91e3a11d6292dab09598e4d", size = 9746591, upload-time = "2025-09-30T22:46:14.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/e8/16c58c57c9ce1474dd1e50090ebd78b008c70fc4f06793da65f9a0aba391/cfgrib-0.9.15.1-py3-none-any.whl", hash = "sha256:f1bee90e86917389be9f767051bf32d00f95f6f4e4312b344567511b3cfd62d2", size = 49123, upload-time = "2025-09-30T22:46:12.206Z" }, -] - [[package]] name = "cfgv" version = "3.4.0" @@ -398,15 +283,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/fd/a7266970312df65e68b5641b86e0540a739182f5e9c62eec6dbd29f18055/cftime-1.6.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85ba8e7356d239cfe56ef7707ac30feaf67964642ac760a82e507ee3c5db4ac4", size = 1642614, upload-time = "2025-10-13T18:56:09.815Z" }, { url = "https://files.pythonhosted.org/packages/c4/73/f0035a4bc2df8885bb7bd5fe63659686ea1ec7d0cc74b4e3d50e447402e5/cftime-1.6.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:456039af7907a3146689bb80bfd8edabd074c7f3b4eca61f91b9c2670addd7ad", size = 1688090, upload-time = "2025-10-13T18:56:11.442Z" }, { url = "https://files.pythonhosted.org/packages/88/15/8856a0ab76708553ff597dd2e617b088c734ba87dc3fd395e2b2f3efffe8/cftime-1.6.5-cp312-cp312-win_amd64.whl", hash = "sha256:da84534c43699960dc980a9a765c33433c5de1a719a4916748c2d0e97a071e44", size = 464840, upload-time = "2025-10-13T18:56:12.506Z" }, -] - -[[package]] -name = "chardet" -version = "5.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, + { url = "https://files.pythonhosted.org/packages/3a/85/451009a986d9273d2208fc0898aa00262275b5773259bf3f942f6716a9e7/cftime-1.6.5-cp312-cp312-win_arm64.whl", hash = "sha256:c62cd8db9ea40131eea7d4523691c5d806d3265d31279e4a58574a42c28acd77", size = 450534, upload-time = "2026-01-02T21:16:48.784Z" }, ] [[package]] @@ -446,30 +323,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, ] -[[package]] -name = "click-plugins" -version = "1.1.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, -] - -[[package]] -name = "cligj" -version = "0.7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ea/0d/837dbd5d8430fd0f01ed72c4cfb2f548180f4c68c635df84ce87956cff32/cligj-0.7.2.tar.gz", hash = "sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27", size = 9803, upload-time = "2021-05-28T21:23:27.935Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/86/43fa9f15c5b9fb6e82620428827cd3c284aa933431405d1bcf5231ae3d3e/cligj-0.7.2-py3-none-any.whl", hash = "sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df", size = 7069, upload-time = "2021-05-28T21:23:26.877Z" }, -] - [[package]] name = "cloudpickle" version = "3.1.2" @@ -531,6 +384,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -542,7 +434,7 @@ wheels = [ [[package]] name = "dask" -version = "2025.11.0" +version = "2026.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -553,9 +445,9 @@ dependencies = [ { name = "pyyaml" }, { name = "toolz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/db/33/eacaa72731f7fc64868caaf2d35060d50049eff889bd217263e68f76472f/dask-2025.11.0.tar.gz", hash = "sha256:23d59e624b80ee05b7cc8df858682cca58262c4c3b197ccf61da0f6543c8f7c3", size = 10984781, upload-time = "2025-11-06T16:56:51.535Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/52/b0f9172b22778def907db1ff173249e4eb41f054b46a9c83b1528aaf811f/dask-2026.1.2.tar.gz", hash = "sha256:1136683de2750d98ea792670f7434e6c1cfce90cab2cc2f2495a9e60fd25a4fc", size = 10997838, upload-time = "2026-01-30T21:04:20.54Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/54/a46920229d12c3a6e9f0081d1bdaeffad23c1826353ace95714faee926e5/dask-2025.11.0-py3-none-any.whl", hash = "sha256:08c35a8146c05c93b34f83cf651009129c42ee71762da7ca452fb7308641c2b8", size = 1477108, upload-time = "2025-11-06T16:56:44.892Z" }, + { url = "https://files.pythonhosted.org/packages/e5/23/d39ccc4ed76222db31530b0a7d38876fdb7673e23f838e8d8f0ed4651a4f/dask-2026.1.2-py3-none-any.whl", hash = "sha256:46a0cf3b8d87f78a3d2e6b145aea4418a6d6d606fe6a16c79bd8ca2bb862bc91", size = 1482084, upload-time = "2026-01-30T21:04:18.363Z" }, ] [[package]] @@ -599,270 +491,331 @@ wheels = [ ] [[package]] -name = "eccodes" -version = "2.44.0" +name = "donfig" +version = "0.8.1.post1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "cffi" }, - { name = "eccodeslib", marker = "sys_platform != 'win32'" }, - { name = "findlibs" }, - { name = "numpy" }, + { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/2a/9242d0a83de707ed401906a34bfe1d9a3af616abf498580ef73a6e8cebd5/eccodes-2.44.0.tar.gz", hash = "sha256:8aba9316749349e64db7d075100bff8e24a892814e3529132ec97b6d787eb8f4", size = 2310714, upload-time = "2025-10-03T14:02:37.462Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/71/80cc718ff6d7abfbabacb1f57aaa42e9c1552bfdd01e64ddd704e4a03638/donfig-0.8.1.post1.tar.gz", hash = "sha256:3bef3413a4c1c601b585e8d297256d0c1470ea012afa6e8461dc28bfb7c23f52", size = 19506, upload-time = "2024-05-23T14:14:31.513Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/a8/4d3b00f09440b269da208831b450a77e150ecfd1ac3981ca83d984ede4bd/eccodes-2.44.0-cp312-cp312-win_amd64.whl", hash = "sha256:20864247343bf88df88eafbf811fa90c290c45ed32d24f046238bd0f1684e16e", size = 7247248, upload-time = "2025-10-03T14:02:05.837Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b8/9d15cea1f63fb2e1e14fda4160c355e6187e69b71b848c05faaae08b2e6c/eccodes-2.44.0-py3-none-any.whl", hash = "sha256:c3f11041bde7c3f53767c5bbed608c43695f257c09c58bb4de24bcd9cdae4e3a", size = 83465, upload-time = "2025-10-03T14:02:36.181Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl", hash = "sha256:2a3175ce74a06109ff9307d90a230f81215cbac9a751f4d1c6194644b8204f9d", size = 21592, upload-time = "2024-05-23T14:13:55.283Z" }, ] [[package]] -name = "eccodeslib" -version = "2.44.0.5" +name = "ecmwf-datastores-client" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "eckitlib" }, - { name = "fckitlib" }, + { name = "attrs" }, + { name = "multiurl" }, + { name = "requests" }, + { name = "typing-extensions" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/51/60/f86eb3e57baf2b1780a7046148c234e9e57b0aeb550d30f39e50991da253/ecmwf_datastores_client-0.4.2.tar.gz", hash = "sha256:7cee1f5e5dab34edcc794cd62bee02c603fafb6f4cc2121c5f012806e0f7934d", size = 48205, upload-time = "2026-01-21T15:27:31.665Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/68/97b7e0cde5368f38335a1e99188726d383084b3014447cccc535cc7f9eac/eccodeslib-2.44.0.5-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:6eed3e0ab2f7172972d9fdee811a4816c8654cefed4b9a9f2dd5d72b019bc26f", size = 8926798, upload-time = "2025-10-29T12:58:14.911Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b2/0109b572f5fc257c0d8f070fc4fdc8e7d1b1fc3d54cfeeb4cea9a5d95f3f/eccodeslib-2.44.0.5-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:3f14740840785a552edfc367fafbb80255d56a4f561185a8f4413f0cfd638eb8", size = 8725655, upload-time = "2025-10-29T13:05:42.544Z" }, - { url = "https://files.pythonhosted.org/packages/7d/fc/3f8815eaff975c1df1453f38310943de80ca029a0eff63cf9708db1caa0d/eccodeslib-2.44.0.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:479b625fa4cb2e78dcbafdde127166fad2df9662d3278b68f8186b60ff91b5fb", size = 20853435, upload-time = "2025-10-29T13:15:28.335Z" }, + { url = "https://files.pythonhosted.org/packages/04/40/2ccf4c87a5f9c8198fe71600d5f307f5dada201c091af8774a9c1e360865/ecmwf_datastores_client-0.4.2-py3-none-any.whl", hash = "sha256:d22a675b35263286de09969502ec897da9ceb9e4c8ec4d709f7ebb3b90d3ae98", size = 29092, upload-time = "2026-01-21T15:27:30.452Z" }, ] [[package]] -name = "eckitlib" -version = "1.32.2.5" +name = "executing" +version = "2.2.1" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/9d/2fb84682c4e412df5f4477427839b28d8c78d5b41835b12fa992598d87a3/eckitlib-1.32.2.5-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:e5cb195558e566239831d6e39b3693aa27c656f8e9316be8ed6ef329eadd1d92", size = 2925743, upload-time = "2025-10-29T12:58:20.448Z" }, - { url = "https://files.pythonhosted.org/packages/3c/e7/b14c4194f61d9b27c6b9e26d6644cb247c27a0b6e3acc99bf14bafcf6c61/eckitlib-1.32.2.5-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:cc535fa2bde152c180300ad629530493f101d4638dac936522c5fbcfeab46e29", size = 3028911, upload-time = "2025-10-29T13:05:51.026Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d2/1a985b441539487e0b1698bac967c088e2d3d4029f534f75fa63271ab9e9/eckitlib-1.32.2.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ba1390b0df9b47bb7631ba03ecc44688a245b9781d0005c1ca9e3020ef9598", size = 44583600, upload-time = "2025-10-29T13:15:41.438Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] [[package]] -name = "ecmwf-opendata" -version = "0.3.24" +name = "fastjsonschema" +version = "2.21.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "multiurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/2f/db2a9dac41bd01526ce2818001fd35b368ba1c39680f3de0ec872437e241/ecmwf_opendata-0.3.24.tar.gz", hash = "sha256:41574de8845f3856e9965f7b285bd7195ff2fcec1a9c3de0bed437629426f8b4", size = 27914, upload-time = "2025-10-28T14:59:33.129Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/5d/47913157ce42954059806c27111ccfa7b9b1747b9254acb8979c25424ce1/ecmwf_opendata-0.3.24-py3-none-any.whl", hash = "sha256:5d8a0a9901780495cbf4896d193476f5af641b225d4a71ae66ee13cb5b11df01", size = 21241, upload-time = "2025-10-28T14:59:31.996Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, ] [[package]] -name = "executing" -version = "2.2.1" +name = "filelock" +version = "3.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, ] [[package]] -name = "fastapi" -version = "0.121.2" +name = "fonttools" +version = "4.60.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fb/48/f08f264da34cf160db82c62ffb335e838b1fc16cbcc905f474c7d4c815db/fastapi-0.121.2.tar.gz", hash = "sha256:ca8e932b2b823ec1721c641e3669472c855ad9564a2854c9899d904c2848b8b9", size = 342944, upload-time = "2025-11-13T17:05:54.692Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/42/97a13e47a1e51a5a7142475bbcf5107fe3a68fc34aef331c897d5fb98ad0/fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9", size = 3559823, upload-time = "2025-09-29T21:13:27.129Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/23/dfb161e91db7c92727db505dc72a384ee79681fe0603f706f9f9f52c2901/fastapi-0.121.2-py3-none-any.whl", hash = "sha256:f2d80b49a86a846b70cc3a03eb5ea6ad2939298bf6a7fe377aa9cd3dd079d358", size = 109201, upload-time = "2025-11-13T17:05:52.718Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f7/a10b101b7a6f8836a5adb47f2791f2075d044a6ca123f35985c42edc82d8/fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc", size = 2832953, upload-time = "2025-09-29T21:11:39.616Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/7bd094b59c926acf2304d2151354ddbeb74b94812f3dc943c231db09cb41/fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877", size = 2352706, upload-time = "2025-09-29T21:11:41.826Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ca/4bb48a26ed95a1e7eba175535fe5805887682140ee0a0d10a88e1de84208/fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c", size = 4923716, upload-time = "2025-09-29T21:11:43.893Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9f/2cb82999f686c1d1ddf06f6ae1a9117a880adbec113611cc9d22b2fdd465/fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401", size = 4968175, upload-time = "2025-09-29T21:11:46.439Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/be569699e37d166b78e6218f2cde8c550204f2505038cdd83b42edc469b9/fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903", size = 4911031, upload-time = "2025-09-29T21:11:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9f/89411cc116effaec5260ad519162f64f9c150e5522a27cbb05eb62d0c05b/fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed", size = 5062966, upload-time = "2025-09-29T21:11:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/62/a1/f888221934b5731d46cb9991c7a71f30cb1f97c0ef5fcf37f8da8fce6c8e/fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6", size = 2218750, upload-time = "2025-09-29T21:11:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/88/8f/a55b5550cd33cd1028601df41acd057d4be20efa5c958f417b0c0613924d/fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383", size = 2267026, upload-time = "2025-09-29T21:11:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" }, ] [[package]] -name = "fastjsonschema" -version = "2.21.2" +name = "fqdn" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, ] [[package]] -name = "fckitlib" -version = "0.14.0.5" +name = "frozenlist" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "eckitlib" }, -] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/02/fc8e1eb63057c862ed0c7fb65edd2f0a87794fb8b16a72d514f6e9e22b5c/fckitlib-0.14.0.5-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:5c4d0313a1bb86c49ed50e6c658dccdc9200ed6e7524ed5515f76171c347b9ee", size = 411476, upload-time = "2025-10-29T12:58:23.247Z" }, - { url = "https://files.pythonhosted.org/packages/f7/3d/0200e76f08ab79fcd6c8379140c558c701fd6c77c81ed86aee10bd0df240/fckitlib-0.14.0.5-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:ec5113cd9c9d18f1682a380d46129d7107eaea3247cc2c0845e144a34356f635", size = 417160, upload-time = "2025-10-29T13:05:54.339Z" }, - { url = "https://files.pythonhosted.org/packages/f1/30/6b0727a63f4f66970d4a383c6a244804bb54369f53c37c53453d7b8d9829/fckitlib-0.14.0.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:31fb81005dd07331bd4ac3f55128b3788f35afe3a04afa63443e878f9cdc9023", size = 13392933, upload-time = "2025-10-29T13:15:48.494Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] -name = "filelock" -version = "3.20.0" +name = "fsspec" +version = "2026.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] [[package]] -name = "findlibs" -version = "0.1.2" +name = "gcsfs" +version = "2026.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/ef/345b0f88b8e9d9e12051142a9cdcf590bf70206d20d81c3f773ade8d9e32/findlibs-0.1.2.tar.gz", hash = "sha256:1f56d220c69686392ebdc4c65b32ee344818bca633643a8c97592957d1728122", size = 11302, upload-time = "2025-07-28T09:15:03.675Z" } +dependencies = [ + { name = "aiohttp" }, + { name = "decorator" }, + { name = "fsspec" }, + { name = "google-auth" }, + { name = "google-auth-oauthlib" }, + { name = "google-cloud-storage" }, + { name = "google-cloud-storage-control" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/91/e7a2f237d51436a4fc947f30f039d2c277bb4f4ce02f86628ba0a094a3ce/gcsfs-2026.2.0.tar.gz", hash = "sha256:d58a885d9e9c6227742b86da419c7a458e1f33c1de016e826ea2909f6338ed84", size = 163376, upload-time = "2026-02-06T18:35:52.217Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/ff/76dd547e129206899e4e26446c3ca7aeaff948c31b05250e9b8690e76883/findlibs-0.1.2-py3-none-any.whl", hash = "sha256:5348bbc7055d2a505962576c2e285b6c0aae6d749f82ba71296e7d41336e66e8", size = 10707, upload-time = "2025-07-28T09:15:02.733Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/c2f68ac51229fc94f094c7f802648fc1de3d19af36434def5e64c0caa32b/gcsfs-2026.2.0-py3-none-any.whl", hash = "sha256:407feaa2af0de81ebce44ea7e6f68598a3753e5e42257b61d6a9f8c0d6d4754e", size = 57557, upload-time = "2026-02-06T18:35:51.09Z" }, ] [[package]] -name = "folium" -version = "0.20.0" +name = "google-api-core" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "branca" }, - { name = "jinja2" }, - { name = "numpy" }, + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, { name = "requests" }, - { name = "xyzservices" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/76/84a1b1b00ce71f9c0c44af7d80f310c02e2e583591fe7d4cb03baecd0d3f/folium-0.20.0.tar.gz", hash = "sha256:a0d78b9d5a36ba7589ca9aedbd433e84e9fcab79cd6ac213adbcff922e454cb9", size = 109932, upload-time = "2025-06-16T20:22:51.803Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/a8/5f764f333204db0390362a4356d03a43626997f26818a0e9396f1b3bd8c9/folium-0.20.0-py2.py3-none-any.whl", hash = "sha256:f0bc2a92acde20bca56367aa5c1c376c433f450608d058daebab2fc9bf8198bf", size = 113394, upload-time = "2025-06-16T20:22:50.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, ] [[package]] -name = "fonttools" -version = "4.60.1" +name = "google-auth" +version = "2.48.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4b/42/97a13e47a1e51a5a7142475bbcf5107fe3a68fc34aef331c897d5fb98ad0/fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9", size = 3559823, upload-time = "2025-09-29T21:13:27.129Z" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/f7/a10b101b7a6f8836a5adb47f2791f2075d044a6ca123f35985c42edc82d8/fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc", size = 2832953, upload-time = "2025-09-29T21:11:39.616Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/7bd094b59c926acf2304d2151354ddbeb74b94812f3dc943c231db09cb41/fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877", size = 2352706, upload-time = "2025-09-29T21:11:41.826Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ca/4bb48a26ed95a1e7eba175535fe5805887682140ee0a0d10a88e1de84208/fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c", size = 4923716, upload-time = "2025-09-29T21:11:43.893Z" }, - { url = "https://files.pythonhosted.org/packages/b8/9f/2cb82999f686c1d1ddf06f6ae1a9117a880adbec113611cc9d22b2fdd465/fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401", size = 4968175, upload-time = "2025-09-29T21:11:46.439Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/be569699e37d166b78e6218f2cde8c550204f2505038cdd83b42edc469b9/fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903", size = 4911031, upload-time = "2025-09-29T21:11:48.977Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9f/89411cc116effaec5260ad519162f64f9c150e5522a27cbb05eb62d0c05b/fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed", size = 5062966, upload-time = "2025-09-29T21:11:54.344Z" }, - { url = "https://files.pythonhosted.org/packages/62/a1/f888221934b5731d46cb9991c7a71f30cb1f97c0ef5fcf37f8da8fce6c8e/fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6", size = 2218750, upload-time = "2025-09-29T21:11:56.601Z" }, - { url = "https://files.pythonhosted.org/packages/88/8f/a55b5550cd33cd1028601df41acd057d4be20efa5c958f417b0c0613924d/fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383", size = 2267026, upload-time = "2025-09-29T21:11:58.852Z" }, - { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, ] [[package]] -name = "fqdn" -version = "1.5.1" +name = "google-auth-oauthlib" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +dependencies = [ + { name = "google-auth" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/b4/1b19567e4c567b796f5c593d89895f3cfae5a38e04f27c6af87618fd0942/google_auth_oauthlib-1.3.0.tar.gz", hash = "sha256:cd39e807ac7229d6b8b9c1e297321d36fcc8a9e4857dff4301870985df51a528", size = 21777, upload-time = "2026-02-27T14:13:01.489Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, + { url = "https://files.pythonhosted.org/packages/2f/56/909fd5632226d3fba31d7aeffd4754410735d49362f5809956fe3e9af344/google_auth_oauthlib-1.3.0-py3-none-any.whl", hash = "sha256:386b3fb85cf4a5b819c6ad23e3128d975216b4cac76324de1d90b128aaf38f29", size = 19308, upload-time = "2026-02-27T14:12:47.865Z" }, ] [[package]] -name = "fsspec" -version = "2025.10.0" +name = "google-cloud-core" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/7f/2747c0d332b9acfa75dc84447a066fdf812b5a6b8d30472b74d309bfe8cb/fsspec-2025.10.0.tar.gz", hash = "sha256:b6789427626f068f9a83ca4e8a3cc050850b6c0f71f99ddb4f542b8266a26a59", size = 309285, upload-time = "2025-10-30T14:58:44.036Z" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/03/ef0bc99d0e0faf4fdbe67ac445e18cdaa74824fd93cd069e7bb6548cb52d/google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963", size = 36027, upload-time = "2025-10-29T23:17:39.513Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl", hash = "sha256:7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d", size = 200966, upload-time = "2025-10-30T14:58:42.53Z" }, + { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469, upload-time = "2025-10-29T23:17:38.548Z" }, ] [[package]] -name = "geojson" -version = "3.2.0" +name = "google-cloud-storage" +version = "3.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/5a/33e761df75c732fcea94aaf01f993d823138581d10c91133da58bc231e63/geojson-3.2.0.tar.gz", hash = "sha256:b860baba1e8c6f71f8f5f6e3949a694daccf40820fa8f138b3f712bd85804903", size = 24574, upload-time = "2024-12-21T19:35:29.835Z" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-crc32c" }, + { name = "google-resumable-media" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/b1/4f0798e88285b50dfc60ed3a7de071def538b358db2da468c2e0deecbb40/google_cloud_storage-3.9.0.tar.gz", hash = "sha256:f2d8ca7db2f652be757e92573b2196e10fbc09649b5c016f8b422ad593c641cc", size = 17298544, upload-time = "2026-02-02T13:36:34.119Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/a7fa2d650602731c90e0a86279841b4586e14228199e8c09165ba4863e29/geojson-3.2.0-py3-none-any.whl", hash = "sha256:69d14156469e13c79479672eafae7b37e2dcd19bdfd77b53f74fa8fe29910b52", size = 15040, upload-time = "2024-12-21T19:37:02.149Z" }, + { url = "https://files.pythonhosted.org/packages/46/0b/816a6ae3c9fd096937d2e5f9670558908811d57d59ddf69dd4b83b326fd1/google_cloud_storage-3.9.0-py3-none-any.whl", hash = "sha256:2dce75a9e8b3387078cbbdad44757d410ecdb916101f8ba308abf202b6968066", size = 321324, upload-time = "2026-02-02T13:36:32.271Z" }, ] [[package]] -name = "geopandas" -version = "1.1.1" +name = "google-cloud-storage-control" +version = "1.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "pyogrio" }, - { name = "pyproj" }, - { name = "shapely" }, + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/76/e1960ba846f153ab109575242abf89dc98f8e057faa32f3decf4cce9247a/geopandas-1.1.1.tar.gz", hash = "sha256:1745713f64d095c43e72e08e753dbd271678254b24f2e01db8cdb8debe1d293d", size = 332655, upload-time = "2025-06-26T21:04:56.57Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/c0/12dfbf7c5e86e34da4af971bb043f11cdc9be8d204eb06ac8a1f9b1d5c74/google_cloud_storage_control-1.10.0.tar.gz", hash = "sha256:2bcbfa4ca6530d25a5baa8dbe80caf0eeabe4c6804798f4f107279719c316bdb", size = 116845, upload-time = "2026-02-12T14:50:07.096Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/70/d5cd0696eff08e62fdbdebe5b46527facb4e7220eabe0ac6225efab50168/geopandas-1.1.1-py3-none-any.whl", hash = "sha256:589e61aaf39b19828843df16cb90234e72897e2579be236f10eee0d052ad98e8", size = 338365, upload-time = "2025-06-26T21:04:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/8e/04/96a674d4ee90eed4e99c0f4faec21c9bbe1a470d37a4757508e90e31f5b9/google_cloud_storage_control-1.10.0-py3-none-any.whl", hash = "sha256:81d9dc6b50106836733adca868501f879f0d7a1c41503d887a1a1b9b9ddbf508", size = 89257, upload-time = "2026-02-12T14:50:01.966Z" }, ] [[package]] -name = "gitdb" -version = "4.0.12" +name = "google-crc32c" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "smmap" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, ] [[package]] -name = "gitpython" -version = "3.1.45" +name = "google-resumable-media" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "gitdb" }, + { name = "google-crc32c" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/d7/520b62a35b23038ff005e334dba3ffc75fcf583bee26723f1fd8fd4b6919/google_resumable_media-2.8.0.tar.gz", hash = "sha256:f1157ed8b46994d60a1bc432544db62352043113684d4e030ee02e77ebe9a1ae", size = 2163265, upload-time = "2025-11-17T15:38:06.659Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0b/93afde9cfe012260e9fe1522f35c9b72d6ee222f316586b1f23ecf44d518/google_resumable_media-2.8.0-py3-none-any.whl", hash = "sha256:dd14a116af303845a8d932ddae161a26e86cc229645bc98b39f026f9b1717582", size = 81340, upload-time = "2025-11-17T15:38:05.594Z" }, ] [[package]] -name = "h11" -version = "0.16.0" +name = "googleapis-common-protos" +version = "1.72.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, ] [[package]] -name = "h3" -version = "4.0.0b2" +name = "grpc-google-iam-v1" +version = "0.14.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/9f/c3bf7ef02d59cb81db8ac2860292ddbfafe34d299d4f5b45ca58899f9ee3/h3-4.0.0b2.tar.gz", hash = "sha256:be147d4662db70c95742a7b283d2bcf9a46313f29253950a6072cce795908871", size = 125488, upload-time = "2022-11-24T01:16:06.779Z" } +dependencies = [ + { name = "googleapis-common-protos", extra = ["grpc"] }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/1e/1011451679a983f2f5c6771a1682542ecb027776762ad031fd0d7129164b/grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389", size = 23745, upload-time = "2025-10-15T21:14:53.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6", size = 32690, upload-time = "2025-10-15T21:14:51.72Z" }, +] [[package]] -name = "h5netcdf" -version = "1.7.3" +name = "grpcio" +version = "1.78.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h5py" }, - { name = "packaging" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/45/03d9869c27ec515b80f82c0096ac1786c94e0c34f99a13419f2fca974b2f/h5netcdf-1.7.3.tar.gz", hash = "sha256:f62a0e77d1e2a6cd8b9d8120d5b62b6a015dc7c6185768a01e983c77c0b794e3", size = 71334, upload-time = "2025-10-21T14:01:33.323Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/49/1f35189c1ca136b2f041b72402f2eb718bdcb435d9e88729fe6f6909c45d/h5netcdf-1.7.3-py3-none-any.whl", hash = "sha256:b1967678127d55009edd4c7e36cb322a7b66bdade37a2e229d857f5ecf375c01", size = 56355, upload-time = "2025-10-21T14:01:32.283Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, + { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, + { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, + { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, ] [[package]] -name = "h5py" -version = "3.15.1" +name = "grpcio-status" +version = "1.78.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/6a/0d79de0b025aa85dc8864de8e97659c94cf3d23148394a954dc5ca52f8c8/h5py-3.15.1.tar.gz", hash = "sha256:c86e3ed45c4473564de55aa83b6fc9e5ead86578773dfbd93047380042e26b69", size = 426236, upload-time = "2025-10-16T10:35:27.404Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/cd/89ce482a931b543b92cdd9b2888805518c4620e0094409acb8c81dd4610a/grpcio_status-1.78.0.tar.gz", hash = "sha256:a34cfd28101bfea84b5aa0f936b4b423019e9213882907166af6b3bddc59e189", size = 13808, upload-time = "2026-02-06T10:01:48.034Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/b8/c0d9aa013ecfa8b7057946c080c0c07f6fa41e231d2e9bd306a2f8110bdc/h5py-3.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:316dd0f119734f324ca7ed10b5627a2de4ea42cc4dfbcedbee026aaa361c238c", size = 3399089, upload-time = "2025-10-16T10:34:12.135Z" }, - { url = "https://files.pythonhosted.org/packages/a4/5e/3c6f6e0430813c7aefe784d00c6711166f46225f5d229546eb53032c3707/h5py-3.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b51469890e58e85d5242e43aab29f5e9c7e526b951caab354f3ded4ac88e7b76", size = 2847803, upload-time = "2025-10-16T10:34:14.564Z" }, - { url = "https://files.pythonhosted.org/packages/00/69/ba36273b888a4a48d78f9268d2aee05787e4438557450a8442946ab8f3ec/h5py-3.15.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a33bfd5dfcea037196f7778534b1ff7e36a7f40a89e648c8f2967292eb6898e", size = 4914884, upload-time = "2025-10-16T10:34:18.452Z" }, - { url = "https://files.pythonhosted.org/packages/3a/30/d1c94066343a98bb2cea40120873193a4fed68c4ad7f8935c11caf74c681/h5py-3.15.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25c8843fec43b2cc368aa15afa1cdf83fc5e17b1c4e10cd3771ef6c39b72e5ce", size = 5109965, upload-time = "2025-10-16T10:34:21.853Z" }, - { url = "https://files.pythonhosted.org/packages/81/3d/d28172116eafc3bc9f5991b3cb3fd2c8a95f5984f50880adfdf991de9087/h5py-3.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a308fd8681a864c04423c0324527237a0484e2611e3441f8089fd00ed56a8171", size = 4561870, upload-time = "2025-10-16T10:34:26.69Z" }, - { url = "https://files.pythonhosted.org/packages/a5/83/393a7226024238b0f51965a7156004eaae1fcf84aa4bfecf7e582676271b/h5py-3.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f4a016df3f4a8a14d573b496e4d1964deb380e26031fc85fb40e417e9131888a", size = 5037161, upload-time = "2025-10-16T10:34:30.383Z" }, - { url = "https://files.pythonhosted.org/packages/cf/51/329e7436bf87ca6b0fe06dd0a3795c34bebe4ed8d6c44450a20565d57832/h5py-3.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:59b25cf02411bf12e14f803fef0b80886444c7fe21a5ad17c6a28d3f08098a1e", size = 2874165, upload-time = "2025-10-16T10:34:33.461Z" }, - { url = "https://files.pythonhosted.org/packages/09/a8/2d02b10a66747c54446e932171dd89b8b4126c0111b440e6bc05a7c852ec/h5py-3.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:61d5a58a9851e01ee61c932bbbb1c98fe20aba0a5674776600fb9a361c0aa652", size = 2458214, upload-time = "2025-10-16T10:34:35.733Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/1241ec22c41028bddd4a052ae9369267b4475265ad0ce7140974548dc3fa/grpcio_status-1.78.0-py3-none-any.whl", hash = "sha256:b492b693d4bf27b47a6c32590701724f1d3b9444b36491878fb71f6208857f34", size = 14523, upload-time = "2026-02-06T10:01:32.584Z" }, ] +[[package]] +name = "h3" +version = "4.0.0b2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/9f/c3bf7ef02d59cb81db8ac2860292ddbfafe34d299d4f5b45ca58899f9ee3/h3-4.0.0b2.tar.gz", hash = "sha256:be147d4662db70c95742a7b283d2bcf9a46313f29253950a6072cce795908871", size = 125488, upload-time = "2022-11-24T01:16:06.779Z" } + [[package]] name = "identify" version = "2.6.15" @@ -881,19 +834,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] -[[package]] -name = "imageio" -version = "2.37.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "pillow" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/606be632e37bf8d05b253e8626c2291d74c691ddc7bcdf7d6aaf33b32f6a/imageio-2.37.2.tar.gz", hash = "sha256:0212ef2727ac9caa5ca4b2c75ae89454312f440a756fcfc8ef1993e718f50f8a", size = 389600, upload-time = "2025-11-04T14:29:39.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/fe/301e0936b79bcab4cacc7548bf2853fc28dced0a578bab1f7ef53c9aa75b/imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b", size = 317646, upload-time = "2025-11-04T14:29:37.948Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -1071,15 +1011,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jmespath" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, -] - [[package]] name = "jsonpointer" version = "3.0.0" @@ -1267,32 +1198,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398, upload-time = "2022-04-20T22:04:42.23Z" }, ] -[[package]] -name = "lxml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, -] - [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1396,6 +1301,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/17/cf5326d6867be057f232d0610de1458f70a8ce7b6290e4b4a277ea62b4cd/ml_dtypes-0.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:8bb9cd1ce63096567f5f42851f5843b5a0ea11511e50039a7649619abfb4ba6d", size = 161560, upload-time = "2025-07-29T18:38:41.072Z" }, ] +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "multiurl" version = "0.3.7" @@ -1440,15 +1372,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "narwhals" -version = "2.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/a2/25208347aa4c2d82a265cf4bc0873aaf5069f525c0438146821e7fc19ef5/narwhals-2.11.0.tar.gz", hash = "sha256:d23f3ea7efc6b4d0355444a72de6b8fa3011175585246c3400c894a7583964af", size = 589233, upload-time = "2025-11-10T16:28:35.675Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/a1/4d21933898e23b011ae0528151b57a9230a62960d0919bf2ee48c7f5c20a/narwhals-2.11.0-py3-none-any.whl", hash = "sha256:a9795e1e44aa94e5ba6406ef1c5ee4c172414ced4f1aea4a79e5894f0c7378d4", size = 423069, upload-time = "2025-11-10T16:28:33.522Z" }, -] - [[package]] name = "nbclient" version = "0.10.2" @@ -1515,38 +1438,47 @@ wheels = [ [[package]] name = "netcdf4" -version = "1.7.3" +version = "1.7.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "cftime" }, { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/76/7bc801796dee752c1ce9cd6935564a6ee79d5c9d9ef9192f57b156495a35/netcdf4-1.7.3.tar.gz", hash = "sha256:83f122fc3415e92b1d4904fd6a0898468b5404c09432c34beb6b16c533884673", size = 836095, upload-time = "2025-10-13T18:38:00.76Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/b6/0370bb3af66a12098da06dc5843f3b349b7c83ccbdf7306e7afa6248b533/netcdf4-1.7.4.tar.gz", hash = "sha256:cdbfdc92d6f4d7192ca8506c9b3d4c1d9892969ff28d8e8e1fc97ca08bf12164", size = 838352, upload-time = "2026-01-05T02:27:38.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/62/d286c76cdf0f6faf6064dc032ba7df3d6172ccca6e7d3571eee5516661b9/netcdf4-1.7.3-cp311-abi3-macosx_13_0_x86_64.whl", hash = "sha256:801c222d8ad35fd7dc7e9aa7ea6373d184bcb3b8ee6b794c5fbecaa5155b1792", size = 2751401, upload-time = "2025-10-13T18:37:52.869Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5e/0bb5593df674971e9fe5d76f7a0dd2006f3ee6b3a9eaece8c01170bac862/netcdf4-1.7.3-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:83dbfd6f10a0ec785d5296016bd821bbe9f0df780be72fc00a1f0d179d9c5f0f", size = 2387517, upload-time = "2025-10-13T18:37:53.947Z" }, - { url = "https://files.pythonhosted.org/packages/8e/27/9530c58ddec2c28297d1abbc2f3668cb7bf79864bcbfb0516634ad0d3908/netcdf4-1.7.3-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:949e086d4d2612b49e5b95f60119d216c9ceb7b17bc771e9e0fa0e9b9c0a2f9f", size = 9621631, upload-time = "2025-10-13T18:37:55.226Z" }, - { url = "https://files.pythonhosted.org/packages/97/1a/78b19893197ed7525edfa7f124a461626541e82aec694a468ba97755c24e/netcdf4-1.7.3-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c764ba6f6a1421cab5496097e8a1c4d2e36be2a04880dfd288bb61b348c217e", size = 9453727, upload-time = "2025-10-13T18:37:57.122Z" }, - { url = "https://files.pythonhosted.org/packages/2a/f8/a5509bc46faedae2b71df29c57e6525b7eb47aee44000fd43e2927a9a3a9/netcdf4-1.7.3-cp311-abi3-win_amd64.whl", hash = "sha256:1b6c646fa179fb1e5e8d6e8231bc78cc0311eceaa1241256b5a853f1d04055b9", size = 7149328, upload-time = "2025-10-13T18:37:59.242Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/38ed7e1956943d28e8ea74161e97c3a00fb98d6d08943b4fd21bae32c240/netcdf4-1.7.4-cp311-abi3-macosx_13_0_x86_64.whl", hash = "sha256:dec70e809cc65b04ebe95113ee9c85ba46a51c3a37c058d2b2b0cadc4d3052d8", size = 23427499, upload-time = "2026-01-05T02:27:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/2f73c133b71709c412bc81d8b721e28dc6237ba9d7dad861b7bfbb70408a/netcdf4-1.7.4-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:75cf59100f0775bc4d6b9d4aca7cbabd12e2b8cf3b9a4fb16d810b92743a315a", size = 22847667, upload-time = "2026-01-05T02:27:09.421Z" }, + { url = "https://files.pythonhosted.org/packages/77/ce/43a3c0c41a6e2e940d87feea79d29aa88302211ac122604838f8a5a48de6/netcdf4-1.7.4-cp311-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddfc7e9d261125c74708119440c85ea288b5fee41db676d2ba1ce9be11f96932", size = 10274769, upload-time = "2026-01-05T21:31:19.243Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7a/a8d32501bb95ecff342004a674720164f95ad616f269450b3bc13dc88ae3/netcdf4-1.7.4-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a72c9f58767779ec14cb7451c3b56bdd8fdc027a792fac2062b14e090c5617f3", size = 10123122, upload-time = "2026-01-05T21:31:22.773Z" }, + { url = "https://files.pythonhosted.org/packages/18/68/e89b4fa9242e59326c849c39ce0f49eb68499603c639405a8449900a4f15/netcdf4-1.7.4-cp311-abi3-win_amd64.whl", hash = "sha256:9476e1f23161ae5159cd1548c50c8a37922e77d76583e247133f256ef7b825fc", size = 21299637, upload-time = "2026-01-05T02:27:11.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fc/edd41a3607241027aa4533e7f18e0cd647e74dde10a63274c65350f59967/netcdf4-1.7.4-cp311-abi3-win_arm64.whl", hash = "sha256:876ad9d58f09c98741c066c726164c45a098a58fb90e5fac9e74de4bb8a793fd", size = 2386377, upload-time = "2026-01-05T02:27:13.808Z" }, ] [[package]] -name = "networkx" -version = "3.5" +name = "nodeenv" +version = "1.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload-time = "2025-05-29T11:35:07.804Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] [[package]] -name = "nodeenv" -version = "1.9.1" +name = "numcodecs" +version = "0.16.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +dependencies = [ + { name = "numpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/8a391e7c356366224734efd24da929cc4796fff468bfb179fe1af6548535/numcodecs-0.16.5.tar.gz", hash = "sha256:0d0fb60852f84c0bd9543cc4d2ab9eefd37fc8efcc410acd4777e62a1d300318", size = 6276387, upload-time = "2025-11-21T02:49:48.986Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, + { url = "https://files.pythonhosted.org/packages/75/cc/55420f3641a67f78392dc0bc5d02cb9eb0a9dcebf2848d1ac77253ca61fa/numcodecs-0.16.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:24e675dc8d1550cd976a99479b87d872cb142632c75cc402fea04c08c4898523", size = 1656287, upload-time = "2025-11-21T02:49:25.755Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6c/86644987505dcb90ba6d627d6989c27bafb0699f9fd00187e06d05ea8594/numcodecs-0.16.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:94ddfa4341d1a3ab99989d13b01b5134abb687d3dab2ead54b450aefe4ad5bd6", size = 1148899, upload-time = "2025-11-21T02:49:26.87Z" }, + { url = "https://files.pythonhosted.org/packages/97/1e/98aaddf272552d9fef1f0296a9939d1487914a239e98678f6b20f8b0a5c8/numcodecs-0.16.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b554ab9ecf69de7ca2b6b5e8bc696bd9747559cb4dd5127bd08d7a28bec59c3a", size = 8534814, upload-time = "2025-11-21T02:49:28.547Z" }, + { url = "https://files.pythonhosted.org/packages/fb/53/78c98ef5c8b2b784453487f3e4d6c017b20747c58b470393e230c78d18e8/numcodecs-0.16.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad1a379a45bd3491deab8ae6548313946744f868c21d5340116977ea3be5b1d6", size = 9173471, upload-time = "2025-11-21T02:49:30.444Z" }, + { url = "https://files.pythonhosted.org/packages/1c/20/2fdec87fc7f8cec950d2b0bea603c12dc9f05b4966dc5924ba5a36a61bf6/numcodecs-0.16.5-cp312-cp312-win_amd64.whl", hash = "sha256:845a9857886ffe4a3172ba1c537ae5bcc01e65068c31cf1fce1a844bd1da050f", size = 801412, upload-time = "2025-11-21T02:49:32.123Z" }, ] [[package]] @@ -1618,7 +1550,7 @@ name = "nvidia-cudnn-cu12" version = "9.15.1.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "sys_platform != 'darwin'" }, + { name = "nvidia-cublas-cu12", marker = "(platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/fe/d8/448f16a767828d3fa76ac2b16d150febde5c9c2f8baa962a35976dde29c8/nvidia_cudnn_cu12-9.15.1.9-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:2520692ef9d7804f4f02c8f3a127b24d568bb944ab1ccee0ab946a64699acf36", size = 646656709, upload-time = "2025-11-12T20:21:46.842Z" }, @@ -1630,7 +1562,7 @@ name = "nvidia-cufft-cu12" version = "11.4.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'darwin'" }, + { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/9b/2b/76445b0af890da61b501fde30650a1a4bd910607261b209cccb5235d3daa/nvidia_cufft_cu12-11.4.1.4-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1a28c9b12260a1aa7a8fd12f5ebd82d027963d635ba82ff39a1acfa7c4c0fbcf", size = 200822453, upload-time = "2025-06-05T20:05:27.889Z" }, @@ -1642,9 +1574,9 @@ name = "nvidia-cusolver-cu12" version = "11.7.5.82" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "sys_platform != 'darwin'" }, - { name = "nvidia-cusparse-cu12", marker = "sys_platform != 'darwin'" }, - { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'darwin'" }, + { name = "nvidia-cublas-cu12", marker = "(platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'win32')" }, + { name = "nvidia-cusparse-cu12", marker = "(platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/03/99/686ff9bf3a82a531c62b1a5c614476e8dfa24a9d89067aeedf3592ee4538/nvidia_cusolver_cu12-11.7.5.82-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:62efa83e4ace59a4c734d052bb72158e888aa7b770e1a5f601682f16fe5b4fd2", size = 337869834, upload-time = "2025-06-05T20:06:53.125Z" }, @@ -1656,7 +1588,7 @@ name = "nvidia-cusparse-cu12" version = "12.5.10.65" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'darwin'" }, + { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'ARM64' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/5e/6f/8710fbd17cdd1d0fc3fea7d36d5b65ce1933611c31e1861da330206b253a/nvidia_cusparse_cu12-12.5.10.65-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:221c73e7482dd93eda44e65ce567c031c07e2f93f6fa0ecd3ba876a195023e83", size = 366359408, upload-time = "2025-06-05T20:07:42.501Z" }, @@ -1690,21 +1622,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd", size = 139103095, upload-time = "2025-09-06T00:32:31.266Z" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "opencv-python" -version = "4.11.0.86" +version = "4.13.0.92" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/06/68c27a523103dad5837dc5b87e71285280c4f098c60e4fe8a8db6486ab09/opencv-python-4.11.0.86.tar.gz", hash = "sha256:03d60ccae62304860d232272e4a4fda93c39d595780cb40b161b310244b736a4", size = 95171956, upload-time = "2025-01-16T13:52:24.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/4d/53b30a2a3ac1f75f65a59eb29cf2ee7207ce64867db47036ad61743d5a23/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:432f67c223f1dc2824f5e73cdfcd9db0efc8710647d4e813012195dc9122a52a", size = 37326322, upload-time = "2025-01-16T13:52:25.887Z" }, - { url = "https://files.pythonhosted.org/packages/3b/84/0a67490741867eacdfa37bc18df96e08a9d579583b419010d7f3da8ff503/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:9d05ef13d23fe97f575153558653e2d6e87103995d54e6a35db3f282fe1f9c66", size = 56723197, upload-time = "2025-01-16T13:55:21.222Z" }, - { url = "https://files.pythonhosted.org/packages/f3/bd/29c126788da65c1fb2b5fb621b7fed0ed5f9122aa22a0868c5e2c15c6d23/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b92ae2c8852208817e6776ba1ea0d6b1e0a1b5431e971a2a0ddd2a8cc398202", size = 42230439, upload-time = "2025-01-16T13:51:35.822Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8b/90eb44a40476fa0e71e05a0283947cfd74a5d36121a11d926ad6f3193cc4/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b02611523803495003bd87362db3e1d2a0454a6a63025dc6658a9830570aa0d", size = 62986597, upload-time = "2025-01-16T13:52:08.836Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d7/1d5941a9dde095468b288d989ff6539dd69cd429dbf1b9e839013d21b6f0/opencv_python-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:810549cb2a4aedaa84ad9a1c92fbfdfc14090e2749cedf2c1589ad8359aa169b", size = 29384337, upload-time = "2025-01-16T13:52:13.549Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/f1c30a92854540bf789e9cd5dde7ef49bbe63f855b85a2e6b3db8135c591/opencv_python-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:085ad9b77c18853ea66283e98affefe2de8cc4c1f43eda4c100cf9b2721142ec", size = 39488044, upload-time = "2025-01-16T13:52:21.928Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, ] [[package]] @@ -1716,18 +1658,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/cd/066e86230ae37ed0be70aae89aabf03ca8d9f39c8aea0dec8029455b5540/opt_einsum-3.4.0-py3-none-any.whl", hash = "sha256:69bb92469f86a1565195ece4ac0323943e83477171b91d24c35afe028a90d7cd", size = 71932, upload-time = "2024-09-26T14:33:23.039Z" }, ] -[[package]] -name = "outcome" -version = "1.3.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -1827,7 +1757,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "platform_machine != 'ARM64' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -1908,19 +1838,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, +] + [[package]] name = "protobuf" -version = "6.33.1" +version = "6.33.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/03/a1440979a3f74f16cab3b75b0da1a1a7f922d56a8ddea96092391998edc0/protobuf-6.33.1.tar.gz", hash = "sha256:97f65757e8d09870de6fd973aeddb92f85435607235d20b2dfed93405d00c85b", size = 443432, upload-time = "2025-11-13T16:44:18.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/f1/446a9bbd2c60772ca36556bac8bfde40eceb28d9cc7838755bc41e001d8f/protobuf-6.33.1-cp310-abi3-win32.whl", hash = "sha256:f8d3fdbc966aaab1d05046d0240dd94d40f2a8c62856d41eaa141ff64a79de6b", size = 425593, upload-time = "2025-11-13T16:44:06.275Z" }, - { url = "https://files.pythonhosted.org/packages/a6/79/8780a378c650e3df849b73de8b13cf5412f521ca2ff9b78a45c247029440/protobuf-6.33.1-cp310-abi3-win_amd64.whl", hash = "sha256:923aa6d27a92bf44394f6abf7ea0500f38769d4b07f4be41cb52bd8b1123b9ed", size = 436883, upload-time = "2025-11-13T16:44:09.222Z" }, - { url = "https://files.pythonhosted.org/packages/cd/93/26213ff72b103ae55bb0d73e7fb91ea570ef407c3ab4fd2f1f27cac16044/protobuf-6.33.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:fe34575f2bdde76ac429ec7b570235bf0c788883e70aee90068e9981806f2490", size = 427522, upload-time = "2025-11-13T16:44:10.475Z" }, - { url = "https://files.pythonhosted.org/packages/c2/32/df4a35247923393aa6b887c3b3244a8c941c32a25681775f96e2b418f90e/protobuf-6.33.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:f8adba2e44cde2d7618996b3fc02341f03f5bc3f2748be72dc7b063319276178", size = 324445, upload-time = "2025-11-13T16:44:11.869Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d0/d796e419e2ec93d2f3fa44888861c3f88f722cde02b7c3488fcc6a166820/protobuf-6.33.1-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:0f4cf01222c0d959c2b399142deb526de420be8236f22c71356e2a544e153c53", size = 339161, upload-time = "2025-11-13T16:44:12.778Z" }, - { url = "https://files.pythonhosted.org/packages/1d/2a/3c5f05a4af06649547027d288747f68525755de692a26a7720dced3652c0/protobuf-6.33.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:8fd7d5e0eb08cd5b87fd3df49bc193f5cfd778701f47e11d127d0afc6c39f1d1", size = 323171, upload-time = "2025-11-13T16:44:14.035Z" }, - { url = "https://files.pythonhosted.org/packages/08/b4/46310463b4f6ceef310f8348786f3cff181cea671578e3d9743ba61a459e/protobuf-6.33.1-py3-none-any.whl", hash = "sha256:d595a9fd694fdeb061a62fbe10eb039cc1e444df81ec9bb70c7fc59ebcb1eafa", size = 170477, upload-time = "2025-11-13T16:44:17.633Z" }, + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, ] [[package]] @@ -1948,110 +1914,41 @@ wheels = [ [[package]] name = "pure-eval" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, -] - -[[package]] -name = "pyarrow" -version = "21.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/c2/ea068b8f00905c06329a3dfcd40d0fcc2b7d0f2e355bdb25b65e0a0e4cd4/pyarrow-21.0.0.tar.gz", hash = "sha256:5051f2dccf0e283ff56335760cbc8622cf52264d67e359d5569541ac11b6d5bc", size = 1133487, upload-time = "2025-07-18T00:57:31.761Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/d4/d4f817b21aacc30195cf6a46ba041dd1be827efa4a623cc8bf39a1c2a0c0/pyarrow-21.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:3a302f0e0963db37e0a24a70c56cf91a4faa0bca51c23812279ca2e23481fccd", size = 31160305, upload-time = "2025-07-18T00:55:35.373Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9c/dcd38ce6e4b4d9a19e1d36914cb8e2b1da4e6003dd075474c4cfcdfe0601/pyarrow-21.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b6b27cf01e243871390474a211a7922bfbe3bda21e39bc9160daf0da3fe48876", size = 32684264, upload-time = "2025-07-18T00:55:39.303Z" }, - { url = "https://files.pythonhosted.org/packages/4f/74/2a2d9f8d7a59b639523454bec12dba35ae3d0a07d8ab529dc0809f74b23c/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e72a8ec6b868e258a2cd2672d91f2860ad532d590ce94cdf7d5e7ec674ccf03d", size = 41108099, upload-time = "2025-07-18T00:55:42.889Z" }, - { url = "https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e", size = 42829529, upload-time = "2025-07-18T00:55:47.069Z" }, - { url = "https://files.pythonhosted.org/packages/33/27/1a93a25c92717f6aa0fca06eb4700860577d016cd3ae51aad0e0488ac899/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:58c30a1729f82d201627c173d91bd431db88ea74dcaa3885855bc6203e433b82", size = 43367883, upload-time = "2025-07-18T00:55:53.069Z" }, - { url = "https://files.pythonhosted.org/packages/05/d9/4d09d919f35d599bc05c6950095e358c3e15148ead26292dfca1fb659b0c/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:072116f65604b822a7f22945a7a6e581cfa28e3454fdcc6939d4ff6090126623", size = 45133802, upload-time = "2025-07-18T00:55:57.714Z" }, - { url = "https://files.pythonhosted.org/packages/71/30/f3795b6e192c3ab881325ffe172e526499eb3780e306a15103a2764916a2/pyarrow-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf56ec8b0a5c8c9d7021d6fd754e688104f9ebebf1bf4449613c9531f5346a18", size = 26203175, upload-time = "2025-07-18T00:56:01.364Z" }, -] - -[[package]] -name = "pycparser" -version = "2.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, -] - -[[package]] -name = "pydantic" -version = "2.12.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] [[package]] -name = "pydantic-core" -version = "2.41.5" +name = "pyasn1" +version = "0.6.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, ] [[package]] -name = "pydap" -version = "3.5.8" +name = "pyasn1-modules" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "beautifulsoup4" }, - { name = "lxml" }, - { name = "numpy" }, - { name = "requests" }, - { name = "requests-cache" }, - { name = "scipy" }, - { name = "webob" }, + { name = "pyasn1" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/aa/8dd622677cb0436e0b84993f0bda2331612bab88995691653af9ddc889ee/pydap-3.5.8.tar.gz", hash = "sha256:0dc3c7f28fd456e17ed1c789ccfd119938a2bd1d73828cdf5319c69a213df560", size = 12126573, upload-time = "2025-10-03T19:19:55.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/20/e989ab34f456f2f86a8d1e9013d09cc022e7508868e452084f49210b912e/pydap-3.5.8-py3-none-any.whl", hash = "sha256:7e18b224e8b93d53b9505dc8de34bcb9b1d121169403a836461d758dc998ca5c", size = 2429178, upload-time = "2025-10-03T19:19:53.38Z" }, + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] [[package]] -name = "pydeck" -version = "0.9.1" +name = "pycparser" +version = "2.23" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jinja2" }, - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/40e14e196864a0f61a92abb14d09b3d3da98f94ccb03b49cf51688140dab/pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605", size = 3832240, upload-time = "2024-05-10T15:36:21.153Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038", size = 6900403, upload-time = "2024-05-10T15:36:17.36Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] [[package]] @@ -2063,25 +1960,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] -[[package]] -name = "pyogrio" -version = "0.11.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "numpy" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bf/1d/ae0340237207664e2da1b77f2cdbcb5f81fd0fc9f3200a48ca993a5e12ef/pyogrio-0.11.1.tar.gz", hash = "sha256:e1441dc9c866f10d8e6ae7ea9249a10c1f57ea921b1f19a5b0977ab91ef8082c", size = 287267, upload-time = "2025-08-02T20:19:20.167Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/d3/2ba967ca4255cdfa130a6d8b437826488567b4bc1bb417c442bb43d62611/pyogrio-0.11.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f186456ebe5d5f61e7bd883bad25a59d43d6304178d4f0d3e03273f42b40a4cc", size = 19450110, upload-time = "2025-08-02T20:18:36.643Z" }, - { url = "https://files.pythonhosted.org/packages/5a/e1/3bc29ae71d24a91cf91f7413541e50acb7de2ce609587168ce2f4b405d3b/pyogrio-0.11.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b8a199bc0e421eac444af96942b7553268e43d0cadf30d0d6d41017de05b7e9e", size = 20635348, upload-time = "2025-08-02T20:18:38.714Z" }, - { url = "https://files.pythonhosted.org/packages/8c/b2/ec453e544370a90b4e8b2c6afa72501963ddc33afe883f0e5ba34af6a80f/pyogrio-0.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afce80b4b32f043fcf76a50e8572e3ad8d9d3e6abbbfa6137f0975ba55c4eeb8", size = 26980190, upload-time = "2025-08-02T20:18:41.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f6/337f122b58f697f807bf9093b606b33b3ef52fe06a21e88d8a9230844cc3/pyogrio-0.11.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0cfd79caf0b8cb7bbf30b419dff7f21509169efcf4d431172c61b44fe1029dba", size = 26474852, upload-time = "2025-08-02T20:18:43.74Z" }, - { url = "https://files.pythonhosted.org/packages/e6/0f/8193a4a879f1284d693793e59a2e185c8fd3c47cb562b0e5daf7289997ea/pyogrio-0.11.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ab3aa6dbf2441d2407ce052233f2966324a3cff752bd43d99e4c779ea54e0a16", size = 27659721, upload-time = "2025-08-02T20:18:46.398Z" }, - { url = "https://files.pythonhosted.org/packages/5f/7d/3e818625a435fcc196ea441a6ca8495f87dd1f1eebeb95760eb401ea425d/pyogrio-0.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:cd10035eb3b5e5a43bdafbd777339d2274e9b75972658364f0ce31c4d3400d1e", size = 19219350, upload-time = "2025-08-02T20:18:48.866Z" }, -] - [[package]] name = "pyparsing" version = "3.2.5" @@ -2113,20 +1991,11 @@ wheels = [ [[package]] name = "pyshp" -version = "2.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/9f/0dd21250c60375a532c35e89fad8d5e8a3f1a2e3f7c389ccc5a60b05263e/pyshp-2.3.1.tar.gz", hash = "sha256:4caec82fd8dd096feba8217858068bacb2a3b5950f43c048c6dc32a3489d5af1", size = 1731544, upload-time = "2022-07-27T19:51:28.409Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/2f/68116db5b36b895c0450e3072b8cb6c2fac0359279b182ea97014d3c8ac0/pyshp-2.3.1-py2.py3-none-any.whl", hash = "sha256:67024c0ccdc352ba5db777c4e968483782dfa78f8e200672a90d2d30fd8b7b49", size = 46537, upload-time = "2022-07-27T19:51:26.34Z" }, -] - -[[package]] -name = "pysocks" -version = "1.7.1" +version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/20/8b07bae73aaa0c3f5a2683ba6e23b46e977e2d33a88126d56bbcc2d135cd/pyshp-3.0.3.tar.gz", hash = "sha256:bf4678b13dd53578ed87669676a2fffeccbcded1ec8ff9cafb36d1b660f4b305", size = 2192568, upload-time = "2025-11-28T17:47:31.616Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, + { url = "https://files.pythonhosted.org/packages/82/06/cad54e8ce758bd836ee5411691cbd49efeb9cc611b374670fce299519334/pyshp-3.0.3-py3-none-any.whl", hash = "sha256:28c8fac8c0c25bb0fecbbfd10ead7f319c2ff2f3b0b44a94f22bd2c93510ad42", size = 58465, upload-time = "2025-11-28T17:47:30.328Z" }, ] [[package]] @@ -2166,15 +2035,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, ] -[[package]] -name = "pytokens" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, -] - [[package]] name = "pytz" version = "2025.2" @@ -2232,28 +2092,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, ] -[[package]] -name = "rasterio" -version = "1.4.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "affine" }, - { name = "attrs" }, - { name = "certifi" }, - { name = "click" }, - { name = "click-plugins" }, - { name = "cligj" }, - { name = "numpy" }, - { name = "pyparsing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/19/ab4326e419b543da623ce4191f68e3f36a4d9adc64f3df5c78f044d8d9ca/rasterio-1.4.3.tar.gz", hash = "sha256:201f05dbc7c4739dacb2c78a1cf4e09c0b7265b0a4d16ccbd1753ce4f2af350a", size = 442990, upload-time = "2024-12-02T14:49:25.571Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/f2/b7417292ceace70d815760f7e41fe5b0244ebff78ede11b1ffa9ca01c370/rasterio-1.4.3-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:e703e4b2c74c678786d5d110a3f30e26f3acfd65f09ccf35f69683a532f7a772", size = 21514543, upload-time = "2024-12-02T14:48:49.757Z" }, - { url = "https://files.pythonhosted.org/packages/b2/ea/e21010457847b26bb4aea3983e9b44afbcefef07defc5e9a3285a8fe2f0c/rasterio-1.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:38a126f8dbf405cd3450b5bd10c6cc493a2e1be4cf83442d26f5e4f412372d36", size = 18735924, upload-time = "2024-12-02T14:48:53.263Z" }, - { url = "https://files.pythonhosted.org/packages/67/72/331727423b28fffdfd8bf18bdc55c18d374c0fefd2dde390cd833f8f4477/rasterio-1.4.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e90c2c300294265c16becc9822337ded0f01fb8664500b4d77890d633d8cd0e", size = 22251721, upload-time = "2024-12-02T14:48:56.533Z" }, - { url = "https://files.pythonhosted.org/packages/be/cc/453816b489af94b9a243eda889865973d518989ba6923b2381f6d6722b43/rasterio-1.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:a962ad4c29feaf38b1d7a94389313127de3646a5b9b734fbf9a04e16051a27ff", size = 25430154, upload-time = "2024-12-02T14:48:59.261Z" }, -] - [[package]] name = "referencing" version = "0.37.0" @@ -2284,20 +2122,16 @@ wheels = [ ] [[package]] -name = "requests-cache" -version = "1.2.1" +name = "requests-oauthlib" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "cattrs" }, - { name = "platformdirs" }, + { name = "oauthlib" }, { name = "requests" }, - { name = "url-normalize" }, - { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/be/7b2a95a9e7a7c3e774e43d067c51244e61dea8b120ae2deff7089a93fb2b/requests_cache-1.2.1.tar.gz", hash = "sha256:68abc986fdc5b8d0911318fbb5f7c80eebcd4d01bfacc6685ecf8876052511d1", size = 3018209, upload-time = "2024-06-18T17:18:03.774Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/2e/8f4051119f460cfc786aa91f212165bb6e643283b533db572d7b33952bd2/requests_cache-1.2.1-py3-none-any.whl", hash = "sha256:1285151cddf5331067baa82598afe2d47c7495a1334bfe7a7d329b43e9fd3603", size = 61425, upload-time = "2024-06-18T17:17:45Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, ] [[package]] @@ -2352,31 +2186,44 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "borb" }, + { name = "cartopy" }, { name = "cma" }, - { name = "fastapi" }, + { name = "dask" }, + { name = "gcsfs" }, { name = "h3" }, { name = "jax", extra = ["cuda"] }, { name = "jaxlib" }, { name = "jupyter-server" }, { name = "matplotlib" }, + { name = "netcdf4" }, + { name = "opencv-python" }, { name = "pandas" }, { name = "perlin-numpy" }, { name = "pytest" }, + { name = "scipy" }, { name = "seaborn" }, { name = "shapely" }, { name = "statsmodels" }, { name = "typer" }, { name = "typer-slim" }, - { name = "uvicorn" }, - { name = "wrr-bench" }, - { name = "wrr-utils" }, { name = "xarray" }, + { name = "zarr" }, ] [package.optional-dependencies] cuda = [ { name = "jax", extra = ["cuda"] }, ] +era5-cds = [ + { name = "cdsapi" }, +] +era5-gcs = [ + { name = "gcsfs" }, + { name = "zarr" }, +] +swopp3 = [ + { name = "swopp3-performance-model" }, +] [package.dev-dependencies] dev = [ @@ -2390,28 +2237,35 @@ dev = [ [package.metadata] requires-dist = [ { name = "borb", url = "https://files.pythonhosted.org/packages/7a/4e/b193d894ffb0fde0f773b0476d8b041528302fa86bccf2cc8006c79404e4/borb-3.0.1.tar.gz" }, + { name = "cartopy", specifier = ">=0.25.0" }, + { name = "cdsapi", marker = "extra == 'era5-cds'", specifier = ">=0.7.0" }, { name = "cma", specifier = ">=4.0.0" }, - { name = "fastapi" }, + { name = "dask", specifier = ">=2024.1.0" }, + { name = "gcsfs", specifier = ">=2026.2.0" }, + { name = "gcsfs", marker = "extra == 'era5-gcs'", specifier = ">=2024.1.0" }, { name = "h3", specifier = "==4.0.0b2" }, { name = "jax", extras = ["cuda"], specifier = "==0.8.1" }, { name = "jax", extras = ["cuda"], marker = "extra == 'cuda'", specifier = "==0.8.1" }, { name = "jaxlib", specifier = "==0.8.1" }, { name = "jupyter-server", specifier = ">=2.17.0" }, { name = "matplotlib", specifier = ">=3.9.2" }, + { name = "netcdf4", specifier = ">=1.6.0" }, + { name = "opencv-python", specifier = ">=4.13.0.92" }, { name = "pandas" }, { name = "perlin-numpy", specifier = ">=0.0.1" }, { name = "pytest", specifier = ">=8.3.2" }, + { name = "scipy", specifier = ">=1.12.0" }, { name = "seaborn", specifier = ">=0.13.2" }, { name = "shapely", specifier = ">=2.0.6" }, { name = "statsmodels", specifier = ">=0.14.6" }, + { name = "swopp3-performance-model", marker = "extra == 'swopp3'", specifier = "==0.1.0" }, { name = "typer", specifier = ">=0.19.2" }, { name = "typer-slim" }, - { name = "uvicorn" }, - { name = "wrr-bench", git = "ssh://git@github.com/Weather-Routing-Research/weather-routing-benchmarks.git?branch=main" }, - { name = "wrr-utils", git = "ssh://git@github.com/Weather-Routing-Research/weather-routing-research.git?branch=main" }, { name = "xarray", specifier = ">=2024.11.0" }, + { name = "zarr", specifier = ">=3.1.5" }, + { name = "zarr", marker = "extra == 'era5-gcs'", specifier = ">=2.17.0" }, ] -provides-extras = ["cuda"] +provides-extras = ["cuda", "era5-cds", "era5-gcs", "swopp3"] [package.metadata.requires-dev] dev = [ @@ -2445,6 +2299,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/91/f3fb250d7e73de71080f9a221d19bd6a1c1eb0d12a1ea26513f6c1052ad6/rpds_py-0.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:1f0cfd1c69e2d14f8c892b893997fa9a60d890a0c8a603e88dca4955f26d1edd", size = 217624, upload-time = "2025-10-22T22:22:26.914Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.14.5" @@ -2471,31 +2337,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, ] -[[package]] -name = "s3fs" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore" }, - { name = "fsspec" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/504cb277632c4d325beabbd03bb43778f0decb9be22d9e0e6c62f44540c7/s3fs-0.4.2.tar.gz", hash = "sha256:2ca5de8dc18ad7ad350c0bd01aef0406aa5d0fff78a561f0f710f9d9858abdd0", size = 57527, upload-time = "2020-03-31T15:24:26.388Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/e4/b8fc59248399d2482b39340ec9be4bb2493846ac23641b43115a7e5cd675/s3fs-0.4.2-py3-none-any.whl", hash = "sha256:91c1dfb45e5217bd441a7a560946fe865ced6225ff7eb0fb459fe6e601a95ed3", size = 19791, upload-time = "2020-03-31T15:24:24.952Z" }, -] - -[[package]] -name = "s3transfer" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, -] - [[package]] name = "scipy" version = "1.16.3" @@ -2531,33 +2372,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, ] -[[package]] -name = "searoute" -version = "1.4.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "geojson" }, - { name = "networkx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/6b/4ce3431761951567063a970e1ca0e505b2f20c0bee46a2e20ed04fe3b891/searoute-1.4.3.tar.gz", hash = "sha256:003dc67645c4d8b6ad751dc1f155c3782eef95bcd46f1c7b9c7597393749be2a", size = 1029367, upload-time = "2025-02-22T14:11:49.783Z" } - -[[package]] -name = "selenium" -version = "4.38.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "trio" }, - { name = "trio-websocket" }, - { name = "typing-extensions" }, - { name = "urllib3", extra = ["socks"] }, - { name = "websocket-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/a0/60a5e7e946420786d57816f64536e21a29f0554706b36f3cba348107024c/selenium-4.38.0.tar.gz", hash = "sha256:c117af6727859d50f622d6d0785b945c5db3e28a45ec12ad85cee2e7cc84fc4c", size = 924101, upload-time = "2025-10-25T02:13:06.752Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/d3/76c8f4a8d99b9f1ebcf9a611b4dd992bf5ee082a6093cfc649af3d10f35b/selenium-4.38.0-py3-none-any.whl", hash = "sha256:ed47563f188130a6fd486b327ca7ba48c5b11fb900e07d6457befdde320e35fd", size = 9694571, upload-time = "2025-10-25T02:13:04.417Z" }, -] - [[package]] name = "send2trash" version = "1.8.3" @@ -2613,15 +2427,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "smmap" -version = "5.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" @@ -2631,15 +2436,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - [[package]] name = "soupsieve" version = "2.8" @@ -2663,19 +2459,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] -[[package]] -name = "starlette" -version = "0.49.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" }, -] - [[package]] name = "statsmodels" version = "0.14.6" @@ -2698,56 +2481,12 @@ wheels = [ ] [[package]] -name = "streamlit" -version = "1.51.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "altair" }, - { name = "blinker" }, - { name = "cachetools" }, - { name = "click" }, - { name = "gitpython" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "pillow" }, - { name = "protobuf" }, - { name = "pyarrow" }, - { name = "pydeck" }, - { name = "requests" }, - { name = "tenacity" }, - { name = "toml" }, - { name = "tornado" }, - { name = "typing-extensions" }, - { name = "watchdog", marker = "sys_platform != 'darwin'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/6d/327ddd5fc35fcf2aeecb4040668337f5565a1c6c95b1e892b8bfd4bb9031/streamlit-1.51.0.tar.gz", hash = "sha256:1e742a9c0b698f466c6f5bf58d333beda5a1fbe8de660743976791b5c1446ef6", size = 9742904, upload-time = "2025-10-29T17:07:39.082Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/60/868371b6482ccd9ef423c6f62650066cf8271fdb2ee84f192695ad6b7a96/streamlit-1.51.0-py3-none-any.whl", hash = "sha256:4008b029f71401ce54946bb09a6a3e36f4f7652cbb48db701224557738cfda38", size = 10171702, upload-time = "2025-10-29T17:07:35.97Z" }, -] - -[[package]] -name = "streamlit-folium" -version = "0.25.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "branca" }, - { name = "folium" }, - { name = "jinja2" }, - { name = "streamlit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7f/2d/99571b9ac124a4382db1c10d03fd95f5c126a3cbe7191789b5b807890d2c/streamlit_folium-0.25.3.tar.gz", hash = "sha256:5ec11b3eff85ec0d6259e72e5597bd79ca7ad65b8837222220964b78428f415b", size = 522601, upload-time = "2025-09-30T22:45:36.325Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/35/d3cdab8cff94971714f866181abb1aa84ad976f6e7b6218a0499197465e4/streamlit_folium-0.25.3-py3-none-any.whl", hash = "sha256:cfdf085764da3f9b5e1e0668f6e4cc0385ff041c98133d023800983a875ca26c", size = 524601, upload-time = "2025-09-30T22:45:34.825Z" }, -] - -[[package]] -name = "tenacity" -version = "9.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +name = "swopp3-performance-model" +version = "0.1.0" +source = { registry = "release_package/wheels" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, + { path = "swopp3_performance_model-0.1.0-cp312-cp312-win_amd64.whl" }, + { path = "swopp3_performance_model-0.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" }, ] [[package]] @@ -2776,15 +2515,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, ] -[[package]] -name = "toml" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, -] - [[package]] name = "toolz" version = "1.1.0" @@ -2815,14 +2545,14 @@ wheels = [ [[package]] name = "tqdm" -version = "4.67.1" +version = "4.67.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] [[package]] @@ -2834,37 +2564,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] -[[package]] -name = "trio" -version = "0.32.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "cffi", marker = "(implementation_name != 'pypy' and os_name == 'nt' and platform_machine != 'aarch64' and sys_platform == 'linux') or (implementation_name != 'pypy' and os_name == 'nt' and sys_platform != 'darwin' and sys_platform != 'linux')" }, - { name = "idna" }, - { name = "outcome" }, - { name = "sniffio" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323, upload-time = "2025-10-31T07:18:17.466Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" }, -] - -[[package]] -name = "trio-websocket" -version = "0.12.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "outcome" }, - { name = "trio" }, - { name = "wsproto" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, -] - [[package]] name = "typer" version = "0.20.0" @@ -2902,18 +2601,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - [[package]] name = "tzdata" version = "2025.2" @@ -2932,43 +2619,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, ] -[[package]] -name = "url-normalize" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/80/31/febb777441e5fcdaacb4522316bf2a527c44551430a4873b052d545e3279/url_normalize-2.2.1.tar.gz", hash = "sha256:74a540a3b6eba1d95bdc610c24f2c0141639f3ba903501e61a52a8730247ff37", size = 18846, upload-time = "2025-04-26T20:37:58.553Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/d9/5ec15501b675f7bc07c5d16aa70d8d778b12375686b6efd47656efdc67cd/url_normalize-2.2.1-py3-none-any.whl", hash = "sha256:3deb687587dc91f7b25c9ae5162ffc0f057ae85d22b1e15cf5698311247f567b", size = 14728, upload-time = "2025-04-26T20:37:57.217Z" }, -] - [[package]] name = "urllib3" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, -] - -[package.optional-dependencies] -socks = [ - { name = "pysocks" }, -] - -[[package]] -name = "uvicorn" -version = "0.38.0" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] @@ -2985,24 +2642,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, ] -[[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, -] - [[package]] name = "wcwidth" version = "0.2.14" @@ -3030,15 +2669,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, ] -[[package]] -name = "webob" -version = "1.8.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/0b/1732085540b01f65e4e7999e15864fe14cd18b12a95731a43fd6fd11b26a/webob-1.8.9.tar.gz", hash = "sha256:ad6078e2edb6766d1334ec3dee072ac6a7f95b1e32ce10def8ff7f0f02d56589", size = 279775, upload-time = "2024-10-24T03:19:20.651Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/bd/c336448be43d40be28e71f2e0f3caf7ccb28e2755c58f4c02c065bfe3e8e/WebOb-1.8.9-py2.py3-none-any.whl", hash = "sha256:45e34c58ed0c7e2ecd238ffd34432487ff13d9ad459ddfd77895e67abba7c1f9", size = 115364, upload-time = "2024-10-24T03:19:18.642Z" }, -] - [[package]] name = "websocket-client" version = "1.9.0" @@ -3048,80 +2678,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, ] -[[package]] -name = "wrr-bench" -version = "0.0.0" -source = { git = "ssh://git@github.com/Weather-Routing-Research/weather-routing-benchmarks.git?branch=main#ee7497f45591734494981b667688d301e5425d87" } -dependencies = [ - { name = "bottleneck" }, - { name = "dask" }, - { name = "geopandas" }, - { name = "netcdf4" }, - { name = "numpy" }, - { name = "opencv-python" }, - { name = "rasterio" }, - { name = "scipy" }, - { name = "searoute" }, - { name = "shapely" }, - { name = "tqdm" }, - { name = "typer" }, - { name = "xarray" }, -] - -[[package]] -name = "wrr-utils" -version = "0.0.0" -source = { git = "ssh://git@github.com/Weather-Routing-Research/weather-routing-research.git?branch=main#2c61ed2b1191db9f065851a5f802db3de4c22b26" } -dependencies = [ - { name = "basemap" }, - { name = "basemap-data-hires" }, - { name = "black" }, - { name = "borb" }, - { name = "boto3" }, - { name = "bottleneck" }, - { name = "cfgrib" }, - { name = "chardet" }, - { name = "cma" }, - { name = "dask" }, - { name = "ecmwf-opendata" }, - { name = "folium" }, - { name = "geojson" }, - { name = "geopandas" }, - { name = "h3" }, - { name = "h5netcdf" }, - { name = "imageio" }, - { name = "lxml" }, - { name = "matplotlib" }, - { name = "netcdf4" }, - { name = "numpy" }, - { name = "opencv-python" }, - { name = "pandas" }, - { name = "pydap" }, - { name = "pytest" }, - { name = "rasterio" }, - { name = "s3fs" }, - { name = "scipy" }, - { name = "searoute" }, - { name = "selenium" }, - { name = "streamlit" }, - { name = "streamlit-folium" }, - { name = "tqdm" }, - { name = "typer" }, - { name = "xarray" }, -] - -[[package]] -name = "wsproto" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/8d/48e227460422d3f78f52618d8ef7d7a0474c6fcdaddf7f2d1aa25854ea75/wsproto-1.3.1.tar.gz", hash = "sha256:81529992325c28f0d9b86ca66fc973da96eb80ab53410249ce2e502749c7723c", size = 50083, upload-time = "2025-11-12T07:50:48.408Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/da/539c2d24b13025e54a86ce3215eb9b6297b023937a087db9ef2a436cc7b4/wsproto-1.3.1-py3-none-any.whl", hash = "sha256:297ce79322989c0d286cc158681641cd18bc7632dfb38cf4054696a89179b993", size = 24402, upload-time = "2025-11-12T07:50:47.178Z" }, -] - [[package]] name = "xarray" version = "2025.10.1" @@ -3137,10 +2693,50 @@ wheels = [ ] [[package]] -name = "xyzservices" -version = "2025.10.0" +name = "yarl" +version = "1.23.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/2b/58b3db8814e39277dd6c34aa206f7d0231ff0406284e18e03d4920a4bc78/xyzservices-2025.10.0.tar.gz", hash = "sha256:c6b7648276c98e8222fbec84d9c763128cf3653705017a4d6c4c3652480ee144", size = 1135110, upload-time = "2025-10-30T14:46:36.531Z" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +] + +[[package]] +name = "zarr" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "donfig" }, + { name = "google-crc32c" }, + { name = "numcodecs" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/76/7fa87f57c112c7b9c82f0a730f8b6f333e792574812872e2cd45ab604199/zarr-3.1.5.tar.gz", hash = "sha256:fbe0c79675a40c996de7ca08e80a1c0a20537bd4a9f43418b6d101395c0bba2b", size = 366825, upload-time = "2025-11-21T14:06:01.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/8f/447cc9cb57456d786204af0f450ffb920039104c5eff6626337c9f403bd1/xyzservices-2025.10.0-py3-none-any.whl", hash = "sha256:cfd6423367c7bc717ed5824d4dd7de2c91486886c1c193db9d8f0fa7fd43bc1b", size = 92737, upload-time = "2025-10-30T14:46:34.923Z" }, + { url = "https://files.pythonhosted.org/packages/44/15/bb13b4913ef95ad5448490821eee4671d0e67673342e4d4070854e5fe081/zarr-3.1.5-py3-none-any.whl", hash = "sha256:29cd905afb6235b94c09decda4258c888fcb79bb6c862ef7c0b8fe009b5c8563", size = 284067, upload-time = "2025-11-21T14:05:59.235Z" }, ] From 59b379fb296a3631bf361f5d029bb6a0dd9a446e Mon Sep 17 00:00:00 2001 From: daniprec Date: Mon, 25 May 2026 12:30:57 +0200 Subject: [PATCH 2/4] Add per-subplot PNG exports for SWOPP3 figures --- scripts/swopp3_analysis.py | 143 +++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/scripts/swopp3_analysis.py b/scripts/swopp3_analysis.py index 8d505be6..7cf4856f 100755 --- a/scripts/swopp3_analysis.py +++ b/scripts/swopp3_analysis.py @@ -137,6 +137,7 @@ from __future__ import annotations import argparse +import re import warnings from functools import cache from pathlib import Path @@ -249,6 +250,13 @@ def _summary_csv_path(paths: AnalysisPaths, folder: str, case_id: str) -> Path | }, } +_CASE_FILE_SUFFIX = { + "AO_WPS": "atlantic_wps", + "AO_noWPS": "atlantic_no_wps", + "PO_WPS": "pacific_wps", + "PO_noWPS": "pacific_no_wps", +} + # Great-circle baselines (GC = fixed route, constant speed) GC_CASES = ["AGC_WPS", "AGC_noWPS", "PGC_WPS", "PGC_noWPS"] @@ -363,6 +371,56 @@ def _save_figure_outputs( out.with_suffix(".tikz").unlink(missing_ok=True) +def _slugify(value: str) -> str: + """Return a filesystem-safe lowercase suffix for subplot filenames.""" + slug = re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_") + return slug or "panel" + + +def _save_subplot_outputs( + fig: plt.Figure, + out: Path, + axes: list[plt.Axes], + panel_suffixes: list[str], + *, + pad_inches: float = 0.06, + **savefig_kwargs: object, +) -> None: + """Save each axis as an individual cropped PNG file. + + The output names use ``_.png``. + """ + if len(axes) != len(panel_suffixes): + raise ValueError("axes and panel_suffixes must have identical length") + + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + fig_w, fig_h = fig.get_size_inches() + save_kwargs = {"transparent": True, **savefig_kwargs} + save_kwargs.pop("bbox_inches", None) + + for ax, suffix in zip(axes, panel_suffixes, strict=False): + bbox_display = ax.get_tightbbox(renderer) + if bbox_display is None: + continue + + bbox_inches = bbox_display.transformed(fig.dpi_scale_trans.inverted()) + x0 = max(0.0, bbox_inches.x0 - pad_inches) + y0 = max(0.0, bbox_inches.y0 - pad_inches) + x1 = min(fig_w, bbox_inches.x1 + pad_inches) + y1 = min(fig_h, bbox_inches.y1 + pad_inches) + if x1 <= x0 or y1 <= y0: + continue + + panel_bbox = mpl.transforms.Bbox.from_extents(x0, y0, x1, y1) + panel_out = out.with_name(f"{out.stem}_{_slugify(suffix)}{out.suffix}") + fig.savefig( + panel_out.with_suffix(".png"), + bbox_inches=panel_bbox, + **save_kwargs, + ) + + def add_source_note( fig: plt.Figure, note: str = "Source: SWOPP3 2024, IEResearchDatalab" ) -> None: @@ -619,6 +677,12 @@ def _draw_violin(ax, data, pos, color, alpha=0.80): add_source_note(fig) out = paths.figs_dir / "fig01_energy_overview.pdf" + _save_subplot_outputs( + fig, + out, + list(axes), + [_CASE_FILE_SUFFIX[case_id] for case_id in OPT_CASES], + ) _save_figure_outputs(fig, out) print(f" Saved {out.name}") plt.close(fig) @@ -729,6 +793,12 @@ def fig_optimization_gains( add_source_note(fig) out = paths.figs_dir / "fig02_optimization_gains.pdf" + _save_subplot_outputs( + fig, + out, + list(axes), + [route for route, _title, _cases in route_groups], + ) _save_figure_outputs(fig, out) print(f" Saved {out.name}") plt.close(fig) @@ -895,6 +965,12 @@ def fig_penalty_tradeoff( add_source_note(fig) out = paths.figs_dir / "fig03_penalty_tradeoff.pdf" + _save_subplot_outputs( + fig, + out, + list(axes), + ["wind_violations", "wave_violations", "mean_energy"], + ) _save_figure_outputs(fig, out) print(f" Saved {out.name}") plt.close(fig) @@ -1033,6 +1109,13 @@ def _fig_seasonality_panel( add_source_note(fig) out = paths.figs_dir / f"{out_stem}.pdf" + _save_subplot_outputs( + fig, + out, + list(axes.flat), + [_CASE_FILE_SUFFIX[case_id] for case_id, _ in cases_order], + bbox_inches="tight", + ) _save_figure_outputs(fig, out, bbox_inches="tight") print(f" Saved {out.name}") plt.close(fig) @@ -1212,6 +1295,13 @@ def _fig_relative_gain_panel( add_source_note(fig) out = paths.figs_dir / f"{out_stem}.pdf" + _save_subplot_outputs( + fig, + out, + list(axes.flat), + [_CASE_FILE_SUFFIX[case_id] for case_id, _ in cases_order], + bbox_inches="tight", + ) _save_figure_outputs(fig, out, bbox_inches="tight") print(f" Saved {out.name}") plt.close(fig) @@ -1343,6 +1433,12 @@ def fig_wps_impact( add_source_note(fig) out = paths.figs_dir / "fig05_wps_impact.pdf" + _save_subplot_outputs( + fig, + out, + list(axes), + [route for route, _title, _wps_case, _nowps_case in route_groups], + ) _save_figure_outputs(fig, out) print(f" Saved {out.name}") plt.close(fig) @@ -1458,6 +1554,12 @@ def fig_fms_improvement( add_source_note(fig) out = paths.figs_dir / "fig06_fms_improvement.pdf" + _save_subplot_outputs( + fig, + out, + axes, + [f"{base_exp}_vs_{fms_exp}" for base_exp, fms_exp, _title in pairs], + ) _save_figure_outputs(fig, out) print(f" Saved {out.name}") plt.close(fig) @@ -1705,6 +1807,13 @@ def _plot_wrapped_track( add_source_note(fig) fig.tight_layout(rect=[0, 0.05, 1, 0.93]) out = paths.figs_dir / "fig07_route_maps.pdf" + _save_subplot_outputs( + fig, + out, + [ax_atl, ax_pac], + ["atlantic", "pacific"], + bbox_inches="tight", + ) _save_figure_outputs(fig, out, bbox_inches="tight") print(f" Saved {out.name}") plt.close(fig) @@ -1811,6 +1920,16 @@ def fig_risk_calendar( add_source_note(fig) out = paths.figs_dir / "fig08_risk_calendar.pdf" + exp_keys = list(ACTIVE_EXPERIMENTS.keys()) + _save_subplot_outputs( + fig, + out, + list(axes.flat), + [ + *[f"wind_violations_{key}" for key in exp_keys], + *[f"wave_violations_{key}" for key in exp_keys], + ], + ) _save_figure_outputs(fig, out) print(f" Saved {out.name}") plt.close(fig) @@ -1977,6 +2096,12 @@ def fig_fms_delta_byseason( add_source_note(fig) out = paths.figs_dir / "fig09_fms_seasonal_delta.pdf" + _save_subplot_outputs( + fig, + out, + axes, + [f"{base_exp}_vs_{fms_exp}" for base_exp, fms_exp in pairs], + ) _save_figure_outputs(fig, out) print(f" Saved {out.name}") plt.close(fig) @@ -2148,6 +2273,12 @@ def fig_gc_victory_rate( ) add_source_note(fig) out = paths.figs_dir / "fig10_gc_victory_rate.pdf" + _save_subplot_outputs( + fig, + out, + list(axes.flat), + [_CASE_FILE_SUFFIX[case_id] for case_id, _ in cases_order], + ) _save_figure_outputs(fig, out) print(f" Saved {out.name}") plt.close(fig) @@ -2262,6 +2393,12 @@ def fig_gc_margin_heatmap( add_source_note(fig) out = paths.figs_dir / "fig11_gc_margin_heatmap.pdf" + _save_subplot_outputs( + fig, + out, + list(axes.flat), + [_CASE_FILE_SUFFIX[case_id] for case_id, _ in cases_order], + ) _save_figure_outputs(fig, out) print(f" Saved {out.name}") plt.close(fig) @@ -2399,6 +2536,12 @@ def fig_gc_violations( ) add_source_note(fig) out = paths.figs_dir / "fig12_gc_violations.pdf" + _save_subplot_outputs( + fig, + out, + list(axes.flat), + [_CASE_FILE_SUFFIX[case_id] for case_id, _ in cases_order], + ) _save_figure_outputs(fig, out) print(f" Saved {out.name}") plt.close(fig) From 5d065ceb3602557ae56a98d9449ae0e1c891906a Mon Sep 17 00:00:00 2001 From: daniprec Date: Wed, 27 May 2026 18:08:24 +0200 Subject: [PATCH 3/4] prune story --- scripts/swopp3_analysis.py | 1292 +++++++++++++++++++++++--- scripts/swopp3_submission_compare.py | 1067 +++++++++++++++++++++ 2 files changed, 2204 insertions(+), 155 deletions(-) create mode 100755 scripts/swopp3_submission_compare.py diff --git a/scripts/swopp3_analysis.py b/scripts/swopp3_analysis.py index 7cf4856f..4dec698e 100755 --- a/scripts/swopp3_analysis.py +++ b/scripts/swopp3_analysis.py @@ -31,11 +31,12 @@ --output-dir DIR Directory where figures and tables are saved. Default: /output/analysis --figures N [N...] Space-separated list of figure numbers to generate - (1–12). Generates all figures if omitted. + (0–13). Generates all figures if omitted. --dpi DPI Figure resolution in DPI. Default: 180 Outputs ------- + fig00_teaser_routes.pdf / .png fig01_energy_overview.pdf / .png fig02_optimization_gains.pdf / .png fig03_penalty_tradeoff.pdf / .png @@ -87,6 +88,9 @@ Figure descriptions ------------------- + fig00 Compact teaser map (Atlantic + Pacific) with one representative + departure per corridor, overlaying GC (dashed grey), CMA-ES + (orange), and final BERS route (blue). fig01 Violin plots of energy consumption (MWh) per case × experiment, with great-circle baseline markers and median-savings annotations. fig02 Grouped bar chart of median % energy savings vs the great-circle @@ -647,9 +651,14 @@ def _draw_violin(ax, data, pos, color, alpha=0.80): case_meta["label"].replace("\n", " "), fontsize=10, fontweight="bold" ) all_ticks = [gc_pos] + list(exp_positions) - all_labels = ["GC"] + [ - ACTIVE_EXPERIMENTS[k]["short"].replace(" + ", "\n+\n") for k in exp_order - ] + all_labels = ["GC"] + if exp_order == ["sweep_combined", "sweep_combined_fms_strict"]: + all_labels.extend(["CMA-ES", "BERS"]) + else: + all_labels.extend( + ACTIVE_EXPERIMENTS[k]["short"].replace(" + ", "\n+\n") + for k in exp_order + ) ax.set_xticks(all_ticks) ax.set_xticklabels(all_labels, fontsize=7.0) ax.set_ylabel("Energy (MWh)", fontsize=8) @@ -675,6 +684,13 @@ def _draw_violin(ax, data, pos, color, alpha=0.80): fontsize=8.5, ) + # Keep per-case panels directly comparable in both combined and per-panel exports. + all_ylims = [ax.get_ylim() for ax in axes] + ymin_all = min(y[0] for y in all_ylims) + ymax_all = max(y[1] for y in all_ylims) + for ax in axes: + ax.set_ylim(ymin_all, ymax_all) + add_source_note(fig) out = paths.figs_dir / "fig01_energy_overview.pdf" _save_subplot_outputs( @@ -1598,12 +1614,237 @@ def load_gc_tracks( return result -# =========================================================================== -# FIGURE 7 — Route maps -# =========================================================================== -def fig_route_maps(paths: AnalysisPaths = DEFAULT_PATHS) -> None: - """Geographic maps showing representative routes for Atlantic and Pacific.""" - setup_style() +def _load_representative_route_triplet( + case_id: str, + gc_case: str, + base_exp: str, + final_exp: str, + paths: AnalysisPaths, +) -> dict[str, object] | None: + """Return aligned (GC, CMA-ES, final) tracks for one representative departure. + + Chooses the departure with the largest energy drop from base to final among + rows that have all three track files available. + """ + base_summary = load_summary_csv(base_exp, case_id, paths) + final_summary = load_summary_csv(final_exp, case_id, paths) + if base_summary is None or final_summary is None: + return None + + base_folder = _experiment_folder(base_exp, paths) + final_folder = _experiment_folder(final_exp, paths) + gc_path = _summary_csv_path(paths, base_folder, gc_case) + if gc_path is None or not gc_path.exists(): + return None + gc_summary = pd.read_csv(gc_path, parse_dates=["departure_time_utc"]) + + cols = [ + "departure_time_utc", + "details_filename", + "energy_cons_mwh", + "max_wind_mps", + "max_hs_m", + ] + base_cols = base_summary[cols].rename( + columns={ + "details_filename": "base_details", + "energy_cons_mwh": "base_energy", + "max_wind_mps": "base_wind", + "max_hs_m": "base_wave", + } + ) + final_cols = final_summary[cols].rename( + columns={ + "details_filename": "final_details", + "energy_cons_mwh": "final_energy", + "max_wind_mps": "final_wind", + "max_hs_m": "final_wave", + } + ) + gc_cols = gc_summary[cols].rename( + columns={ + "details_filename": "gc_details", + "energy_cons_mwh": "gc_energy", + "max_wind_mps": "gc_wind", + "max_hs_m": "gc_wave", + } + ) + + merged = ( + base_cols.merge(final_cols, on="departure_time_utc", how="inner") + .merge(gc_cols, on="departure_time_utc", how="inner") + .dropna(subset=["base_details", "final_details", "gc_details"]) + ) + if merged.empty: + return None + + merged["delta_mwh"] = merged["base_energy"] - merged["final_energy"] + merged["gain_cma_vs_gc_pct"] = ( + (merged["gc_energy"] - merged["base_energy"]) / merged["gc_energy"] * 100 + ) + merged["gain_bers_vs_gc_pct"] = ( + (merged["gc_energy"] - merged["final_energy"]) / merged["gc_energy"] * 100 + ) + merged = merged.sort_values("delta_mwh", ascending=False) + + base_tracks_dir = paths.output_dir / base_folder / "tracks" + final_tracks_dir = paths.output_dir / final_folder / "tracks" + for _, row in merged.iterrows(): + gc_file = base_tracks_dir / row["gc_details"] + base_file = base_tracks_dir / row["base_details"] + final_file = final_tracks_dir / row["final_details"] + if not (gc_file.exists() and base_file.exists() and final_file.exists()): + continue + + gc_track = pd.read_csv(gc_file, parse_dates=["time_utc"]) + base_track = pd.read_csv(base_file, parse_dates=["time_utc"]) + final_track = pd.read_csv(final_file, parse_dates=["time_utc"]) + return { + "gc_track": gc_track, + "base_track": base_track, + "final_track": final_track, + "delta_mwh": float(row["delta_mwh"]), + "gain_cma_vs_gc_pct": float(row["gain_cma_vs_gc_pct"]), + "gain_bers_vs_gc_pct": float(row["gain_bers_vs_gc_pct"]), + "base_wind": float(row["base_wind"]), + "base_wave": float(row["base_wave"]), + "final_wind": float(row["final_wind"]), + "final_wave": float(row["final_wave"]), + } + return None + + +def _load_best_route_triplets_by_season( + case_id: str, + gc_case: str, + base_exp: str, + final_exp: str, + paths: AnalysisPaths, + n_scenarios: int = 3, +) -> list[dict[str, object]]: + """Return up to ``n_scenarios`` best seasonal route triplets vs GC. + + Produces at most one representative departure per season, ranked by the + final-route gain over GC (highest first). + """ + base_summary = load_summary_csv(base_exp, case_id, paths) + final_summary = load_summary_csv(final_exp, case_id, paths) + if base_summary is None or final_summary is None: + return [] + + base_folder = _experiment_folder(base_exp, paths) + final_folder = _experiment_folder(final_exp, paths) + gc_path = _summary_csv_path(paths, base_folder, gc_case) + if gc_path is None or not gc_path.exists(): + return [] + gc_summary = pd.read_csv(gc_path, parse_dates=["departure_time_utc"]) + + cols = [ + "departure_time_utc", + "details_filename", + "energy_cons_mwh", + "max_wind_mps", + "max_hs_m", + ] + base_cols = base_summary[cols].rename( + columns={ + "details_filename": "base_details", + "energy_cons_mwh": "base_energy", + "max_wind_mps": "base_wind", + "max_hs_m": "base_wave", + } + ) + final_cols = final_summary[cols].rename( + columns={ + "details_filename": "final_details", + "energy_cons_mwh": "final_energy", + "max_wind_mps": "final_wind", + "max_hs_m": "final_wave", + } + ) + gc_cols = gc_summary[cols].rename( + columns={ + "details_filename": "gc_details", + "energy_cons_mwh": "gc_energy", + } + ) + + merged = ( + base_cols.merge(final_cols, on="departure_time_utc", how="inner") + .merge(gc_cols, on="departure_time_utc", how="inner") + .dropna(subset=["base_details", "final_details", "gc_details"]) + ) + if merged.empty: + return [] + + merged["season"] = merged["departure_time_utc"].dt.month.map(_MONTH_TO_SEASON) + merged["gain_cma_vs_gc_pct"] = ( + (merged["gc_energy"] - merged["base_energy"]) / merged["gc_energy"] * 100 + ) + merged["gain_bers_vs_gc_pct"] = ( + (merged["gc_energy"] - merged["final_energy"]) / merged["gc_energy"] * 100 + ) + + base_tracks_dir = paths.output_dir / base_folder / "tracks" + final_tracks_dir = paths.output_dir / final_folder / "tracks" + seasonal_candidates: list[dict[str, object]] = [] + + for season in SEASON_ORDER: + season_rows = merged[merged["season"] == season].sort_values( + "gain_bers_vs_gc_pct", ascending=False + ) + if season_rows.empty: + continue + + for _, row in season_rows.iterrows(): + gc_file = base_tracks_dir / row["gc_details"] + base_file = base_tracks_dir / row["base_details"] + final_file = final_tracks_dir / row["final_details"] + if not (gc_file.exists() and base_file.exists() and final_file.exists()): + continue + + seasonal_candidates.append( + { + "season": season, + "gc_track": pd.read_csv(gc_file, parse_dates=["time_utc"]), + "base_track": pd.read_csv(base_file, parse_dates=["time_utc"]), + "final_track": pd.read_csv( + final_file, + parse_dates=["time_utc"], + ), + "gain_cma_vs_gc_pct": float(row["gain_cma_vs_gc_pct"]), + "gain_bers_vs_gc_pct": float(row["gain_bers_vs_gc_pct"]), + "base_wind": float(row["base_wind"]), + "base_wave": float(row["base_wave"]), + "final_wind": float(row["final_wind"]), + "final_wave": float(row["final_wave"]), + } + ) + break + + seasonal_candidates.sort(key=lambda d: d["gain_bers_vs_gc_pct"], reverse=True) + return seasonal_candidates[:n_scenarios] + + +def _fig_teaser_seasonal_scenarios_for_ocean( + cfg: dict[str, object], + paths: AnalysisPaths, + base_exp: str, + final_exp: str, + n_scenarios: int = 3, +) -> None: + """Save a compact multi-panel teaser with the best seasonal scenarios.""" + scenarios = _load_best_route_triplets_by_season( + case_id=str(cfg["case_id"]), + gc_case=str(cfg["gc_case"]), + base_exp=base_exp, + final_exp=final_exp, + paths=paths, + n_scenarios=n_scenarios, + ) + if not scenarios: + print(f" [!] Missing seasonal scenarios for {cfg['title']}; skipping") + return def _plot_wrapped_track( ax: plt.Axes, @@ -1613,13 +1854,11 @@ def _plot_wrapped_track( central_longitude: float, **plot_kwargs: object, ) -> None: - """Plot a track split at antimeridian crossings to avoid long wrap lines.""" lon = np.asarray(lon_vals, dtype=float) lat = np.asarray(lat_vals, dtype=float) valid = np.isfinite(lon) & np.isfinite(lat) if not valid.any(): return - lon = lon[valid] lat = lat[valid] lon = ((lon - central_longitude + 180.0) % 360.0) - 180.0 + central_longitude @@ -1627,7 +1866,6 @@ def _plot_wrapped_track( split_idx = np.where(np.abs(np.diff(lon)) > 180.0)[0] + 1 lon_segments = np.split(lon, split_idx) lat_segments = np.split(lat, split_idx) - for lon_seg, lat_seg in zip(lon_segments, lat_segments, strict=False): if len(lon_seg) < 2: continue @@ -1638,179 +1876,441 @@ def _plot_wrapped_track( **plot_kwargs, ) - # Cartopy/constrained_layout are incompatible — disable for this figure with mpl.rc_context({"figure.constrained_layout.use": False}): - fig = plt.figure(figsize=(14, 6), facecolor="#FAFAF7") + ncols = len(scenarios) + fig = plt.figure(figsize=(4.4 * ncols, 4.2), facecolor="#FAFAF7") fig.suptitle( - "Optimised routes diverge from great circles to exploit prevailing winds and avoid storms", # noqa: E501 - fontsize=12, + f"{cfg['title']}: best seasonal scenarios vs great-circle", + fontsize=11, fontweight="bold", x=0.02, ha="left", ) - # Atlantic map - ax_atl = fig.add_subplot( - 1, - 2, - 1, - projection=ccrs.PlateCarree(central_longitude=-40), + axes = [] + for i in range(ncols): + ax = fig.add_subplot(1, ncols, i + 1, projection=cfg["projection"]) + axes.append(ax) + ax.set_extent(cfg["extent"], crs=ccrs.PlateCarree()) + ax.add_feature(cfeature.OCEAN, facecolor="#EFF5FF", zorder=0) + ax.add_feature(cfeature.LAND, facecolor="#D9D0C3", zorder=1) + ax.add_feature( + cfeature.COASTLINE, + linewidth=0.45, + edgecolor="#808080", + zorder=2, + ) + ax.gridlines( + draw_labels=False, + linewidth=0.35, + color="#C8C8C8", + x_inline=False, + y_inline=False, + ) + + GC_COLOR = "#7A7A7A" + CMA_COLOR = "#FF8C42" + BERS_COLOR = "#1C5DAA" + + for ax, scenario in zip(axes, scenarios, strict=False): + gc_trk = scenario["gc_track"] + base_trk = scenario["base_track"] + final_trk = scenario["final_track"] + + _plot_wrapped_track( + ax, + gc_trk["lon_deg"].to_numpy(), + gc_trk["lat_deg"].to_numpy(), + central_longitude=float(cfg["central_longitude"]), + color=GC_COLOR, + linewidth=1.1, + linestyle="--", + alpha=0.95, + zorder=3, + ) + _plot_wrapped_track( + ax, + base_trk["lon_deg"].to_numpy(), + base_trk["lat_deg"].to_numpy(), + central_longitude=float(cfg["central_longitude"]), + color=CMA_COLOR, + linewidth=1.4, + alpha=0.9, + zorder=4, + ) + _plot_wrapped_track( + ax, + final_trk["lon_deg"].to_numpy(), + final_trk["lat_deg"].to_numpy(), + central_longitude=float(cfg["central_longitude"]), + color=BERS_COLOR, + linewidth=1.8, + alpha=0.95, + zorder=5, + ) + + ax.text( + 0.02, + 0.02, + ( + f"CMA-ES vs GC: {float(scenario['gain_cma_vs_gc_pct']):+.1f}%\n" + f"BERS vs GC: {float(scenario['gain_bers_vs_gc_pct']):+.1f}%" + ), + transform=ax.transAxes, + fontsize=6.5, + color="#4D4D4D", + ha="left", + va="bottom", + bbox={ + "boxstyle": "round,pad=0.2", + "fc": "white", + "ec": "none", + "alpha": 0.74, + }, + zorder=6, + ) + ax.text( + 0.98, + 0.02, + ( + "Max (CMAES / BERS)\n" + "Wind (m/s): " + f"{float(scenario['base_wind']):.1f}/{float(scenario['final_wind']):.1f}\n" + "Wave Hs (m): " + f"{float(scenario['base_wave']):.1f}/{float(scenario['final_wave']):.1f}" + ), + transform=ax.transAxes, + fontsize=6.0, + color="#4D4D4D", + ha="right", + va="bottom", + bbox={ + "boxstyle": "round,pad=0.2", + "fc": "white", + "ec": "none", + "alpha": 0.74, + }, + zorder=6, + ) + + legend_elements = [ + mlines.Line2D( + [], + [], + color=GC_COLOR, + linestyle="--", + linewidth=1.2, + label="Great-circle", + ), + mlines.Line2D([], [], color=CMA_COLOR, linewidth=1.5, label="CMA-ES"), + mlines.Line2D([], [], color=BERS_COLOR, linewidth=1.9, label="BERS"), + ] + fig.legend( + handles=legend_elements, + loc="lower center", + ncol=3, + bbox_to_anchor=(0.5, -0.02), + fontsize=8, + ) + + add_source_note(fig) + fig.tight_layout(rect=[0, 0.08, 1, 0.9]) + out = paths.figs_dir / f"fig00_{str(cfg['slug'])}_seasonal_scenarios.pdf" + _save_subplot_outputs( + fig, + out, + axes, + [str(s["season"]).lower() for s in scenarios], + bbox_inches="tight", ) - # Pacific map (centred at 180° to avoid antimeridian split) - ax_pac = fig.add_subplot( - 1, - 2, - 2, - projection=ccrs.PlateCarree(central_longitude=180), + _save_figure_outputs(fig, out, bbox_inches="tight") + print(f" Saved {out.name}") + plt.close(fig) + + +def fig_teaser_seasonal_scenarios(paths: AnalysisPaths = DEFAULT_PATHS) -> None: + """Generate 3 best-vs-GC seasonal scenarios for each ocean corridor.""" + setup_style() + base_exp, final_exp = EXPERIMENT_PAIRS[0] + ocean_cfgs = [ + { + "slug": "atlantic", + "title": "Trans-Atlantic", + "projection": ccrs.PlateCarree(central_longitude=-40), + "central_longitude": -40, + "extent": [-80, 15, 25, 65], + "case_id": "AO_WPS", + "gc_case": "AGC_WPS", + }, + { + "slug": "pacific", + "title": "Trans-Pacific", + "projection": ccrs.PlateCarree(central_longitude=180), + "central_longitude": 180, + "extent": [115, 250, 20, 65], + "case_id": "PO_WPS", + "gc_case": "PGC_WPS", + }, + ] + + for cfg in ocean_cfgs: + _fig_teaser_seasonal_scenarios_for_ocean( + cfg=cfg, + paths=paths, + base_exp=base_exp, + final_exp=final_exp, + n_scenarios=3, ) - route_configs = [ - { - "ax": ax_atl, - "title": "Trans-Atlantic (Santander → New York)", - "central_longitude": -40, - "extent": [-80, 15, 25, 65], - "cases": ["AO_WPS", "AO_noWPS"], - "gc_case": "AGC_WPS", - "seasons": ["Winter", "Summer"], - }, - { - "ax": ax_pac, - "title": "Trans-Pacific (Tokyo → Los Angeles)", - "central_longitude": 180, - "extent": [115, 250, 20, 65], - "cases": ["PO_WPS", "PO_noWPS"], - "gc_case": "PGC_WPS", - "seasons": ["Winter", "Summer"], - }, - ] - for cfg in route_configs: - ax = cfg["ax"] +# ========================================================================== +# FIGURE 0 — Teaser route comparison +# ========================================================================== +def fig_teaser_routes(paths: AnalysisPaths = DEFAULT_PATHS) -> None: + """Compact teaser map with one representative Atlantic and Pacific departure.""" + setup_style() + + def _plot_wrapped_track( + ax: plt.Axes, + lon_vals: np.ndarray, + lat_vals: np.ndarray, + *, + central_longitude: float, + **plot_kwargs: object, + ) -> None: + """Plot a track split at antimeridian crossings to avoid wrap artefacts.""" + lon = np.asarray(lon_vals, dtype=float) + lat = np.asarray(lat_vals, dtype=float) + valid = np.isfinite(lon) & np.isfinite(lat) + if not valid.any(): + return + + lon = lon[valid] + lat = lat[valid] + lon = ((lon - central_longitude + 180.0) % 360.0) - 180.0 + central_longitude + + split_idx = np.where(np.abs(np.diff(lon)) > 180.0)[0] + 1 + lon_segments = np.split(lon, split_idx) + lat_segments = np.split(lat, split_idx) + for lon_seg, lat_seg in zip(lon_segments, lat_segments, strict=False): + if len(lon_seg) < 2: + continue + ax.plot( + lon_seg, + lat_seg, + transform=ccrs.PlateCarree(), + **plot_kwargs, + ) + + base_exp, final_exp = EXPERIMENT_PAIRS[0] + teaser_cfgs = [ + { + "title": "Trans-Atlantic", + "projection": ccrs.PlateCarree(central_longitude=-40), + "central_longitude": -40, + "extent": [-80, 15, 25, 65], + "case_id": "AO_WPS", + "gc_case": "AGC_WPS", + }, + { + "title": "Trans-Pacific", + "projection": ccrs.PlateCarree(central_longitude=180), + "central_longitude": 180, + "extent": [115, 250, 20, 65], + "case_id": "PO_WPS", + "gc_case": "PGC_WPS", + }, + ] + + route_triplets: list[dict[str, object]] = [] + for cfg in teaser_cfgs: + triplet = _load_representative_route_triplet( + cfg["case_id"], cfg["gc_case"], base_exp, final_exp, paths + ) + if triplet is None: + print( + f" [!] Missing representative tracks for {cfg['title']}; " + "skipping fig00" + ) + return + route_triplets.append(triplet) + + with mpl.rc_context({"figure.constrained_layout.use": False}): + fig = plt.figure(figsize=(11, 4.5), facecolor="#FAFAF7") + fig.suptitle( + "Two-stage weather routing: from coarse search to refined optimal tracks", + fontsize=11.5, + fontweight="bold", + x=0.02, + ha="left", + ) + + axes = [] + for i, cfg in enumerate(teaser_cfgs, start=1): + ax = fig.add_subplot(1, 2, i, projection=cfg["projection"]) + axes.append(ax) ax.set_extent(cfg["extent"], crs=ccrs.PlateCarree()) - ax.add_feature(cfeature.LAND, facecolor="#D9D0C3", zorder=1) ax.add_feature(cfeature.OCEAN, facecolor="#EFF5FF", zorder=0) + ax.add_feature(cfeature.LAND, facecolor="#D9D0C3", zorder=1) ax.add_feature( - cfeature.COASTLINE, linewidth=0.5, edgecolor="#7D7D7D", zorder=2 - ) - ax.add_feature( - cfeature.BORDERS, linewidth=0.3, edgecolor="#BBBBBB", zorder=2 + cfeature.COASTLINE, + linewidth=0.45, + edgecolor="#808080", + zorder=2, ) - gl = ax.gridlines( - draw_labels=True, - linewidth=0.4, - color="#CCCCCC", + ax.gridlines( + draw_labels=False, + linewidth=0.35, + color="#C8C8C8", x_inline=False, y_inline=False, ) - gl.xlabel_style = {"size": 7} - gl.ylabel_style = {"size": 7} - ax.set_title(cfg["title"], fontsize=10, fontweight="bold", pad=6) - - for exp_key in ACTIVE_EXPERIMENTS: - exp_meta = ACTIVE_EXPERIMENTS[exp_key] - for case_id in cfg["cases"]: - for season in cfg["seasons"]: - tracks = load_tracks( - exp_key, - case_id, - paths, - season_filter=season, - n_sample=4, - ) - alpha = 0.55 if season == "Winter" else 0.35 - for trk in tracks: - _plot_wrapped_track( - ax, - trk["lon_deg"].values, - trk["lat_deg"].values, - central_longitude=cfg["central_longitude"], - color=exp_meta["color"], - linewidth=0.85, - alpha=alpha, - zorder=3, - ) - - # Great-circle reference routes — dashed dark grey - gc_case = cfg.get("gc_case") - if gc_case: - for season in cfg["seasons"]: - alpha_gc = 0.80 if season == "Winter" else 0.45 - for trk in load_gc_tracks( - gc_case, - paths, - season_filter=season, - n_sample=4, - ): - _plot_wrapped_track( - ax, - trk["lon_deg"].values, - trk["lat_deg"].values, - central_longitude=cfg["central_longitude"], - color="#111111", - linewidth=1.5, - linestyle="--", - alpha=alpha_gc, - zorder=5, - ) + ax.set_title(cfg["title"], fontsize=10.5, fontweight="bold", pad=6) + + GC_COLOR = "#7A7A7A" + CMA_COLOR = "#FF8C42" + BERS_COLOR = "#1C5DAA" + + for ax, cfg, route_data in zip(axes, teaser_cfgs, route_triplets, strict=False): + gc_trk = route_data["gc_track"] + base_trk = route_data["base_track"] + final_trk = route_data["final_track"] + gain_cma_vs_gc_pct = route_data["gain_cma_vs_gc_pct"] + gain_bers_vs_gc_pct = route_data["gain_bers_vs_gc_pct"] + base_wind = route_data["base_wind"] + base_wave = route_data["base_wave"] + final_wind = route_data["final_wind"] + final_wave = route_data["final_wave"] + + _plot_wrapped_track( + ax, + gc_trk["lon_deg"].to_numpy(), + gc_trk["lat_deg"].to_numpy(), + central_longitude=cfg["central_longitude"], + color=GC_COLOR, + linewidth=1.3, + linestyle="--", + alpha=0.95, + zorder=3, + ) + _plot_wrapped_track( + ax, + base_trk["lon_deg"].to_numpy(), + base_trk["lat_deg"].to_numpy(), + central_longitude=cfg["central_longitude"], + color=CMA_COLOR, + linewidth=1.7, + alpha=0.9, + zorder=4, + ) + _plot_wrapped_track( + ax, + final_trk["lon_deg"].to_numpy(), + final_trk["lat_deg"].to_numpy(), + central_longitude=cfg["central_longitude"], + color=BERS_COLOR, + linewidth=2.1, + alpha=0.95, + zorder=5, + ) + + ax.text( + 0.02, + 0.03, + ( + f"CMA-ES gain vs GC: {gain_cma_vs_gc_pct:+.1f}%\n" + f"BERS gain vs GC: {gain_bers_vs_gc_pct:+.1f}%" + ), + transform=ax.transAxes, + fontsize=7.0, + color="#555555", + ha="left", + va="bottom", + bbox={ + "boxstyle": "round,pad=0.2", + "fc": "white", + "ec": "none", + "alpha": 0.7, + }, + zorder=6, + ) + ax.text( + 0.98, + 0.03, + ( + "Max met-ocean along route\n" + "Wind speed (m/s): " + f"CMA-ES {base_wind:.1f} | BERS {final_wind:.1f}\n" + "Wave height Hs (m): " + f"CMA-ES {base_wave:.1f} | BERS {final_wave:.1f}" + ), + transform=ax.transAxes, + fontsize=6.6, + color="#4D4D4D", + ha="right", + va="bottom", + bbox={ + "boxstyle": "round,pad=0.2", + "fc": "white", + "ec": "none", + "alpha": 0.75, + }, + zorder=6, + ) + + flow_note = ( + "CMA-ES coarse search \\N{RIGHTWARDS ARROW} " + "FMS refinement \\N{RIGHTWARDS ARROW} final route" + ) + fig.text( + 0.5, + 0.03, + flow_note, + ha="center", + va="center", + fontsize=8.2, + color="#4D4D4D", + bbox={ + "boxstyle": "round,pad=0.3", + "fc": "white", + "ec": "#D4D4D4", + "alpha": 0.85, + }, + ) - # Legend - exp_lines = [ + legend_elements = [ mlines.Line2D( [], [], - color=ACTIVE_EXPERIMENTS[k]["color"], - linewidth=2, - label=ACTIVE_EXPERIMENTS[k]["label"], - ) - for k in ACTIVE_EXPERIMENTS + color=GC_COLOR, + linestyle="--", + linewidth=1.4, + label="Great-circle", + ), + mlines.Line2D([], [], color=CMA_COLOR, linewidth=1.8, label="CMA-ES"), + mlines.Line2D( + [], + [], + color=BERS_COLOR, + linewidth=2.2, + label="BERS (final)", + ), ] - legend_elements = ( - [ - mlines.Line2D( - [], - [], - color="#111111", - linewidth=1.5, - linestyle="--", - label="Great-circle route", - ), - ] - + exp_lines - + [ - mlines.Line2D( - [], - [], - color="#555", - linewidth=2, - alpha=0.75, - label="Winter departures (bold)", - ), - mlines.Line2D( - [], - [], - color="#555", - linewidth=2, - alpha=0.40, - label="Summer departures (faint)", - ), - ] - ) fig.legend( handles=legend_elements, loc="lower center", - ncol=1 + len(ACTIVE_EXPERIMENTS) + 2, - bbox_to_anchor=(0.5, -0.01), - fontsize=8.5, + ncol=3, + bbox_to_anchor=(0.5, -0.035), + fontsize=8.2, ) add_source_note(fig) - fig.tight_layout(rect=[0, 0.05, 1, 0.93]) - out = paths.figs_dir / "fig07_route_maps.pdf" + fig.tight_layout(rect=[0, 0.08, 1, 0.9]) + out = paths.figs_dir / "fig00_teaser_routes.pdf" _save_subplot_outputs( fig, out, - [ax_atl, ax_pac], + axes, ["atlantic", "pacific"], bbox_inches="tight", ) @@ -1818,6 +2318,486 @@ def _plot_wrapped_track( print(f" Saved {out.name}") plt.close(fig) + # Also export seasonal teaser scenarios: 3 best-vs-GC examples per ocean. + fig_teaser_seasonal_scenarios(paths) + + +# =========================================================================== +# FIGURE 7 — Route maps +# =========================================================================== +def fig_route_maps(paths: AnalysisPaths = DEFAULT_PATHS) -> None: + """Geographic maps showing all BERS routes and seasonal mean corridors.""" + setup_style() + + def _wrap_longitudes( + lon_vals: np.ndarray, + central_longitude: float, + ) -> np.ndarray: + """Wrap longitudes to the plotting frame centred on *central_longitude*.""" + lon = np.asarray(lon_vals, dtype=float) + return ((lon - central_longitude + 180.0) % 360.0) - 180.0 + central_longitude + + def _plot_wrapped_track( + ax: plt.Axes, + lon_vals: np.ndarray, + lat_vals: np.ndarray, + *, + central_longitude: float, + **plot_kwargs: object, + ) -> None: + """Plot a track split at antimeridian crossings to avoid long wrap lines.""" + lon = np.asarray(lon_vals, dtype=float) + lat = np.asarray(lat_vals, dtype=float) + valid = np.isfinite(lon) & np.isfinite(lat) + if not valid.any(): + return + + lon = lon[valid] + lat = lat[valid] + lon = _wrap_longitudes(lon, central_longitude) + + split_idx = np.where(np.abs(np.diff(lon)) > 180.0)[0] + 1 + lon_segments = np.split(lon, split_idx) + lat_segments = np.split(lat, split_idx) + + for lon_seg, lat_seg in zip(lon_segments, lat_segments, strict=False): + if len(lon_seg) < 2: + continue + ax.plot( + lon_seg, + lat_seg, + transform=ccrs.PlateCarree(), + **plot_kwargs, + ) + + def _load_all_bers_tracks( + case_id: str, + central_longitude: float, + ) -> list[dict[str, object]]: + """Load all available final-route tracks for one optimised case.""" + final_exp = EXPERIMENT_PAIRS[0][1] + summary = load_summary_csv(final_exp, case_id, paths) + if summary is None: + return [] + + tracks_dir = paths.output_dir / _experiment_folder(final_exp, paths) / "tracks" + routes: list[dict[str, object]] = [] + for _, row in summary.sort_values("departure_time_utc").iterrows(): + fpath = tracks_dir / row["details_filename"] + if not fpath.exists(): + continue + trk = pd.read_csv(fpath, parse_dates=["time_utc"]) + routes.append( + { + "season": row["season"], + "lon": _wrap_longitudes( + trk["lon_deg"].to_numpy(), + central_longitude, + ), + "lat": trk["lat_deg"].to_numpy(dtype=float), + } + ) + return routes + + def _load_best_bers_routes_by_month( + case_id: str, + gc_case: str, + central_longitude: float, + ) -> list[dict[str, object]]: + """Load the best BERS route for each month, ranked by saving vs GC.""" + base_exp, final_exp = EXPERIMENT_PAIRS[0] + summary = load_summary_csv(final_exp, case_id, paths) + if summary is None: + return [] + + gc_path = _summary_csv_path(paths, _experiment_folder(base_exp, paths), gc_case) + if gc_path is None or not gc_path.exists(): + return [] + gc_summary = pd.read_csv(gc_path, parse_dates=["departure_time_utc"]) + + merged = summary.merge( + gc_summary[["departure_time_utc", "energy_cons_mwh"]].rename( + columns={"energy_cons_mwh": "gc_energy_cons_mwh"} + ), + on="departure_time_utc", + how="inner", + ) + if merged.empty: + return [] + + merged["gain_bers_vs_gc_pct"] = ( + (merged["gc_energy_cons_mwh"] - merged["energy_cons_mwh"]) + / merged["gc_energy_cons_mwh"] + * 100 + ) + + tracks_dir = paths.output_dir / _experiment_folder(final_exp, paths) / "tracks" + routes: list[dict[str, object]] = [] + for month in range(1, 13): + month_rows = merged[merged["month"] == month].sort_values( + "gain_bers_vs_gc_pct", + ascending=False, + ) + if month_rows.empty: + continue + row = month_rows.iloc[0] + fpath = tracks_dir / row["details_filename"] + if not fpath.exists(): + continue + trk = pd.read_csv(fpath, parse_dates=["time_utc"]) + routes.append( + { + "month": int(month), + "season": row["season"], + "gain_bers_vs_gc_pct": float(row["gain_bers_vs_gc_pct"]), + "lon": _wrap_longitudes( + trk["lon_deg"].to_numpy(), + central_longitude, + ), + "lat": trk["lat_deg"].to_numpy(dtype=float), + } + ) + return routes + + def _load_gc_reference_track( + gc_case: str, + central_longitude: float, + ) -> tuple[np.ndarray, np.ndarray] | None: + """Load one great-circle track to use as the route baseline.""" + base_exp = EXPERIMENT_PAIRS[0][0] + summary_path = _summary_csv_path( + paths, + _experiment_folder(base_exp, paths), + gc_case, + ) + if summary_path is None or not summary_path.exists(): + return None + + summary = pd.read_csv(summary_path, parse_dates=["departure_time_utc"]) + if summary.empty: + return None + + tracks_dir = paths.output_dir / _experiment_folder(base_exp, paths) / "tracks" + details_name = summary.sort_values("departure_time_utc").iloc[0][ + "details_filename" + ] + fpath = tracks_dir / details_name + if not fpath.exists(): + return None + + trk = pd.read_csv(fpath, parse_dates=["time_utc"]) + return ( + _wrap_longitudes(trk["lon_deg"].to_numpy(), central_longitude), + trk["lat_deg"].to_numpy(dtype=float), + ) + + def _resample_track( + lon_vals: np.ndarray, + lat_vals: np.ndarray, + n_points: int = 240, + ) -> tuple[np.ndarray, np.ndarray]: + """Resample one route to a fixed number of points along cumulative length.""" + lon = np.asarray(lon_vals, dtype=float) + lat = np.asarray(lat_vals, dtype=float) + valid = np.isfinite(lon) & np.isfinite(lat) + lon = lon[valid] + lat = lat[valid] + if len(lon) == 0: + return np.array([]), np.array([]) + if len(lon) == 1: + return np.full(n_points, lon[0]), np.full(n_points, lat[0]) + + seg = np.hypot(np.diff(lon), np.diff(lat)) + dist = np.concatenate([[0.0], np.cumsum(seg)]) + if dist[-1] <= 0: + return np.full(n_points, lon[0]), np.full(n_points, lat[0]) + + target = np.linspace(0.0, dist[-1], n_points) + return np.interp(target, dist, lon), np.interp(target, dist, lat) + + def _seasonal_route_stats( + routes: list[dict[str, object]], + season: str, + n_points: int = 240, + ) -> dict[str, object] | None: + """Return mean route and percentile envelope for one season.""" + seasonal = [route for route in routes if route["season"] == season] + if not seasonal: + return None + + lon_stack = [] + lat_stack = [] + for route in seasonal: + lon_res, lat_res = _resample_track(route["lon"], route["lat"], n_points) + if len(lon_res) == 0: + continue + lon_stack.append(lon_res) + lat_stack.append(lat_res) + if not lon_stack: + return None + + lon_arr = np.vstack(lon_stack) + lat_arr = np.vstack(lat_stack) + return { + "season": season, + "lon_mean": lon_arr.mean(axis=0), + "lat_mean": lat_arr.mean(axis=0), + "lat_p10": np.percentile(lat_arr, 10, axis=0), + "lat_p90": np.percentile(lat_arr, 90, axis=0), + "count": len(lon_stack), + } + + def _setup_map(ax: plt.Axes, cfg: dict[str, object]) -> None: + """Apply shared cartographic styling.""" + ax.set_extent(cfg["extent"], crs=ccrs.PlateCarree()) + ax.add_feature(cfeature.LAND, facecolor="#D9D0C3", zorder=1) + ax.add_feature(cfeature.OCEAN, facecolor="#EFF5FF", zorder=0) + ax.add_feature(cfeature.COASTLINE, linewidth=0.5, edgecolor="#7D7D7D", zorder=2) + ax.add_feature(cfeature.BORDERS, linewidth=0.3, edgecolor="#BBBBBB", zorder=2) + gl = ax.gridlines( + draw_labels=True, + linewidth=0.4, + color="#CCCCCC", + x_inline=False, + y_inline=False, + ) + gl.xlabel_style = {"size": 7} + gl.ylabel_style = {"size": 7} + ax.set_title(cfg["title"], fontsize=10, fontweight="bold", pad=6) + + route_configs = [ + { + "title": "Trans-Atlantic (Santander → New York)", + "case_id": "AO_WPS", + "gc_case": "AGC_WPS", + "central_longitude": -40.0, + "extent": [-80, 15, 25, 65], + "projection": ccrs.PlateCarree(central_longitude=-40), + }, + { + "title": "Trans-Pacific (Tokyo → Los Angeles)", + "case_id": "PO_WPS", + "gc_case": "PGC_WPS", + "central_longitude": 180.0, + "extent": [115, 250, 20, 65], + "projection": ccrs.PlateCarree(central_longitude=180), + }, + ] + season_colors = { + "Winter": "#1C5DAA", + "Spring": "#6DC201", + "Summer": "#F23333", + "Autumn": "#FF8C42", + } + + route_data = { + cfg["case_id"]: _load_all_bers_tracks( + str(cfg["case_id"]), + float(cfg["central_longitude"]), + ) + for cfg in route_configs + } + + # Cartopy/constrained_layout are incompatible — disable for this figure + with mpl.rc_context({"figure.constrained_layout.use": False}): + fig_all = plt.figure(figsize=(14, 6), facecolor="#FAFAF7") + fig_all.suptitle( + "Best BERS route of each month, coloured by departure season", + fontsize=12, + fontweight="bold", + x=0.02, + ha="left", + ) + axes_all = [ + fig_all.add_subplot(1, 2, 1, projection=route_configs[0]["projection"]), + fig_all.add_subplot(1, 2, 2, projection=route_configs[1]["projection"]), + ] + + for ax, cfg in zip(axes_all, route_configs, strict=False): + _setup_map(ax, cfg) + gc_track = _load_gc_reference_track( + str(cfg["gc_case"]), + float(cfg["central_longitude"]), + ) + if gc_track is not None: + _plot_wrapped_track( + ax, + gc_track[0], + gc_track[1], + central_longitude=float(cfg["central_longitude"]), + color="#555555", + linewidth=1.6, + linestyle="--", + alpha=0.95, + zorder=2, + ) + routes = _load_best_bers_routes_by_month( + str(cfg["case_id"]), + str(cfg["gc_case"]), + float(cfg["central_longitude"]), + ) + for route in routes: + season = str(route["season"]) + _plot_wrapped_track( + ax, + route["lon"], + route["lat"], + central_longitude=float(cfg["central_longitude"]), + color=season_colors[season], + linewidth=1.2, + linestyle="-", + alpha=0.9, + zorder=3, + ) + ax.text( + 0.02, + 0.02, + f"{len(routes)} monthly best routes", + transform=ax.transAxes, + fontsize=7.5, + color="#4D4D4D", + ha="left", + va="bottom", + bbox={ + "boxstyle": "round,pad=0.2", + "fc": "white", + "ec": "none", + "alpha": 0.72, + }, + ) + + season_handles = [ + mlines.Line2D( + [], + [], + color="#555555", + linewidth=1.6, + linestyle="--", + label="Great-circle", + ), + ] + [ + mlines.Line2D([], [], color=season_colors[s], linewidth=2, label=s) + for s in SEASON_ORDER + ] + fig_all.legend( + handles=season_handles, + loc="lower center", + ncol=5, + bbox_to_anchor=(0.5, -0.01), + fontsize=8.5, + ) + add_source_note(fig_all) + fig_all.tight_layout(rect=[0, 0.05, 1, 0.93]) + out_all = paths.figs_dir / "fig07a_bers_routes_monthly_best.pdf" + _save_subplot_outputs( + fig_all, + out_all, + axes_all, + ["atlantic", "pacific"], + bbox_inches="tight", + ) + _save_figure_outputs(fig_all, out_all, bbox_inches="tight") + print(f" Saved {out_all.name}") + plt.close(fig_all) + + fig_avg = plt.figure(figsize=(14, 6), facecolor="#FAFAF7") + fig_avg.suptitle( + "Seasonal mean BERS routes with 10–90 percentile corridor", + fontsize=12, + fontweight="bold", + x=0.02, + ha="left", + ) + axes_avg = [ + fig_avg.add_subplot(1, 2, 1, projection=route_configs[0]["projection"]), + fig_avg.add_subplot(1, 2, 2, projection=route_configs[1]["projection"]), + ] + + for ax, cfg in zip(axes_avg, route_configs, strict=False): + _setup_map(ax, cfg) + gc_track = _load_gc_reference_track( + str(cfg["gc_case"]), + float(cfg["central_longitude"]), + ) + if gc_track is not None: + _plot_wrapped_track( + ax, + gc_track[0], + gc_track[1], + central_longitude=float(cfg["central_longitude"]), + color="#555555", + linewidth=1.6, + linestyle="--", + alpha=0.95, + zorder=2, + ) + routes = route_data[str(cfg["case_id"])] + for season in SEASON_ORDER: + stats = _seasonal_route_stats(routes, season) + if stats is None: + continue + ax.fill_between( + stats["lon_mean"], + stats["lat_p10"], + stats["lat_p90"], + transform=ccrs.PlateCarree(), + color=season_colors[season], + alpha=0.14, + zorder=3, + ) + _plot_wrapped_track( + ax, + stats["lon_mean"], + stats["lat_mean"], + central_longitude=float(cfg["central_longitude"]), + color=season_colors[season], + linewidth=2.2, + linestyle="-", + alpha=0.95, + zorder=4, + ) + + mean_handles = [ + mlines.Line2D( + [], + [], + color="#555555", + linewidth=1.6, + linestyle="--", + label="Great-circle", + ), + ] + [ + mlines.Line2D([], [], color=season_colors[s], linewidth=2.2, label=s) + for s in SEASON_ORDER + ] + mean_handles.append( + mpatches.Patch( + facecolor="#888888", + alpha=0.14, + label="10-90 percentile band", + ) + ) + fig_avg.legend( + handles=mean_handles, + loc="lower center", + ncol=5, + bbox_to_anchor=(0.5, -0.01), + fontsize=8.5, + ) + add_source_note(fig_avg) + fig_avg.tight_layout(rect=[0, 0.05, 1, 0.93]) + out_avg = paths.figs_dir / "fig07b_bers_routes_average.pdf" + _save_subplot_outputs( + fig_avg, + out_avg, + axes_avg, + ["atlantic", "pacific"], + bbox_inches="tight", + ) + _save_figure_outputs(fig_avg, out_avg, bbox_inches="tight") + print(f" Saved {out_avg.name}") + plt.close(fig_avg) + # =========================================================================== # FIGURE 8 — Risk calendar (heatmap of violation rate) @@ -2582,7 +3562,7 @@ def parse_args() -> argparse.Namespace: type=int, metavar="N", default=None, - help="Figure numbers to generate, e.g. --figures 1 5 10. Generates all if omitted.", # noqa: E501 + help="Figure numbers to generate, e.g. --figures 0 1 5 10. Generates all if omitted.", # noqa: E501 ) return p.parse_args() @@ -2628,6 +3608,8 @@ def _want(n: int) -> bool: ) print("\nGenerating figures…") + if _want(0): + fig_teaser_routes(paths) if _want(1): fig_energy_overview(df, gc_baselines, gc_full, paths) if _want(2): diff --git a/scripts/swopp3_submission_compare.py b/scripts/swopp3_submission_compare.py new file mode 100755 index 00000000..b7d8f23f --- /dev/null +++ b/scripts/swopp3_submission_compare.py @@ -0,0 +1,1067 @@ +#!/usr/bin/env python +"""Compare SWOPP3 submissions and generate analysis outputs. + +This script scans a root folder containing one subfolder per submission, +validates each candidate against SWOPP3 output structure requirements, and +generates the comparison outputs requested in the SWOPP3 review comments: + +1. Consumption-over-departures line charts (per corridor/config). +2. Spread comparison between participants (per corridor/config). +3. Spread comparison between months (per corridor/config). +4. Hourly animation for one selected departure and case. + +Expected submission structure +----------------------------- +Each valid submission folder must contain: + +- Summary CSV files for the four optimised cases: + ``AO_WPS``, ``AO_noWPS``, ``PO_WPS``, ``PO_noWPS``. +- A ``tracks/`` subdirectory. +- Every summary row must reference an existing track CSV inside ``tracks/``. +- Each summary CSV must have 366 departures for year 2024. + +Usage +----- + uv run scripts/swopp3_submission_compare.py \ + --input-root output \ + --output-dir output/analysis/submission_compare +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +import matplotlib.animation as animation +import matplotlib.dates as mdates +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import xarray as xr +from PIL import Image, ImageSequence + +from routetools.swopp3 import SWOPP3_CASES +from routetools.violations import find_team_prefix + +REQUIRED_CASES = ["AO_WPS", "AO_noWPS", "PO_WPS", "PO_noWPS"] + +CASE_LABELS = { + "AO_WPS": "Atlantic / WPS", + "AO_noWPS": "Atlantic / no WPS", + "PO_WPS": "Pacific / WPS", + "PO_noWPS": "Pacific / no WPS", +} + +SUMMARY_COLUMNS = { + "departure_time_utc", + "arrival_time_utc", + "energy_cons_mwh", + "max_wind_mps", + "max_hs_m", + "sailed_distance_nm", + "details_filename", +} + +MONTH_NAMES = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] + +SPREAD_SAMPLE_COUNT = 10 +ANIMATION_BASE_FPS = 8 +ANIMATION_SPEEDUP = 4 +ANIMATION_FPS = ANIMATION_BASE_FPS * ANIMATION_SPEEDUP + + +@dataclass(frozen=True) +class SubmissionData: + """Parsed and validated SWOPP3 submission.""" + + name: str + path: Path + team_prefix: str + tracks_dir: Path + summaries: dict[str, pd.DataFrame] + + +@dataclass(frozen=True) +class CandidateIssue: + """Validation issue found while scanning submission folders.""" + + folder: str + reason: str + + +def _save_figure(fig: plt.Figure, path: Path, dpi: int) -> None: + """Save a figure as PDF and PNG.""" + path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(path, dpi=dpi, bbox_inches="tight") + fig.savefig(path.with_suffix(".png"), dpi=dpi, bbox_inches="tight") + + +def _gif_to_mp4(gif_path: Path, mp4_path: Path, fps: int) -> None: + """Convert GIF to MP4 using OpenCV. + + This fallback is used when ffmpeg is unavailable in the environment. + """ + try: + import cv2 + except Exception as exc: # noqa: BLE001 + raise RuntimeError( + "MP4 export requires ffmpeg or opencv-python for GIF->MP4 fallback" + ) from exc + + with Image.open(gif_path) as im: + frames = [frame.convert("RGB") for frame in ImageSequence.Iterator(im)] + + if not frames: + raise RuntimeError(f"No frames found in GIF: {gif_path}") + + first = np.array(frames[0]) + height, width, _ = first.shape + fourcc = cv2.VideoWriter_fourcc(*"mp4v") + writer = cv2.VideoWriter(str(mp4_path), fourcc, float(fps), (width, height)) + + if not writer.isOpened(): + raise RuntimeError(f"Could not open MP4 writer for {mp4_path}") + + try: + for frame in frames: + rgb = np.array(frame) + bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) + writer.write(bgr) + finally: + writer.release() + + +def _read_summary_csv(path: Path) -> pd.DataFrame: + """Read and normalize one SWOPP3 summary CSV.""" + df = pd.read_csv(path) + missing = SUMMARY_COLUMNS.difference(df.columns) + if missing: + raise ValueError(f"Missing columns {sorted(missing)} in {path.name}") + + df = df.copy() + df["departure_time_utc"] = pd.to_datetime(df["departure_time_utc"], utc=True) + df["arrival_time_utc"] = pd.to_datetime(df["arrival_time_utc"], utc=True) + df["energy_cons_mwh"] = pd.to_numeric(df["energy_cons_mwh"], errors="coerce") + if df["energy_cons_mwh"].isna().any(): + raise ValueError(f"Invalid numeric energy values in {path.name}") + + return df.sort_values("departure_time_utc").reset_index(drop=True) + + +def _is_2024_departure_series(series: pd.Series) -> bool: + """Return whether all datetimes in the series belong to year 2024.""" + return bool((series.dt.year == 2024).all()) + + +def scan_submissions( + root: Path, + *, + required_cases: list[str], + expected_departures: int, +) -> tuple[list[SubmissionData], list[CandidateIssue]]: + """Scan root folder and return valid submissions and rejected candidates.""" + submissions: list[SubmissionData] = [] + issues: list[CandidateIssue] = [] + + if not root.exists(): + raise FileNotFoundError(f"Input root does not exist: {root}") + + for candidate in sorted(p for p in root.iterdir() if p.is_dir()): + try: + tracks_dir = candidate / "tracks" + if not tracks_dir.exists() or not tracks_dir.is_dir(): + raise ValueError("missing tracks/ directory") + + team_prefix = find_team_prefix(candidate) + summaries: dict[str, pd.DataFrame] = {} + + for case in required_cases: + summary_path = candidate / f"{team_prefix}-{case}.csv" + if not summary_path.exists(): + raise ValueError(f"missing summary file for case {case}") + + summary = _read_summary_csv(summary_path) + if len(summary) != expected_departures: + raise ValueError( + f"case {case} has {len(summary)} rows, expected " + f"{expected_departures}" + ) + if summary["departure_time_utc"].nunique() != expected_departures: + raise ValueError(f"case {case} has duplicated departures") + if not _is_2024_departure_series(summary["departure_time_utc"]): + raise ValueError(f"case {case} includes non-2024 departures") + + missing_tracks = [ + fname + for fname in summary["details_filename"].astype(str) + if not (tracks_dir / fname).exists() + ] + if missing_tracks: + raise ValueError( + f"case {case} references missing track files " + f"(example: {missing_tracks[0]})" + ) + + summaries[case] = summary + + submissions.append( + SubmissionData( + name=candidate.name, + path=candidate, + team_prefix=team_prefix, + tracks_dir=tracks_dir, + summaries=summaries, + ) + ) + + except Exception as exc: # noqa: BLE001 + issues.append(CandidateIssue(folder=candidate.name, reason=str(exc))) + + return submissions, issues + + +def _read_track(path: Path) -> pd.DataFrame: + """Read a SWOPP3 track CSV and return time-sorted data.""" + track = pd.read_csv(path) + required = {"time_utc", "lat_deg", "lon_deg"} + if not required.issubset(track.columns): + raise ValueError(f"Track file {path.name} missing required columns") + + track = track.copy() + track["time_utc"] = pd.to_datetime(track["time_utc"], utc=True) + track["lat_deg"] = pd.to_numeric(track["lat_deg"], errors="coerce") + track["lon_deg"] = pd.to_numeric(track["lon_deg"], errors="coerce") + track = track.dropna(subset=["time_utc", "lat_deg", "lon_deg"]) + + return track.sort_values("time_utc").reset_index(drop=True) + + +def _interp_track_lonlat( + track: pd.DataFrame, + elapsed_hours: float, +) -> tuple[float, float]: + """Interpolate track position at a given elapsed hour.""" + if track.empty: + raise ValueError("Cannot interpolate empty track") + + t0 = track["time_utc"].iloc[0] + elapsed = (track["time_utc"] - t0).dt.total_seconds().to_numpy() / 3600.0 + lon = track["lon_deg"].to_numpy(dtype=float) + lat = track["lat_deg"].to_numpy(dtype=float) + + elapsed_target = float(np.clip(elapsed_hours, elapsed.min(), elapsed.max())) + lon_i = float(np.interp(elapsed_target, elapsed, lon)) + lat_i = float(np.interp(elapsed_target, elapsed, lat)) + return lon_i, lat_i + + +def _sample_waypoints_for_case( + submission: SubmissionData, + case: str, + sample_hours: np.ndarray, +) -> tuple[pd.DatetimeIndex, np.ndarray]: + """Sample all departures for one case at selected elapsed hours. + + Returns + ------- + tuple[pd.DatetimeIndex, np.ndarray] + Sorted departures and an array with shape ``(D, S, 2)`` in + ``(lon, lat)`` order. + """ + summary = ( + submission.summaries[case] + .sort_values("departure_time_utc") + .reset_index(drop=True) + ) + departures = pd.DatetimeIndex(summary["departure_time_utc"]) + sampled = np.empty((len(summary), len(sample_hours), 2), dtype=float) + + for dep_idx, row in summary.iterrows(): + track = _read_track(submission.tracks_dir / str(row["details_filename"])) + for sample_idx, elapsed_h in enumerate(sample_hours): + lon_i, lat_i = _interp_track_lonlat(track, elapsed_h) + sampled[dep_idx, sample_idx, 0] = lon_i + sampled[dep_idx, sample_idx, 1] = lat_i + + return departures, sampled + + +def _mean_pairwise_haversine_km(points_lonlat: np.ndarray) -> float: + """Mean pairwise haversine distance in km for ``(N, 2)`` lon/lat points.""" + n_points = int(points_lonlat.shape[0]) + if n_points < 2: + return 0.0 + + lon = np.radians(points_lonlat[:, 0][:, None]) + lat = np.radians(points_lonlat[:, 1][:, None]) + dlon = lon - lon.T + dlat = lat - lat.T + + a = np.sin(dlat / 2.0) ** 2 + np.cos(lat) * np.cos(lat.T) * np.sin(dlon / 2.0) ** 2 + c = 2.0 * np.arcsin(np.minimum(1.0, np.sqrt(a))) + dist_km = 6371.0 * c + + tri = np.triu_indices(n_points, k=1) + return float(np.mean(dist_km[tri])) + + +def plot_consumption( + submissions: list[SubmissionData], + *, + out_dir: Path, + dpi: int, +) -> None: + """Plot non-penalized consumption time series for each corridor/config.""" + for case in REQUIRED_CASES: + fig, ax = plt.subplots(figsize=(12, 5)) + for submission in submissions: + summary = submission.summaries[case].sort_values("departure_time_utc") + ax.plot( + summary["departure_time_utc"], + summary["energy_cons_mwh"], + linewidth=1.4, + alpha=0.9, + label=submission.name, + ) + + ax.set_title(f"Consumption Across 2024 Departures - {CASE_LABELS[case]}") + ax.set_xlabel("Departure date") + ax.set_ylabel("Energy consumption (MWh)") + ax.grid(alpha=0.3) + ax.legend(loc="best", ncols=2, fontsize=9) + ax.xaxis.set_major_locator(mdates.MonthLocator(interval=1)) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%b")) + + out = out_dir / f"consumption_{case}.pdf" + _save_figure(fig, out, dpi=dpi) + plt.close(fig) + + +def plot_participant_spread( + submissions: list[SubmissionData], + *, + case: str, + sampled_cache: dict[tuple[str, str], tuple[pd.DatetimeIndex, np.ndarray]], + sample_hours: np.ndarray, + out_dir: Path, + dpi: int, +) -> None: + """Plot spread vs time for each participant and case.""" + fig, ax = plt.subplots(figsize=(10, 5)) + + for submission in submissions: + _, points = sampled_cache[(submission.name, case)] + spreads = [ + _mean_pairwise_haversine_km(points[:, sample_idx, :]) + for sample_idx in range(points.shape[1]) + ] + ax.plot(sample_hours, spreads, marker="o", label=submission.name) + + ax.set_title(f"Participant Spread Comparison - {CASE_LABELS[case]}") + ax.set_xlabel("Elapsed time (hours)") + ax.set_ylabel("Mean pairwise waypoint distance (km)") + ax.grid(alpha=0.3) + ax.legend(loc="best", ncols=2, fontsize=9) + + out = out_dir / f"spread_participants_{case}.pdf" + _save_figure(fig, out, dpi=dpi) + plt.close(fig) + + +def plot_month_spread( + submissions: list[SubmissionData], + *, + case: str, + sampled_cache: dict[tuple[str, str], tuple[pd.DatetimeIndex, np.ndarray]], + sample_hours: np.ndarray, + out_dir: Path, + dpi: int, +) -> None: + """Plot cross-participant spread grouped by month for each case.""" + per_submission: dict[str, tuple[pd.DatetimeIndex, np.ndarray]] = { + sub.name: sampled_cache[(sub.name, case)] for sub in submissions + } + + common_dates = set(per_submission[submissions[0].name][0]) + for sub in submissions[1:]: + common_dates &= set(per_submission[sub.name][0]) + aligned_dates = sorted(common_dates) + + if not aligned_dates: + return + + date_to_row = { + sub.name: {dt: idx for idx, dt in enumerate(per_submission[sub.name][0])} + for sub in submissions + } + + by_month: dict[int, list[np.ndarray]] = {month: [] for month in range(1, 13)} + for date in aligned_dates: + month = date.month + curve = np.zeros(len(sample_hours), dtype=float) + + for sample_idx in range(len(sample_hours)): + points = np.array( + [ + per_submission[sub.name][1][ + date_to_row[sub.name][date], + sample_idx, + :, + ] + for sub in submissions + ], + dtype=float, + ) + curve[sample_idx] = _mean_pairwise_haversine_km(points) + + by_month[month].append(curve) + + fig, ax = plt.subplots(figsize=(10, 5)) + for month in range(1, 13): + if not by_month[month]: + continue + mean_curve = np.mean(np.vstack(by_month[month]), axis=0) + ax.plot(sample_hours, mean_curve, marker="o", label=MONTH_NAMES[month - 1]) + + ax.set_title(f"Month Spread Comparison - {CASE_LABELS[case]}") + ax.set_xlabel("Elapsed time (hours)") + ax.set_ylabel("Mean cross-participant distance (km)") + ax.grid(alpha=0.3) + ax.legend(loc="best", ncols=4, fontsize=8) + + out = out_dir / f"spread_months_{case}.pdf" + _save_figure(fig, out, dpi=dpi) + plt.close(fig) + + +def _pick_data_var(dataset: xr.Dataset, candidates: list[str]) -> str: + """Return the first available variable in dataset from a candidate list.""" + for candidate in candidates: + if candidate in dataset.data_vars: + return candidate + raise KeyError( + f"None of variables {candidates} found in dataset vars " + f"{list(dataset.data_vars)}" + ) + + +def _pick_coord(dataset: xr.Dataset, candidates: list[str]) -> str: + """Return first available coordinate/dimension name.""" + for candidate in candidates: + if candidate in dataset.coords or candidate in dataset.dims: + return candidate + raise KeyError( + f"None of coordinates {candidates} found in dataset coords " + f"{list(dataset.coords)}" + ) + + +def _normalize_longitude(ds: xr.Dataset, lon_name: str) -> xr.Dataset: + """Normalize longitude axis to [-180, 180) when needed.""" + lon = ds[lon_name] + lon_vals = lon.values + if np.nanmax(lon_vals) > 180.0: + lon_shift = ((lon + 180.0) % 360.0) - 180.0 + ds = ds.assign_coords({lon_name: lon_shift}).sortby(lon_name) + return ds + + +def _slice_2d_region( + ds: xr.Dataset, + *, + lat_name: str, + lon_name: str, + lat_min: float, + lat_max: float, + lon_min: float, + lon_max: float, +) -> xr.Dataset: + """Slice a dataset to a latitude/longitude box, handling axis ordering.""" + lat = ds[lat_name] + lon = ds[lon_name] + + lat_slice = ( + slice(lat_min, lat_max) + if float(lat.values[0]) <= float(lat.values[-1]) + else slice(lat_max, lat_min) + ) + lon_slice = ( + slice(lon_min, lon_max) + if float(lon.values[0]) <= float(lon.values[-1]) + else slice(lon_max, lon_min) + ) + return ds.sel({lat_name: lat_slice, lon_name: lon_slice}) + + +def _track_state_at_hour( + track: pd.DataFrame, + hour: float, +) -> tuple[float, float, float, float]: + """Interpolate vessel state at an hour: lon, lat, dlon_dt, dlat_dt.""" + if track.empty: + raise ValueError("Cannot sample empty track") + + t0 = track["time_utc"].iloc[0] + elapsed = (track["time_utc"] - t0).dt.total_seconds().to_numpy() / 3600.0 + lon = track["lon_deg"].to_numpy(dtype=float) + lat = track["lat_deg"].to_numpy(dtype=float) + + hour = float(np.clip(hour, elapsed.min(), elapsed.max())) + lon_i = float(np.interp(hour, elapsed, lon)) + lat_i = float(np.interp(hour, elapsed, lat)) + + delta = 0.5 + h0 = float(np.clip(hour - delta, elapsed.min(), elapsed.max())) + h1 = float(np.clip(hour + delta, elapsed.min(), elapsed.max())) + lon0 = float(np.interp(h0, elapsed, lon)) + lat0 = float(np.interp(h0, elapsed, lat)) + lon1 = float(np.interp(h1, elapsed, lon)) + lat1 = float(np.interp(h1, elapsed, lat)) + + return lon_i, lat_i, lon1 - lon0, lat1 - lat0 + + +def animate_departure( + submissions: list[SubmissionData], + *, + case: str, + departure: datetime, + output_path: Path, + wave_path: Path, + wind_path: Path, + dpi: int, +) -> None: + """Generate the requested hourly animation for one case/departure.""" + departure_utc = pd.Timestamp(departure, tz="UTC") + case_hours = int(SWOPP3_CASES[case]["passage_hours"]) + frame_hours = np.arange(0, case_hours + 1, 1, dtype=int) + + tracks_by_submission: dict[str, pd.DataFrame] = {} + total_energy: dict[str, float] = {} + for sub in submissions: + summary = sub.summaries[case] + row = summary.loc[summary["departure_time_utc"] == departure_utc] + if row.empty: + continue + details = str(row.iloc[0]["details_filename"]) + tracks_by_submission[sub.name] = _read_track(sub.tracks_dir / details) + total_energy[sub.name] = float(row.iloc[0]["energy_cons_mwh"]) + + if not tracks_by_submission: + raise ValueError( + "No submissions contain departure " + f"{departure_utc.isoformat()} for case {case}" + ) + + all_lons = np.concatenate( + [ + track["lon_deg"].to_numpy(dtype=float) + for track in tracks_by_submission.values() + ] + ) + all_lats = np.concatenate( + [ + track["lat_deg"].to_numpy(dtype=float) + for track in tracks_by_submission.values() + ] + ) + lon_min = float(np.min(all_lons) - 5.0) + lon_max = float(np.max(all_lons) + 5.0) + lat_min = float(np.min(all_lats) - 5.0) + lat_max = float(np.max(all_lats) + 5.0) + + wind_ds = xr.open_dataset(wind_path) + wave_ds = xr.open_dataset(wave_path) + try: + wind_u = _pick_data_var(wind_ds, ["u10", "10m_u_component_of_wind", "U10"]) + wind_v = _pick_data_var(wind_ds, ["v10", "10m_v_component_of_wind", "V10"]) + wave_h = _pick_data_var( + wave_ds, + [ + "swh", + "significant_height_of_combined_wind_waves_and_swell", + "hs", + "Hs", + ], + ) + + wind_time = _pick_coord(wind_ds, ["valid_time", "time"]) + wind_lat = _pick_coord(wind_ds, ["latitude", "lat"]) + wind_lon = _pick_coord(wind_ds, ["longitude", "lon"]) + + wave_time = _pick_coord(wave_ds, ["valid_time", "time"]) + wave_lat = _pick_coord(wave_ds, ["latitude", "lat"]) + wave_lon = _pick_coord(wave_ds, ["longitude", "lon"]) + + wind_ds = _normalize_longitude(wind_ds, wind_lon) + wave_ds = _normalize_longitude(wave_ds, wave_lon) + + wind_region = _slice_2d_region( + wind_ds, + lat_name=wind_lat, + lon_name=wind_lon, + lat_min=lat_min, + lat_max=lat_max, + lon_min=lon_min, + lon_max=lon_max, + ) + wave_region = _slice_2d_region( + wave_ds, + lat_name=wave_lat, + lon_name=wave_lon, + lat_min=lat_min, + lat_max=lat_max, + lon_min=lon_min, + lon_max=lon_max, + ) + + fig = plt.figure(figsize=(14, 8)) + grid = fig.add_gridspec(2, 1, height_ratios=[5.8, 1.2], hspace=0.06) + ax_map = fig.add_subplot(grid[0, 0]) + ax_legend = fig.add_subplot(grid[1, 0]) + ax_legend.axis("off") + + ax_map.set_xlim(lon_min, lon_max) + ax_map.set_ylim(lat_min, lat_max) + ax_map.set_facecolor("#0d2538") + ax_map.set_xlabel("Longitude (deg)") + ax_map.set_ylabel("Latitude (deg)") + ax_map.grid(alpha=0.25, color="#9cb4c3", linestyle="--", linewidth=0.5) + + sub_names = list(tracks_by_submission) + colors = plt.cm.tab10(np.linspace(0, 1, len(sub_names))) + line_handles: dict[str, plt.Line2D] = {} + vessel_handles: dict[str, plt.Line2D] = {} + vessel_shadow_handles: dict[str, plt.Line2D] = {} + for idx, name in enumerate(sub_names): + (line,) = ax_map.plot( + [], + [], + color=colors[idx], + linewidth=2.0, + alpha=0.92, + solid_capstyle="round", + label=name, + ) + line_handles[name] = line + (shadow,) = ax_map.plot( + [], + [], + linestyle="None", + marker=(3, 0, 0), + markersize=13, + markerfacecolor="black", + markeredgecolor="none", + alpha=0.35, + zorder=8, + ) + vessel_shadow_handles[name] = shadow + (vessel,) = ax_map.plot( + [], + [], + linestyle="None", + marker=(3, 0, 0), + markersize=10, + markerfacecolor=colors[idx], + markeredgecolor="white", + markeredgewidth=1.2, + zorder=9, + ) + vessel_handles[name] = vessel + + wave_mesh = None + wind_quiver = None + info_text = ax_legend.text( + 0.01, + 0.95, + "", + va="top", + ha="left", + fontsize=10.5, + family="monospace", + bbox={ + "boxstyle": "round,pad=0.4", + "facecolor": "#f4f7fb", + "edgecolor": "#d0d7de", + "alpha": 0.9, + }, + ) + + best_name = min(total_energy, key=total_energy.get) + + def _frame(abs_hour: int): + nonlocal wave_mesh, wind_quiver + current_time = departure_utc + pd.Timedelta(hours=int(abs_hour)) + current_time_naive = current_time.tz_localize(None) + + wave_slice = wave_region.sel( + {wave_time: current_time_naive}, + method="nearest", + ) + wind_slice = wind_region.sel( + {wind_time: current_time_naive}, + method="nearest", + ) + + lon_w = wave_slice[wave_lon].values + lat_w = wave_slice[wave_lat].values + hs = wave_slice[wave_h].values + hs = np.nan_to_num(hs, nan=0.0) + + if wave_mesh is not None: + wave_mesh.remove() + wave_mesh = ax_map.pcolormesh( + lon_w, + lat_w, + hs, + shading="auto", + cmap="viridis", + alpha=0.55, + vmin=0.0, + vmax=max(1.0, float(np.nanpercentile(hs, 95))), + ) + + lon_c = wind_slice[wind_lon].values + lat_c = wind_slice[wind_lat].values + u = wind_slice[wind_u].values + v = wind_slice[wind_v].values + + step_lat = max(1, int(len(lat_c) / 20)) + step_lon = max(1, int(len(lon_c) / 25)) + lon_q = lon_c[::step_lon] + lat_q = lat_c[::step_lat] + u_q = u[::step_lat, ::step_lon] + v_q = v[::step_lat, ::step_lon] + lon_qq, lat_qq = np.meshgrid(lon_q, lat_q) + + if wind_quiver is not None: + wind_quiver.remove() + wind_quiver = ax_map.quiver( + lon_qq, + lat_qq, + u_q, + v_q, + color="white", + alpha=0.8, + width=0.0018, + scale=350, + ) + + ranking_rows: list[tuple[str, float, float]] = [] + for name in sub_names: + track = tracks_by_submission[name] + lon_i, lat_i, dlon, dlat = _track_state_at_hour(track, abs_hour) + + t0 = track["time_utc"].iloc[0] + elapsed = ( + track["time_utc"] - t0 + ).dt.total_seconds().to_numpy() / 3600.0 + cutoff = np.searchsorted(elapsed, abs_hour, side="right") + trail = track.iloc[: max(cutoff, 2)] + line_handles[name].set_data( + trail["lon_deg"].to_numpy(dtype=float), + trail["lat_deg"].to_numpy(dtype=float), + ) + + heading_deg = float(np.degrees(np.arctan2(dlat, dlon))) + marker_style = (3, 0, heading_deg - 90.0) + vessel_shadow_handles[name].set_marker(marker_style) + vessel_handles[name].set_marker(marker_style) + vessel_shadow_handles[name].set_data([lon_i], [lat_i]) + vessel_handles[name].set_data([lon_i], [lat_i]) + + cumulative = total_energy[name] * min(abs_hour / case_hours, 1.0) + ranking_rows.append((name, cumulative, total_energy[name])) + + ranking_rows.sort(key=lambda row: row[1]) + top_five = ranking_rows[:5] + lines = [ + "Live Fuel Leaderboard (Top 5)", + "Rank Participant Cum(MWh) Total(MWh)", + "---- -------------------------- -------- ----------", + ] + for rank, (name, cumulative, total) in enumerate(top_five, start=1): + lines.append( + f"{rank:>4} {name:<26} {cumulative:8.2f} {total:10.2f}" + ) + + if abs_hour >= case_hours: + lines.append("") + lines.append( + f"WINNER: {best_name} ({total_energy[best_name]:.2f} MWh total)" + ) + ax_map.set_title( + f"{CASE_LABELS[case]} - {departure_utc.date()} - " + f"Final (winner: {best_name})" + ) + else: + ax_map.set_title( + f"{CASE_LABELS[case]} - {departure_utc.date()} - +{abs_hour:03d}h" + ) + + info_text.set_text("\n".join(lines)) + + return [ + *line_handles.values(), + *vessel_shadow_handles.values(), + *vessel_handles.values(), + info_text, + ] + + anim = animation.FuncAnimation( + fig, + _frame, + frames=frame_hours, + interval=15, + blit=False, + repeat=False, + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + fps = ANIMATION_FPS + mp4_path = output_path.with_suffix(".mp4") + gif_path = output_path.with_suffix(".gif") + + # Always save GIF. + gif_writer = animation.PillowWriter(fps=fps) + anim.save(gif_path, writer=gif_writer, dpi=dpi) + + # Always save MP4 (ffmpeg preferred, OpenCV fallback). + if animation.writers.is_available("ffmpeg"): + mp4_writer = animation.FFMpegWriter(fps=fps) + anim.save(mp4_path, writer=mp4_writer, dpi=dpi) + else: + _gif_to_mp4(gif_path, mp4_path, fps=fps) + + plt.close(fig) + finally: + wind_ds.close() + wave_ds.close() + + +def _corridor_from_case(case: str) -> str: + """Return corridor identifier from case id.""" + if case.startswith("AO") or case.startswith("AGC"): + return "atlantic" + if case.startswith("PO") or case.startswith("PGC"): + return "pacific" + raise ValueError(f"Unknown SWOPP3 case: {case}") + + +def _parse_departure_argument(value: str) -> datetime: + """Parse departure input and default date-only values to 12:00 UTC.""" + text = value.strip() + formats = [ + "%Y-%m-%d", + "%Y-%m-%dT%H:%M", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M", + "%Y-%m-%d %H:%M:%S", + ] + for fmt in formats: + try: + parsed = datetime.strptime(text, fmt) + if fmt == "%Y-%m-%d": + return parsed.replace(hour=12, minute=0, second=0) + return parsed + except ValueError: + continue + raise ValueError( + "Invalid --animation-departure format. Use YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS]" + ) + + +def build_parser() -> argparse.ArgumentParser: + """Build CLI parser.""" + parser = argparse.ArgumentParser( + description="SWOPP3 submission comparison plots and animation" + ) + parser.add_argument( + "--input-root", + type=Path, + default=Path("output"), + help="Folder containing one subfolder per submission.", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("output/analysis/submission_compare"), + help="Directory where figures and animation are written.", + ) + parser.add_argument( + "--expected-departures", + type=int, + default=366, + help="Expected number of departures per case.", + ) + parser.add_argument( + "--sample-count", + type=int, + default=10, + help=( + "Deprecated. Spread metrics always use 10 evenly-spaced times " + "across the full passage duration." + ), + ) + parser.add_argument( + "--dpi", + type=int, + default=180, + help="Figure and animation DPI.", + ) + parser.add_argument( + "--animation-cases", + nargs="+", + choices=REQUIRED_CASES, + default=REQUIRED_CASES, + help=( + "Case list used for animation generation. " + "Defaults to all four optimized cases." + ), + ) + parser.add_argument( + "--animation-departure", + type=str, + default="2024-01-01", + help="Departure date used for animation (YYYY-MM-DD).", + ) + parser.add_argument( + "--skip-animation", + action="store_true", + help="Skip animation generation.", + ) + parser.add_argument( + "--wind-path-atlantic", + type=Path, + default=Path("data/era5/era5_wind_atlantic_2024.nc"), + help="Atlantic ERA5 wind dataset used for animation.", + ) + parser.add_argument( + "--wave-path-atlantic", + type=Path, + default=Path("data/era5/era5_waves_atlantic_2024.nc"), + help="Atlantic ERA5 wave dataset used for animation.", + ) + parser.add_argument( + "--wind-path-pacific", + type=Path, + default=Path("data/era5/era5_wind_pacific_2024.nc"), + help="Pacific ERA5 wind dataset used for animation.", + ) + parser.add_argument( + "--wave-path-pacific", + type=Path, + default=Path("data/era5/era5_waves_pacific_2024.nc"), + help="Pacific ERA5 wave dataset used for animation.", + ) + return parser + + +def main() -> None: + """CLI entrypoint.""" + parser = build_parser() + args = parser.parse_args() + + submissions, issues = scan_submissions( + args.input_root, + required_cases=REQUIRED_CASES, + expected_departures=args.expected_departures, + ) + + print(f"Scanned {args.input_root}") + print(f"Valid submissions: {len(submissions)}") + if submissions: + for sub in submissions: + print(f" - {sub.name}") + print(f"Rejected folders: {len(issues)}") + if issues: + for issue in issues: + print(f" - {issue.folder}: {issue.reason}") + + if len(submissions) < 2: + raise RuntimeError( + "At least two valid submissions are required for comparison plots." + ) + + args.output_dir.mkdir(parents=True, exist_ok=True) + + sample_hours_by_case: dict[str, np.ndarray] = {} + sampled_cache: dict[tuple[str, str], tuple[pd.DatetimeIndex, np.ndarray]] = {} + if args.sample_count != SPREAD_SAMPLE_COUNT: + print( + "Ignoring --sample-count=" + f"{args.sample_count}; using fixed " + f"{SPREAD_SAMPLE_COUNT} samples for spread plots." + ) + for case in REQUIRED_CASES: + case_hours = float(SWOPP3_CASES[case]["passage_hours"]) + sample_hours = np.linspace(0.0, case_hours, SPREAD_SAMPLE_COUNT) + sample_hours_by_case[case] = sample_hours + for sub in submissions: + sampled_cache[(sub.name, case)] = _sample_waypoints_for_case( + sub, case, sample_hours + ) + + plot_consumption(submissions, out_dir=args.output_dir, dpi=args.dpi) + + for case in REQUIRED_CASES: + plot_participant_spread( + submissions, + case=case, + sampled_cache=sampled_cache, + sample_hours=sample_hours_by_case[case], + out_dir=args.output_dir, + dpi=args.dpi, + ) + + for case in REQUIRED_CASES: + plot_month_spread( + submissions, + case=case, + sampled_cache=sampled_cache, + sample_hours=sample_hours_by_case[case], + out_dir=args.output_dir, + dpi=args.dpi, + ) + + if not args.skip_animation: + departure = _parse_departure_argument(args.animation_departure) + for animation_case in args.animation_cases: + corridor = _corridor_from_case(animation_case) + if corridor == "atlantic": + wind_path = args.wind_path_atlantic + wave_path = args.wave_path_atlantic + else: + wind_path = args.wind_path_pacific + wave_path = args.wave_path_pacific + + print(f"Rendering animation for {animation_case}...") + animate_departure( + submissions, + case=animation_case, + departure=departure, + output_path=args.output_dir + / f"animation_{animation_case}_{args.animation_departure}", + wave_path=wave_path, + wind_path=wind_path, + dpi=args.dpi, + ) + + +if __name__ == "__main__": + main() From 2fdebe7386ccc87b1090169bac9d083a13a0e449 Mon Sep 17 00:00:00 2001 From: daniprec Date: Wed, 27 May 2026 18:32:48 +0200 Subject: [PATCH 4/4] Support zipped SWOPP3 submissions and participant name parsing --- scripts/swopp3_submission_compare.py | 226 ++++++++++++++++++--------- 1 file changed, 148 insertions(+), 78 deletions(-) diff --git a/scripts/swopp3_submission_compare.py b/scripts/swopp3_submission_compare.py index b7d8f23f..75c89f56 100755 --- a/scripts/swopp3_submission_compare.py +++ b/scripts/swopp3_submission_compare.py @@ -30,6 +30,9 @@ from __future__ import annotations import argparse +import re +import tempfile +import zipfile from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -168,11 +171,66 @@ def _is_2024_departure_series(series: pd.Series) -> bool: return bool((series.dt.year == 2024).all()) +def _participant_name_from_submission_id(raw_name: str) -> str: + """Convert official submission id to a participant display name. + + Expected pattern resembles ``XXXXXX_participant_name_PhaseId...``. + Falls back to the original stem when no match is found. + """ + stem = Path(raw_name).stem + match = re.match(r"^\d+_(.+?)_PhaseId.*$", stem, flags=re.IGNORECASE) + participant = match.group(1) if match else stem + return participant.replace("_", " ").strip() or stem + + +def _find_submission_root(candidate_root: Path) -> Path: + """Return the folder that directly contains submission CSVs and tracks/.""" + if (candidate_root / "tracks").is_dir(): + return candidate_root + + track_dirs = [p for p in candidate_root.rglob("tracks") if p.is_dir()] + roots = sorted({p.parent for p in track_dirs}) + if not roots: + raise ValueError("missing tracks/ directory") + if len(roots) > 1: + raise ValueError("multiple candidate submission folders found after extraction") + return roots[0] + + +def _discover_submission_candidates( + root: Path, + extraction_root: Path, +) -> list[tuple[str, str, Path]]: + """Discover folder/zip submission candidates. + + Returns tuples ``(display_name, source_label, submission_path)``. + """ + candidates: list[tuple[str, str, Path]] = [] + + for entry in sorted(root.iterdir()): + if entry.is_dir(): + display_name = _participant_name_from_submission_id(entry.name) + candidates.append((display_name, entry.name, entry)) + continue + + if entry.is_file() and entry.suffix.lower() == ".zip": + extracted_dir = extraction_root / entry.stem + extracted_dir.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(entry) as zf: + zf.extractall(extracted_dir) + submission_path = _find_submission_root(extracted_dir) + display_name = _participant_name_from_submission_id(entry.stem) + candidates.append((display_name, entry.name, submission_path)) + + return candidates + + def scan_submissions( root: Path, *, required_cases: list[str], expected_departures: int, + extraction_root: Path, ) -> tuple[list[SubmissionData], list[CandidateIssue]]: """Scan root folder and return valid submissions and rejected candidates.""" submissions: list[SubmissionData] = [] @@ -181,12 +239,21 @@ def scan_submissions( if not root.exists(): raise FileNotFoundError(f"Input root does not exist: {root}") - for candidate in sorted(p for p in root.iterdir() if p.is_dir()): + used_names: dict[str, int] = {} + discovered = _discover_submission_candidates(root, extraction_root) + + for display_name, source_label, candidate in discovered: try: tracks_dir = candidate / "tracks" if not tracks_dir.exists() or not tracks_dir.is_dir(): raise ValueError("missing tracks/ directory") + # Ensure legend/cache keys remain unique when two archives share names. + occurrence = used_names.get(display_name, 0) + 1 + used_names[display_name] = occurrence + if occurrence > 1: + display_name = f"{display_name} ({occurrence})" + team_prefix = find_team_prefix(candidate) summaries: dict[str, pd.DataFrame] = {} @@ -221,7 +288,7 @@ def scan_submissions( submissions.append( SubmissionData( - name=candidate.name, + name=display_name, path=candidate, team_prefix=team_prefix, tracks_dir=tracks_dir, @@ -230,7 +297,7 @@ def scan_submissions( ) except Exception as exc: # noqa: BLE001 - issues.append(CandidateIssue(folder=candidate.name, reason=str(exc))) + issues.append(CandidateIssue(folder=source_label, reason=str(exc))) return submissions, issues @@ -894,8 +961,8 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--input-root", type=Path, - default=Path("output"), - help="Folder containing one subfolder per submission.", + default=Path("output/swopp3_submissions"), + help="Folder containing participant submission zip files or directories.", ) parser.add_argument( "--output-dir", @@ -977,91 +1044,94 @@ def main() -> None: parser = build_parser() args = parser.parse_args() - submissions, issues = scan_submissions( - args.input_root, - required_cases=REQUIRED_CASES, - expected_departures=args.expected_departures, - ) - - print(f"Scanned {args.input_root}") - print(f"Valid submissions: {len(submissions)}") - if submissions: - for sub in submissions: - print(f" - {sub.name}") - print(f"Rejected folders: {len(issues)}") - if issues: - for issue in issues: - print(f" - {issue.folder}: {issue.reason}") - - if len(submissions) < 2: - raise RuntimeError( - "At least two valid submissions are required for comparison plots." + with tempfile.TemporaryDirectory(prefix="swopp3_submissions_") as tmpdir: + extraction_root = Path(tmpdir) + submissions, issues = scan_submissions( + args.input_root, + required_cases=REQUIRED_CASES, + expected_departures=args.expected_departures, + extraction_root=extraction_root, ) - args.output_dir.mkdir(parents=True, exist_ok=True) - - sample_hours_by_case: dict[str, np.ndarray] = {} - sampled_cache: dict[tuple[str, str], tuple[pd.DatetimeIndex, np.ndarray]] = {} - if args.sample_count != SPREAD_SAMPLE_COUNT: - print( - "Ignoring --sample-count=" - f"{args.sample_count}; using fixed " - f"{SPREAD_SAMPLE_COUNT} samples for spread plots." - ) - for case in REQUIRED_CASES: - case_hours = float(SWOPP3_CASES[case]["passage_hours"]) - sample_hours = np.linspace(0.0, case_hours, SPREAD_SAMPLE_COUNT) - sample_hours_by_case[case] = sample_hours - for sub in submissions: - sampled_cache[(sub.name, case)] = _sample_waypoints_for_case( - sub, case, sample_hours + print(f"Scanned {args.input_root}") + print(f"Valid submissions: {len(submissions)}") + if submissions: + for sub in submissions: + print(f" - {sub.name}") + print(f"Rejected folders: {len(issues)}") + if issues: + for issue in issues: + print(f" - {issue.folder}: {issue.reason}") + + if len(submissions) < 2: + raise RuntimeError( + "At least two valid submissions are required for comparison plots." ) - plot_consumption(submissions, out_dir=args.output_dir, dpi=args.dpi) + args.output_dir.mkdir(parents=True, exist_ok=True) - for case in REQUIRED_CASES: - plot_participant_spread( - submissions, - case=case, - sampled_cache=sampled_cache, - sample_hours=sample_hours_by_case[case], - out_dir=args.output_dir, - dpi=args.dpi, - ) + sample_hours_by_case: dict[str, np.ndarray] = {} + sampled_cache: dict[tuple[str, str], tuple[pd.DatetimeIndex, np.ndarray]] = {} + if args.sample_count != SPREAD_SAMPLE_COUNT: + print( + "Ignoring --sample-count=" + f"{args.sample_count}; using fixed " + f"{SPREAD_SAMPLE_COUNT} samples for spread plots." + ) + for case in REQUIRED_CASES: + case_hours = float(SWOPP3_CASES[case]["passage_hours"]) + sample_hours = np.linspace(0.0, case_hours, SPREAD_SAMPLE_COUNT) + sample_hours_by_case[case] = sample_hours + for sub in submissions: + sampled_cache[(sub.name, case)] = _sample_waypoints_for_case( + sub, case, sample_hours + ) - for case in REQUIRED_CASES: - plot_month_spread( - submissions, - case=case, - sampled_cache=sampled_cache, - sample_hours=sample_hours_by_case[case], - out_dir=args.output_dir, - dpi=args.dpi, - ) + plot_consumption(submissions, out_dir=args.output_dir, dpi=args.dpi) - if not args.skip_animation: - departure = _parse_departure_argument(args.animation_departure) - for animation_case in args.animation_cases: - corridor = _corridor_from_case(animation_case) - if corridor == "atlantic": - wind_path = args.wind_path_atlantic - wave_path = args.wave_path_atlantic - else: - wind_path = args.wind_path_pacific - wave_path = args.wave_path_pacific + for case in REQUIRED_CASES: + plot_participant_spread( + submissions, + case=case, + sampled_cache=sampled_cache, + sample_hours=sample_hours_by_case[case], + out_dir=args.output_dir, + dpi=args.dpi, + ) - print(f"Rendering animation for {animation_case}...") - animate_departure( + for case in REQUIRED_CASES: + plot_month_spread( submissions, - case=animation_case, - departure=departure, - output_path=args.output_dir - / f"animation_{animation_case}_{args.animation_departure}", - wave_path=wave_path, - wind_path=wind_path, + case=case, + sampled_cache=sampled_cache, + sample_hours=sample_hours_by_case[case], + out_dir=args.output_dir, dpi=args.dpi, ) + if not args.skip_animation: + departure = _parse_departure_argument(args.animation_departure) + for animation_case in args.animation_cases: + corridor = _corridor_from_case(animation_case) + if corridor == "atlantic": + wind_path = args.wind_path_atlantic + wave_path = args.wave_path_atlantic + else: + wind_path = args.wind_path_pacific + wave_path = args.wave_path_pacific + + print(f"Rendering animation for {animation_case}...") + animate_departure( + submissions, + case=animation_case, + departure=departure, + output_path=args.output_dir + / f"animation_{animation_case}_{args.animation_departure}", + wave_path=wave_path, + wind_path=wind_path, + dpi=args.dpi, + ) + if __name__ == "__main__": main()