Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
gili8420
a53adc4585 fix(prediction): correct dataset mapping 2026-08-04 13:42:10 +09:00
3 changed files with 107 additions and 3 deletions

View file

@ -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")

View file

@ -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" # <-- в конце

View file

@ -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")