Skip to content

Commit fdb9569

Browse files
committed
Merge branch 'feat/swopp3-clean' into feat/swopp3-violation-reporting
2 parents fd368b5 + 88979b1 commit fdb9569

11 files changed

Lines changed: 1793 additions & 162 deletions

File tree

codabench/scoring_program/scoring.py

Lines changed: 129 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@
6969
# Passage time tolerance (hours)
7070
PASSAGE_TIME_TOLERANCE_H = 1.0
7171

72+
# Evaluation resampling interval (minutes)
73+
# Decouples waypoint density (Δt₁) from integration accuracy (Δt₂).
74+
EVAL_DT_MINUTES = 15.0
75+
7276
CASE_NAMES = [
7377
"AO_WPS",
7478
"AO_noWPS",
@@ -86,28 +90,28 @@
8690
CASE_DEFS = {
8791
"AO_WPS": {
8892
"src": (43.6, -4.0),
89-
"dst": (40.53, -73.80),
93+
"dst": (40.6, -69.0),
9094
"passage_h": 354,
9195
"route": "atlantic",
9296
"wps": True,
9397
},
9498
"AO_noWPS": {
9599
"src": (43.6, -4.0),
96-
"dst": (40.53, -73.80),
100+
"dst": (40.6, -69.0),
97101
"passage_h": 354,
98102
"route": "atlantic",
99103
"wps": False,
100104
},
101105
"AGC_WPS": {
102106
"src": (43.6, -4.0),
103-
"dst": (40.53, -73.80),
107+
"dst": (40.6, -69.0),
104108
"passage_h": 354,
105109
"route": "atlantic",
106110
"wps": True,
107111
},
108112
"AGC_noWPS": {
109113
"src": (43.6, -4.0),
110-
"dst": (40.53, -73.80),
114+
"dst": (40.6, -69.0),
111115
"passage_h": 354,
112116
"route": "atlantic",
113117
"wps": False,
@@ -526,6 +530,12 @@ def _load_era5_grid(nc_paths: list[str]) -> dict | None:
526530
for v in data:
527531
data[v] = data[v][:, :, ::-1]
528532

533+
# Decompose angular variables into sin/cos for correct interpolation
534+
if "mwd" in data:
535+
mwd_rad = np.radians(data["mwd"])
536+
data["mwd_sin"] = np.sin(mwd_rad).astype(np.float32)
537+
data["mwd_cos"] = np.cos(mwd_rad).astype(np.float32)
538+
529539
return {
530540
"data": data,
531541
"lat": lat,
@@ -600,6 +610,23 @@ def _interp_era5(
600610
return result.astype(np.float64)
601611

602612

613+
def _interp_era5_angle(
614+
grid: dict,
615+
var_name: str,
616+
query_lat: np.ndarray,
617+
query_lon: np.ndarray,
618+
query_t_h: np.ndarray,
619+
) -> np.ndarray:
620+
"""Interpolate an angular ERA5 variable using sin/cos decomposition.
621+
622+
Avoids the discontinuity at 0/360 degrees that breaks linear
623+
interpolation of angular quantities (e.g. mean wave direction).
624+
"""
625+
sin_vals = _interp_era5(grid, f"{var_name}_sin", query_lat, query_lon, query_t_h)
626+
cos_vals = _interp_era5(grid, f"{var_name}_cos", query_lat, query_lon, query_t_h)
627+
return np.mod(np.degrees(np.arctan2(sin_vals, cos_vals)), 360.0)
628+
629+
603630
# ─── RISE performance model (self-contained, numpy only) ─────────────
604631

605632
# Constants
@@ -630,8 +657,8 @@ def _rise_power(tws, twa_deg, swh, mwa_deg, v, wps):
630657
# Aerodynamic drag
631658
p_wind = _KA * v * (vr * ux - v**2)
632659

633-
# Wave added resistance
634-
mwa_rad = np.radians(mwa_deg)
660+
# Wave added resistance (center MWA to [-180, 180] before radians)
661+
mwa_rad = np.radians(np.mod(mwa_deg + 180.0, 360.0) - 180.0)
635662
p_wave = _AW * swh**2 * v**1.5 * np.exp(-_KW * np.abs(mwa_rad) ** 3)
636663

637664
power = p_hull + p_wind + p_wave
@@ -648,6 +675,70 @@ def _rise_power(tws, twa_deg, swh, mwa_deg, v, wps):
648675
return np.maximum(power, 0.0)
649676

650677

678+
def _slerp(
679+
lat1: float,
680+
lon1: float,
681+
lat2: float,
682+
lon2: float,
683+
f: float,
684+
) -> tuple[float, float]:
685+
"""Spherical linear interpolation at fraction *f* in [0, 1]."""
686+
phi1, lam1 = math.radians(lat1), math.radians(lon1)
687+
phi2, lam2 = math.radians(lat2), math.radians(lon2)
688+
p1 = np.array(
689+
[
690+
math.cos(phi1) * math.cos(lam1),
691+
math.cos(phi1) * math.sin(lam1),
692+
math.sin(phi1),
693+
]
694+
)
695+
p2 = np.array(
696+
[
697+
math.cos(phi2) * math.cos(lam2),
698+
math.cos(phi2) * math.sin(lam2),
699+
math.sin(phi2),
700+
]
701+
)
702+
dot = float(np.clip(np.dot(p1, p2), -1.0, 1.0))
703+
sigma = math.acos(dot)
704+
if sigma < 1e-12:
705+
lat = lat1 + f * (lat2 - lat1)
706+
lon = lon1 + f * ((lon2 - lon1 + 180.0) % 360.0 - 180.0)
707+
return lat, lon
708+
a = math.sin((1 - f) * sigma) / math.sin(sigma)
709+
b = math.sin(f * sigma) / math.sin(sigma)
710+
p = a * p1 + b * p2
711+
lat = math.degrees(math.atan2(p[2], math.sqrt(p[0] ** 2 + p[1] ** 2)))
712+
lon = math.degrees(math.atan2(p[1], p[0]))
713+
return lat, lon
714+
715+
716+
def _resample_waypoints(
717+
waypoints: list[tuple[datetime, float, float]],
718+
dt_minutes: float = EVAL_DT_MINUTES,
719+
) -> list[tuple[datetime, float, float]]:
720+
"""Resample a track to uniform Δt₂ via great-circle interpolation."""
721+
if len(waypoints) < 2:
722+
return list(waypoints)
723+
dt_seconds = dt_minutes * 60.0
724+
result: list[tuple[datetime, float, float]] = []
725+
for i in range(len(waypoints) - 1):
726+
t0, lat0, lon0 = waypoints[i]
727+
t1, lat1, lon1 = waypoints[i + 1]
728+
seg_seconds = (t1 - t0).total_seconds()
729+
if seg_seconds <= 0:
730+
result.append((t0, lat0, lon0))
731+
continue
732+
n_sub = max(1, math.ceil(seg_seconds / dt_seconds))
733+
for j in range(n_sub):
734+
f = j / n_sub
735+
t = t0 + timedelta(seconds=f * seg_seconds)
736+
lat, lon = _slerp(lat0, lon0, lat1, lon1, f)
737+
result.append((t, lat, lon))
738+
result.append(waypoints[-1])
739+
return result
740+
741+
651742
def _haversine_m(lat1, lon1, lat2, lon2):
652743
"""Haversine distance in metres between arrays of points."""
653744
R = 6_371_000.0
@@ -764,6 +855,10 @@ def evaluate_route(
764855
if n_wp < 2:
765856
return None
766857

858+
# Resample to uniform Δt₂ for integration-accuracy independence
859+
waypoints = _resample_waypoints(waypoints, EVAL_DT_MINUTES)
860+
n_wp = len(waypoints)
861+
767862
lats = np.array([wp[1] for wp in waypoints])
768863
lons = np.array([wp[2] for wp in waypoints])
769864

@@ -797,7 +892,7 @@ def evaluate_route(
797892
u10 = _interp_era5(wind_grid, "u10", mid_lat, mid_lon, seg_mid_h)
798893
v10 = _interp_era5(wind_grid, "v10", mid_lat, mid_lon, seg_mid_h)
799894
swh = _interp_era5(wave_grid, "swh", mid_lat, mid_lon, seg_mid_h)
800-
mwd = _interp_era5(wave_grid, "mwd", mid_lat, mid_lon, seg_mid_h)
895+
mwd = _interp_era5_angle(wave_grid, "mwd", mid_lat, mid_lon, seg_mid_h)
801896

802897
# Ship speed (m/s)
803898
seg_dist_m = _haversine_m(lats[:-1], lons[:-1], lats[1:], lons[1:])
@@ -1209,6 +1304,8 @@ def score_submission() -> dict:
12091304
tracks_dir = submission_dir / "tracks"
12101305
re_eval_energy = 0.0
12111306
re_eval_ok = era5_scorer is not None
1307+
missing_tracks = 0
1308+
total_tracks = len(fa_rows)
12121309
for row in fa_rows:
12131310
fb_name = row.get("details_filename", "")
12141311
if not fb_name:
@@ -1268,8 +1365,19 @@ def score_submission() -> dict:
12681365
re_eval_ok = False
12691366
elif re_eval_ok:
12701367
# Track file missing or departure unknown — cannot re-evaluate
1368+
if not fb_path.exists():
1369+
missing_tracks += 1
12711370
re_eval_ok = False
12721371

1372+
# Report missing track files for this case
1373+
if missing_tracks > 0:
1374+
all_errors.append(
1375+
f"{case}: {missing_tracks} of {total_tracks} track files "
1376+
f"missing from tracks/ directory. Ensure all File B CSVs "
1377+
f"referenced in the details_filename column of File A are "
1378+
f"included in the tracks/ folder of your submission zip."
1379+
)
1380+
12731381
# Use re-evaluated energy when successful, else fall back
12741382
if re_eval_ok and era5_scorer is not None:
12751383
case_energy_final = re_eval_energy
@@ -1279,6 +1387,20 @@ def score_submission() -> dict:
12791387
else:
12801388
# ERA5 available but re-evaluation failed — penalty
12811389
case_energy_final = 1e12
1390+
if missing_tracks > 0:
1391+
all_errors.append(
1392+
f"{case}: PENALTY — energy set to 1e12 because "
1393+
f"{missing_tracks} track file(s) are missing. "
1394+
f"All {total_tracks} departures must have "
1395+
f"corresponding track files for ERA5 re-evaluation."
1396+
)
1397+
else:
1398+
all_errors.append(
1399+
f"{case}: PENALTY — energy set to 1e12 because "
1400+
f"ERA5 re-evaluation could not be completed. "
1401+
f"Check that track files have valid waypoints "
1402+
f"(time_utc, lat_deg, lon_deg columns)."
1403+
)
12821404

12831405
scores[f"{case}_energy_mwh"] = round(case_energy_final, 4)
12841406
scores[f"{case}_reported_mwh"] = round(case_energy, 4)

0 commit comments

Comments
 (0)