From a53adc4585e3e8256cb032dcb4dd408ce36a7b02 Mon Sep 17 00:00:00 2001 From: gili8420 Date: Tue, 4 Aug 2026 13:42:10 +0900 Subject: [PATCH] fix(prediction): correct dataset mapping --- stratoflights_api/serializers.py | 15 ++++- stratoflights_api/services/tawhiri.py | 21 ++++++- stratoflights_api/test_launch_bounds.py | 74 +++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 stratoflights_api/test_launch_bounds.py diff --git a/stratoflights_api/serializers.py b/stratoflights_api/serializers.py index 4c77cf0..88f02c5 100644 --- a/stratoflights_api/serializers.py +++ b/stratoflights_api/serializers.py @@ -5,6 +5,7 @@ from django.contrib.auth.password_validation import validate_password from django.core.validators import validate_email from django.core.exceptions import ValidationError as DjangoValidationError from django.contrib.auth import get_user_model +from .services.tawhiri import LATEST_DATASET_KEYWORD from .validators import ( validate_custom_curve, rate_clip, _rfc3339_to_timestamp, base64_to_curve @@ -21,13 +22,23 @@ PROFILE_STANDARD = "standard_profile" PROFILE_FLOAT = "float_profile" PROFILE_REVERSE = "reverse_profile" PROFILE_CUSTOM = "custom_profile" -LATEST_DATASET_KEYWORD = "latest" SUPPORTED_PROFILES = [PROFILE_STANDARD, PROFILE_FLOAT, PROFILE_REVERSE, PROFILE_CUSTOM] class PredictionRequestSerializer(serializers.Serializer): launch_latitude = serializers.FloatField(min_value=-90, max_value=90) - launch_longitude = serializers.FloatField(min_value=0, max_value=360) + # Deliberately unbounded. Longitude is on a circle, so every real number names + # a real meridian and any interval here is a policy about typos, not places — + # and that policy belongs to the one service that owns the wind grid. The + # endpoint this calls, GET /api/v1/prediction, applies no longitude bound: it + # normalises and lets the grid refuse what it cannot use. + # + # This field was min_value=0, narrower than that endpoint, which refused every + # launch west of Greenwich — Canada, Greenland, Alaska. Two layers each + # guessing at the range is what produced that, so this one no longer guesses. + # FloatField still refuses anything that is not a number; NaN and infinity get + # through here and are rejected by the predictor's request decoder. + launch_longitude = serializers.FloatField() launch_datetime = serializers.DateTimeField() launch_altitude = serializers.FloatField(required=False) format = serializers.CharField(default="json") diff --git a/stratoflights_api/services/tawhiri.py b/stratoflights_api/services/tawhiri.py index 5a6bf04..c82b5c6 100644 --- a/stratoflights_api/services/tawhiri.py +++ b/stratoflights_api/services/tawhiri.py @@ -5,6 +5,12 @@ from typing import Any from zoneinfo import ZoneInfo from collections import OrderedDict +# Request value meaning "let the predictor use whatever run it has loaded". +# Lives here because this module is the one that decides what reaches the +# predictor; the serializer imports it as its default. +LATEST_DATASET_KEYWORD = "latest" + + class TawhiriClient: BASE_URL = "http://127.0.0.1:8080/api/v1/prediction" TIMEOUT = 15 @@ -36,7 +42,20 @@ class TawhiriClient: query["burst_altitude"] = params.get("burst_altitude") query["descent_rate"] = params.get("descent_rate") query["interpolate"] = str(params.get("interpolate", False)).lower() - #query["dataset"] = cls._convert_value(params.get("dataset")) + # Forwarded only when the operator actually named a run. + # + # The predictor now honours this parameter and refuses a run it does not + # hold, so anything that is not a real epoch must not be sent. Two values + # reach here that are not: "" from the UI, and LATEST_DATASET_KEYWORD, the + # serializer's default, which means "server chooses" — exactly what + # omitting the parameter does. The `filtered` comprehension below drops + # None but neither of these. + # + # A malformed epoch is deliberately still forwarded, so the predictor + # rejects it instead of the value being quietly ignored. + dataset = params.get("dataset") + if dataset and dataset != LATEST_DATASET_KEYWORD: + query["dataset"] = cls._convert_value(dataset) query["format"] = params.get("format", "json") query["pred_type"] = "single" # <-- в конце diff --git a/stratoflights_api/test_launch_bounds.py b/stratoflights_api/test_launch_bounds.py new file mode 100644 index 0000000..645cd97 --- /dev/null +++ b/stratoflights_api/test_launch_bounds.py @@ -0,0 +1,74 @@ +from django.test import SimpleTestCase + +from .serializers import PredictionRequestSerializer + + +class LaunchLongitudeTests(SimpleTestCase): + """ + This API does not bound longitude. The predictor does. + + Longitude lives on a circle: every real number names a real meridian, and + 5170 is a legitimate way to write 50 E. So any interval is a policy about + typos, not about places, and there is exactly one place that policy belongs — + the service that owns the wind grid. Two layers each guessing at it is how the + original defect happened: this serializer declared min_value=0, narrower than + the endpoint it calls, and refused every launch west of Greenwich. Canada, + Greenland and Alaska, i.e. most of the Arctic sites this product targets. + + The endpoint actually called is GET /api/v1/prediction, which applies no + longitude bound at all — it normalises and lets the grid refuse what it cannot + use. Mirroring that exactly is the point: this layer must never be the narrower + one. + + Nuuk is -51.7. The same meridian written unsigned is 308.3, and that always + worked, which is what showed the constraint was about notation, not place. + + Non-numbers are still refused here, by FloatField. NaN and infinity are not: + FloatField parses them, and the predictor's request decoder rejects them with a + 400 (measured). Deliberately not re-checked here, so this layer keeps a single + responsibility. + """ + + def payload(self, lng): + return { + "launch_latitude": 64.1, + "launch_longitude": lng, + "launch_datetime": "2026-08-05T12:00:00Z", + "launch_altitude": 0, + "ascent_rate": 5, + "burst_altitude": 30000, + "descent_rate": 5, + "profile": "standard_profile", + } + + def assert_accepted(self, lng): + s = PredictionRequestSerializer(data=self.payload(lng)) + self.assertTrue(s.is_valid(), f"lng={lng} rejected: {s.errors}") + + def test_accepts_a_western_launch(self): + self.assert_accepted(-51.7) + + def test_accepts_the_same_meridian_written_unsigned(self): + self.assert_accepted(308.3) + + def test_accepts_every_notation_the_predictor_accepts(self): + # Measured against GET /api/v1/prediction: each of these returns 200, and + # the pairs below name the same meridian, so each returns the same + # trajectory as its twin. -180/180 are one meridian; 0/360 are one + # meridian; -90/270 are one meridian. + for lng in (-180, 180, 0, 360, -90, 270, -200, -180.0001, 359.999): + with self.subTest(lng=lng): + self.assert_accepted(lng) + + def test_does_not_bound_the_range(self): + # 1e30 is nonsense, and it is the predictor that says so — it answers 400 + # "lng=1e+30 out of range". Asserting acceptance here is the explicit + # statement that range is not this layer's job; a bound added here would + # again risk being narrower than the service behind it. + self.assert_accepted(1e30) + + def test_still_refuses_things_that_are_not_numbers(self): + for lng in ("abc", None, "", []): + with self.subTest(lng=lng): + s = PredictionRequestSerializer(data=self.payload(lng)) + self.assertFalse(s.is_valid(), f"lng={lng!r} accepted but is not a number")