Skip to content

Commit 8c45162

Browse files
committed
feat(swopp3): add CMA-ES diagnostics hook and stabilize FMS travel-time routing
1 parent 7294e65 commit 8c45162

19 files changed

Lines changed: 4311 additions & 487 deletions

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,6 @@ Write and reason in English.
4040
- 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.
4141
- You can add, commit and push changes to this repository. Never commit to 'main' or 'swopp' branches directly.
4242
- If you are on 'main' or 'swopp', create a new branch for your changes and open a pull request for review.
43-
- Create tests before implementing new features or fixing bugs. Tests should be in the `tests/` directory and follow existing patterns.
43+
- Create tests before implementing new features or fixing bugs in `routetools` (you can ignore other directories such as `scripts` or `docs`). Tests should be in the `tests/` directory and follow existing patterns.
4444
- Make sure to run all tests and hooks before pushing your changes. If you encounter any issues, please fix them before pushing.
4545
- Do small commits, preferably one per logical change. This makes it easier to review and understand the history of changes.

docs/swopp3_sweep_results.md

Lines changed: 52 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# SWOPP3 Parameter Sweep Results
22

3-
**Date:** 2026-03-19
4-
**Branch:** `feat/swopp3-final-results`
5-
**Route:** Pacific noWPS (no waypoint system)
3+
**Date:** 2026-03-19
4+
**Branch:** `feat/swopp3-final-results`
5+
**Route:** Pacific noWPS (no waypoint system)
66
**Operational constraints:** TWS ≤ 20 m/s, Hs ≤ 7.0 m
77

88
---
@@ -19,10 +19,10 @@ where $x_i$ is TWS or Hs at segment midpoint $i$.
1919

2020
Two parameters control the penalty magnitude:
2121

22-
| Parameter | Symbol | Role |
23-
|-----------|--------|------|
24-
| `weather_penalty_weight` (wpw) | outer multiplier | **Global scale** — multiplies the entire penalty term before it is added to the energy cost. Controls how much the penalty matters *relative to fuel cost*. |
25-
| `sharpness` | inner multiplier | **Violation sensitivity** — multiplies each individual squared excess *before* summation. Controls how steeply the penalty ramps up *per segment* as conditions worsen beyond the threshold. |
22+
| Parameter | Symbol | Role |
23+
| ------------------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24+
| `weather_penalty_weight` (wpw) | outer multiplier | **Global scale** — multiplies the entire penalty term before it is added to the energy cost. Controls how much the penalty matters _relative to fuel cost_. |
25+
| `sharpness` | inner multiplier | **Violation sensitivity** — multiplies each individual squared excess _before_ summation. Controls how steeply the penalty ramps up _per segment_ as conditions worsen beyond the threshold. |
2626

2727
They are mathematically interchangeable in a single-field scenario
2828
($\texttt{wpw} \cdot \texttt{sharpness}$ acts as one effective multiplier),
@@ -31,6 +31,7 @@ scales each field's excess identically (same ramp steepness for TWS and Hs),
3131
while `wpw` scales the combined total against the fuel cost.
3232

3333
**In practice for this sweep:**
34+
3435
- Higher **wpw** → optimizer sees weather avoidance as more important than fuel savings → routes detour more.
3536
- Higher **sharpness** → small exceedances above the limit are punished more aggressively → optimizer reacts earlier to marginal violations.
3637

@@ -42,38 +43,39 @@ while `wpw` scales the combined total against the fuel cost.
4243
over-spend (+30 to +99%) seen on stormy Pacific crossings.
4344

4445
**Setup:**
46+
4547
- 10 departures with worst delta%: [24, 337, 338, 339, 340, 341, 348, 349, 362, 364]
4648
- Grid: `wpw ∈ {5, 10, 20, 40, 100}` × `sharpness ∈ {1, 2, 5}` × `σ₀ ∈ {0.1, 0.2}`
4749
- 30 configs × 10 departures = **300 runs** completed in 622 s (~2 s/run)
4850

4951
### Summary by Configuration
5052

51-
| wpw | sharp | σ₀ | mean Δ% | #viol TWS | #viol Hs | #any viol | mean TWS | mean Hs |
52-
|----:|------:|---:|--------:|----------:|---------:|----------:|---------:|--------:|
53-
| 5 | 1 | 0.2 | **-5.0** | 5 | 10 | 10 | 19.5 | 7.92 |
54-
| 5 | 2 | 0.2 | -3.0 | 5 | 9 | 9 | 19.4 | 7.72 |
55-
| 10 | 1 | 0.2 | -3.0 | 3 | 9 | 9 | 19.4 | 7.72 |
56-
| 5 | 1 | 0.1 | -2.6 | 5 | 9 | 9 | 19.9 | 8.11 |
57-
| 10 | 1 | 0.1 | 0.0 | 5 | 8 | 9 | 19.7 | 7.66 |
58-
| 5 | 2 | 0.1 | 0.3 | 4 | 8 | 8 | 19.7 | 7.52 |
59-
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
60-
| 100 | 5 | 0.1 | **+24.6** | 1 | 7 | 7 | 19.0 | 7.22 |
61-
| 100 | 2 | 0.1 | +25.4 | 2 | 7 | 7 | 18.7 | 7.24 |
53+
| wpw | sharp | σ₀ | mean Δ% | #viol TWS | #viol Hs | #any viol | mean TWS | mean Hs |
54+
| --: | ----: | --: | --------: | --------: | -------: | --------: | -------: | ------: |
55+
| 5 | 1 | 0.2 | **-5.0** | 5 | 10 | 10 | 19.5 | 7.92 |
56+
| 5 | 2 | 0.2 | -3.0 | 5 | 9 | 9 | 19.4 | 7.72 |
57+
| 10 | 1 | 0.2 | -3.0 | 3 | 9 | 9 | 19.4 | 7.72 |
58+
| 5 | 1 | 0.1 | -2.6 | 5 | 9 | 9 | 19.9 | 8.11 |
59+
| 10 | 1 | 0.1 | 0.0 | 5 | 8 | 9 | 19.7 | 7.66 |
60+
| 5 | 2 | 0.1 | 0.3 | 4 | 8 | 8 | 19.7 | 7.52 |
61+
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
62+
| 100 | 5 | 0.1 | **+24.6** | 1 | 7 | 7 | 19.0 | 7.22 |
63+
| 100 | 2 | 0.1 | +25.4 | 2 | 7 | 7 | 18.7 | 7.24 |
6264

6365
### Best Config vs Baseline — Per Departure
6466

6567
| Dep | Baseline (wpw=100,s=5,σ=0.1) Δ% | Baseline viol | Best (wpw=5,s=1,σ=0.2) Δ% | Best viol |
66-
|----:|---:|---|---:|---|
67-
| 24 | +28.2 | Hs | -21.6 | Hs |
68-
| 337 | +18.2 | TWS+Hs | -5.7 | TWS+Hs |
69-
| 338 | +34.9 | Hs | +4.6 | Hs |
70-
| 339 | +35.2 | Hs | +12.0 | TWS+Hs |
71-
| 340 | +98.7 | Hs | +10.6 | TWS+Hs |
72-
| 341 | +66.2 | Hs | +16.9 | Hs |
73-
| 348 | +18.1 | **none** | +7.0 | Hs |
74-
| 349 | +8.2 | Hs | +5.5 | Hs |
75-
| 362 | -30.1 | none | -38.7 | TWS+Hs |
76-
| 364 | -31.5 | none | -40.2 | TWS+Hs |
68+
| --: | ------------------------------: | ------------- | ------------------------: | --------- |
69+
| 24 | +28.2 | Hs | -21.6 | Hs |
70+
| 337 | +18.2 | TWS+Hs | -5.7 | TWS+Hs |
71+
| 338 | +34.9 | Hs | +4.6 | Hs |
72+
| 339 | +35.2 | Hs | +12.0 | TWS+Hs |
73+
| 340 | +98.7 | Hs | +10.6 | TWS+Hs |
74+
| 341 | +66.2 | Hs | +16.9 | Hs |
75+
| 348 | +18.1 | **none** | +7.0 | Hs |
76+
| 349 | +8.2 | Hs | +5.5 | Hs |
77+
| 362 | -30.1 | none | -38.7 | TWS+Hs |
78+
| 364 | -31.5 | none | -40.2 | TWS+Hs |
7779

7880
### Key Observations
7981

@@ -106,25 +108,26 @@ over-spend (+30 to +99%) seen on stormy Pacific crossings.
106108
departures currently converging exactly to the great circle (0% delta).
107109

108110
**Setup:**
111+
109112
- 20 sampled zero-delta departures: [44, 45, 46, 111, 113, 116, 118, 125,
110113
143, 159, 160, 169, 174, 177, 185, 186, 232, 233, 239, 274]
111114
- Grid: `σ₀ ∈ {0.1, 0.3, 0.5}` × `popsize ∈ {200, 400}` × `maxfevals ∈ {25k, 50k}` × `K ∈ {10, 15}`
112115
- 24 configs × 20 departures = **480 runs** completed in 1294 s (~2.7 s/run)
113116

