forked from afanasyev.aa/stratoflights
Compare commits
No commits in common. "datasets_fix" and "master" have entirely different histories.
datasets_f
...
master
3 changed files with 3 additions and 107 deletions
|
|
@ -5,7 +5,6 @@ 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
|
||||
|
|
@ -22,23 +21,13 @@ 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)
|
||||
# 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_longitude = serializers.FloatField(min_value=0, max_value=360)
|
||||
launch_datetime = serializers.DateTimeField()
|
||||
launch_altitude = serializers.FloatField(required=False)
|
||||
format = serializers.CharField(default="json")
|
||||
|
|
|
|||
|
|
@ -5,12 +5,6 @@ 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
|
||||
|
|
@ -42,20 +36,7 @@ class TawhiriClient:
|
|||
query["burst_altitude"] = params.get("burst_altitude")
|
||||
query["descent_rate"] = params.get("descent_rate")
|
||||
query["interpolate"] = str(params.get("interpolate", False)).lower()
|
||||
# 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["dataset"] = cls._convert_value(params.get("dataset"))
|
||||
query["format"] = params.get("format", "json")
|
||||
query["pred_type"] = "single" # <-- в конце
|
||||
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue