From 2c207bd5909cbb5fa3473f1c83f86e4bb1300da5 Mon Sep 17 00:00:00 2001 From: Vadim Bertrand <36510417+vadmbertr@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:27:10 +0000 Subject: [PATCH] Document antimeridian limitations of the ensemble scores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scores in score.py operate on raw [lon, lat] components: squared_error and energy_score via the default Euclidean l2_distance kernel, and dawid_sebastiani / variogram_score directly (sample covariance and cross-component differences). None is aware of the ±180° seam, so a pair straddling the dateline reads as ~358° apart, and a forecast and observation given in different longitude conventions are compared inconsistently — a real risk now that the solver leaves integrated longitude unbounded. Add an antimeridian warning to the module docstring and a per-function note to each score, pointing at the mitigations: a great-circle kernel (kernel=haversine or metric.separation_distance) for the kernel-based scores, and a single consistent longitude convention (e.g. via wrap_longitude) for all four. Documentation only — no behaviour change. The metrics in metric.py are already seam-safe (they route through haversine). --- src/pastax/score.py | 57 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/pastax/score.py b/src/pastax/score.py index 1f14b8c..8745be9 100644 --- a/src/pastax/score.py +++ b/src/pastax/score.py @@ -20,9 +20,28 @@ The default distance kernel for :func:`squared_error` and :func:`energy_score` is the Euclidean distance. A user may pass any callable -satisfying the broadcasting kernel contract — +satisfying the broadcasting kernel contract — notably :func:`pastax.metric.separation_distance` for great-circle distances on the sphere. + +.. warning:: + + **Antimeridian / longitude convention.** These scores operate on the raw + ``[lon, lat]`` components and are *not* aware of the ±180° seam. Two + positions either side of the dateline (e.g. ``179`` and ``-179``) read as + ~358° apart rather than ~2°, and a forecast and observation supplied in + different longitude conventions — e.g. one wrapped to ``[-180, 180)``, the + other carrying the solver's unbounded longitude (``181``, ``200``, …) — are + compared inconsistently. Keep every input (all ensemble members *and* the + observation) in a single, consistent longitude convention and avoid data + that straddles the seam; ``pastax.wrap_longitude`` can normalise them the + same way. For :func:`squared_error` / :func:`energy_score`, additionally + pass a great-circle kernel (``kernel=pastax.haversine`` or + :func:`pastax.metric.separation_distance`) so the distance itself is + seam-safe. :func:`dawid_sebastiani` and :func:`variogram_score` have no + kernel hook and rely entirely on the consistent-convention requirement. + (The metrics in :mod:`pastax.metric` are seam-safe — they use + :func:`pastax.haversine`, which is periodic in longitude.) """ from collections.abc import Callable @@ -104,6 +123,14 @@ def squared_error( With the default L2 kernel this is the squared error of the ensemble mean (Pic et al. 2025, Eq. 11). + .. note:: + + The default :func:`l2_distance` kernel is *not* antimeridian-safe. Near + the dateline pass a great-circle kernel (``kernel=pastax.haversine`` or + :func:`pastax.metric.separation_distance`) and keep the forecast and + observation in one consistent longitude convention — see the + module-level warning. + Args: forecast: Ensemble forecast, shape ``(S, T, 2)``. obs: Observed trajectory, shape ``(T, 2)``. @@ -142,6 +169,15 @@ def dawid_sebastiani( be a.s. full-rank on :math:`\mathbb{R}^2`; for :math:`S \leq 2` the score is undefined (singular covariance). + .. note:: + + Not antimeridian-safe and has no kernel hook: the sample covariance and + the ``mu - obs`` term are taken on the raw ``[lon, lat]`` components, so + an ensemble straddling ±180° gets a spuriously inflated longitude + variance that corrupts :math:`\Sigma_t`, its log-determinant and the + Mahalanobis term. Keep the ensemble and observation in one consistent + longitude convention, away from the seam — see the module-level warning. + Args: forecast: Ensemble forecast, shape ``(S, T, 2)``, with ``S >= 3``. obs: Observed trajectory, shape ``(T, 2)``. @@ -193,6 +229,17 @@ def energy_score( off-diagonal estimator exactly. Strictly proper for the L2 kernel and :math:`\alpha \in (0, 2)`; propriety with other kernels is not guaranteed. + .. note:: + + The default :func:`l2_distance` kernel is *not* antimeridian-safe. Near + the dateline pass a great-circle kernel (``kernel=pastax.haversine`` or + :func:`pastax.metric.separation_distance`) and keep inputs in one + consistent longitude convention — see the module-level warning. Note the + trade-off: strict propriety holds for the Euclidean default (a distance + of negative type) but is *not* guaranteed for the geodesic/haversine + distance. A chordal distance (map ``[lon, lat]`` to a 3-D unit vector + and take the Euclidean distance) is both seam-safe *and* strictly proper. + Args: forecast: Ensemble forecast, shape ``(S, T, 2)``, with ``S >= 2``. obs: Observed trajectory, shape ``(T, 2)``. @@ -241,6 +288,14 @@ def variogram_score( ``component_weights = 1 - I``, the diagonal contribution (zero) is masked out and the off-diagonal pair is counted twice (symmetric formulation). + .. note:: + + Not antimeridian-safe and has no kernel hook: the component differences + :math:`|X_{t,i} - X_{t,j}|` use the *absolute* longitude value, so the + score depends on the longitude convention and is wrong when forecast and + observation disagree on it or when data straddles ±180°. Keep every input + in one consistent longitude convention — see the module-level warning. + Args: forecast: Ensemble forecast, shape ``(S, T, 2)``. obs: Observed trajectory, shape ``(T, 2)``.