114117
### Summary by Configuration
115118

116-
| σ₀ | pop | maxfevals | K | mean Δ% | median Δ% | min Δ% | #any viol |
117-
|---:|----:|----------:|--:|--------:|----------:|-------:|----------:|
118-
| 0.3 | 200 | 50000 | 15 | **-0.7** | 0.0 | -7.6 | 0 |
119-
| 0.5 | 200 | 50000 | 15 | -0.7 | 0.0 | -7.4 | 0 |
120-
| 0.3 | 400 | 50000 | 15 | -0.7 | 0.0 | -7.4 | 0 |
121-
| 0.5 | 400 | 50000 | 15 | -0.7 | 0.0 | -7.4 | 0 |
122-
| 0.3 | 200 | 25000 | 15 | -0.7 | 0.0 | -7.5 | 0 |
123-
| 0.5 | 200 | 25000 | 15 | -0.7 | 0.0 | -7.3 | 0 |
124-
| 0.1 | 200 | 25000 | 15 | -0.4 | 0.0 | -7.6 | 0 |
125-
| ... | ... | ... | ... | ... | ... | ... | ... |
126-
| 0.1 | 200 | 25000 | 10 | **0.0** | 0.0 | 0.0 | 0 |
127-
| 0.1 | 400 | 25000 | 10 | 0.0 | 0.0 | 0.0 | 0 |
119+
| σ₀ | pop | maxfevals | K | mean Δ% | median Δ% | min Δ% | #any viol |
120+
| --: | --: | --------: | --: | -------: | --------: | -----: | --------: |
121+
| 0.3 | 200 | 50000 | 15 | **-0.7** | 0.0 | -7.6 | 0 |
122+
| 0.5 | 200 | 50000 | 15 | -0.7 | 0.0 | -7.4 | 0 |
123+
| 0.3 | 400 | 50000 | 15 | -0.7 | 0.0 | -7.4 | 0 |
124+
| 0.5 | 400 | 50000 | 15 | -0.7 | 0.0 | -7.4 | 0 |
125+
| 0.3 | 200 | 25000 | 15 | -0.7 | 0.0 | -7.5 | 0 |
126+
| 0.5 | 200 | 25000 | 15 | -0.7 | 0.0 | -7.3 | 0 |
127+
| 0.1 | 200 | 25000 | 15 | -0.4 | 0.0 | -7.6 | 0 |
128+
| ... | ... | ... | ... | ... | ... | ... | ... |
129+
| 0.1 | 200 | 25000 | 10 | **0.0** | 0.0 | 0.0 | 0 |
130+
| 0.1 | 400 | 25000 | 10 | 0.0 | 0.0 | 0.0 | 0 |
128131

129132
### Key Observations
130133

@@ -152,14 +155,14 @@ departures currently converging exactly to the great circle (0% delta).
152155

153156
### Parameter Selection
154157

155-
| Parameter | Recommended | Rationale |
156-
|-----------|-------------|-----------|
157-
| **K** | **15** | Consistently improves zero-delta departures; no downside |
158-
| **σ₀** | **0.3** | Best tradeoff: helps Stage A detours and Stage B exploration |
159-
| **popsize** | **200** | 400 shows no benefit; saves compute |
160-
| **maxfevals** | **25000** | 50k shows no benefit on these departures |
161-
| **wpw** | **10** | Compromise: mean Δ% ≈ 0% (vs +24.6% baseline), TWS violations drop from 5 to 3 |
162-
| **sharpness** | **1** | Lower sharpness reduces over-reaction to marginal exceedances |
158+
| Parameter | Recommended | Rationale |
159+
| ------------- | ----------- | ------------------------------------------------------------------------------ |
160+
| **K** | **15** | Consistently improves zero-delta departures; no downside |
161+
| **σ₀** | **0.3** | Best tradeoff: helps Stage A detours and Stage B exploration |
162+
| **popsize** | **200** | 400 shows no benefit; saves compute |
163+
| **maxfevals** | **25000** | 50k shows no benefit on these departures |
164+
| **wpw** | **10** | Compromise: mean Δ% ≈ 0% (vs +24.6% baseline), TWS violations drop from 5 to 3 |
165+
| **sharpness** | **1** | Lower sharpness reduces over-reaction to marginal exceedances |
163166

164167
### Tradeoff: Energy vs Compliance
165168

