6969# Passage time tolerance (hours)
7070PASSAGE_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+
7276CASE_NAMES = [
7377 "AO_WPS" ,
7478 "AO_noWPS" ,
8690CASE_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+
651742def _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