routetools/cmaes.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,26 @@
1+
"""CMA-ES route optimization utilities.
2+
3+
This module parameterizes vessel routes as Bezier curves and optimizes their
4+
free control points with `pycma`. The main `optimize` entry point converts an
5+
initial straight line or seed curve into control-point space, evaluates each
6+
population as full routes, applies optional land and weather penalties, and
7+
returns the best route in waypoint form together with summary metadata.
8+
9+
The default path minimizes `routetools.cost.cost_function`, but callers may
10+
inject a custom batched `cost_fn` to align CMA-ES with another objective, such
11+
as SWOPP3's RISE energy model. Land intersection penalties, distance-to-land
12+
repulsion, and weather penalties are layered outside the custom objective so
13+
the population loop can be reused across synthetic and real-weather scenarios.
14+
15+
New in this workflow is the optional `snapshot_callback` hook exposed on both
16+
`_cma_evolution_strategy` and `optimize`. When provided, it receives one
17+
dictionary per CMA-ES iteration containing the whole candidate population,
18+
their costs, the generation-best route, and the global-best route so far. This
19+
is intended for controlled visualizations and diagnostics, including the
20+
single-departure SWOPP3 GIF script, without changing the optimizer's default
21+
behavior or return type.
22+
"""
23+
124
import time
225
import warnings
326
from collections.abc import Callable
@@ -19,6 +42,9 @@
1942
from routetools.weather import weather_penalty as _weather_penalty
2043
from routetools.weather import weather_penalty_smooth as _weather_penalty_smooth
2144

45+
type CmaesSnapshot = dict[str, Any]
46+
type CmaesSnapshotCallback = Callable[[CmaesSnapshot], None]
47+
2248

2349
@jit # type: ignore[misc]
2450
def batch_bezier(t: jnp.ndarray, control: jnp.ndarray) -> jnp.ndarray:
@@ -316,6 +342,7 @@ def _cma_evolution_strategy(
316342
land_margin: int = 0,
317343
verbose: bool = True,
318344
bounds: list[list[float]] | None = None,
345+
snapshot_callback: CmaesSnapshotCallback | None = None,
319346
**kwargs: dict[str, Any],
320347
) -> cma.CMAEvolutionStrategy:
321348
curve: jnp.ndarray
@@ -429,10 +456,36 @@ def _cma_evolution_strategy(
429456
cost = cost.at[idx_worst[i]].set(top_costs[i])
430457
curve = curve.at[idx_worst[i], ...].set(top_curves[i, ...])
431458

459+
generation_best_index = int(jnp.argmin(cost))
460+
generation_best_curve = curve[generation_best_index]
461+
generation_best_cost = float(cost[generation_best_index])
462+
432463
es.tell(X, cost.tolist()) # update the optimizer
433464
if verbose:
434465
es.disp()
435466

467+
if snapshot_callback is not None:
468+
best_curve = control_to_curve(
469+
jnp.asarray(es.best.x),
470+
src,
471+
dst,
472+
L=L,
473+
num_pieces=num_pieces,
474+
force_L_multiple_of_num_pieces=force_L_multiple_of_num_pieces,
475+
)
476+
snapshot_callback(
477+
{
478+
"iteration": es.countiter,
479+
"population_curves": curve,
480+
"population_costs": cost,
481+
"generation_best_index": generation_best_index,
482+
"generation_best_curve": generation_best_curve,
483+
"generation_best_cost": generation_best_cost,
484+
"best_curve": best_curve,
485+
"best_cost": float(es.best.f),
486+
}
487+
)
488+
436489
# Save the top solutions (use cost as fitness)
437490
if keep_top > 0:
438491
idx_sorted = jnp.argsort(cost)
@@ -487,6 +540,7 @@ def optimize(
487540
land_margin: int = 0,
488541
verbose: bool = True,
489542
bounds: list[list[float]] | None = None,
543+
snapshot_callback: CmaesSnapshotCallback | None = None,
490544
) -> tuple[jnp.ndarray, dict[str, Any]]:
491545
"""
492546
Solve the vessel routing problem for a given vector field.
@@ -570,6 +624,10 @@ def optimize(
570624
Per-dimension ``[lower, upper]`` bounds for CMA-ES control-point
571625
parameters. Each list has length ``2*(K-2)``. ``None`` disables
572626
bounds (default).
627+
snapshot_callback : callable, optional
628+
Called after every CMA-ES iteration with the current population,
629+
per-candidate costs, the generation-best route, and the global-best
630+
route so far. Default is ``None``.
573631
574632
Returns
575633
-------
@@ -662,6 +720,7 @@ def optimize(
662720
land_margin=land_margin,
663721
verbose=verbose,
664722
bounds=bounds,
723+
snapshot_callback=snapshot_callback,
665724
)
666725
time_end = time.time()
667726
if verbose:

0 commit comments

Comments
 (0)