Compare commits
6 commits
master
...
datasets_f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a53adc4585 | ||
|
|
7f70ebdd96 | ||
|
|
80e0177be0 | ||
|
|
0261b41a71 | ||
|
|
5eb8a4a4e2 | ||
|
|
1850608f96 |
8 changed files with 185 additions and 20 deletions
|
|
@ -45,6 +45,7 @@ MEDIA_ROOT = os.getenv('MEDIA_ROOT', os.path.join(BASE_DIR, 'media')) # Куд
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
'daphne',
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
'django.contrib.auth',
|
'django.contrib.auth',
|
||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
|
|
@ -103,7 +104,7 @@ ASGI_APPLICATION = 'stratoflights.asgi.application'
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
||||||
|
|
||||||
if PRODUCTION:
|
if not PRODUCTION:
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
|
@ -167,7 +168,7 @@ AUTH_USER_MODEL = 'stratoflights_api.User'
|
||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||||
'PAGE_SIZE': 100,
|
'PAGE_SIZE': 1000,
|
||||||
|
|
||||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||||
|
|
||||||
|
|
@ -196,9 +197,9 @@ CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:5173,
|
||||||
|
|
||||||
CHANNEL_LAYERS = {
|
CHANNEL_LAYERS = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
"BACKEND": "channels.layers.InMemoryChannelLayer",
|
||||||
"CONFIG": {
|
# "CONFIG": {
|
||||||
"hosts": [("redis", 6379)],
|
# "hosts": [("redis", 6379)],
|
||||||
},
|
# },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ from rest_framework.response import Response
|
||||||
class CustomLimitOffsetPagination(LimitOffsetPagination):
|
class CustomLimitOffsetPagination(LimitOffsetPagination):
|
||||||
limit_query_param = 'limit'
|
limit_query_param = 'limit'
|
||||||
offset_query_param = 'skip'
|
offset_query_param = 'skip'
|
||||||
max_limit = 100
|
max_limit = 1000
|
||||||
default_limit = 10
|
default_limit = 10
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stratoflights_api', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='telemetrypacket',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -39,7 +39,7 @@ class Satellite(models.Model):
|
||||||
|
|
||||||
class TelemetryPacket(models.Model):
|
class TelemetryPacket(models.Model):
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
get_user_model(), on_delete=models.CASCADE, default=0)
|
get_user_model(), on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
satellite = models.ForeignKey(
|
satellite = models.ForeignKey(
|
||||||
Satellite, on_delete=models.CASCADE, related_name="telemetry")
|
Satellite, on_delete=models.CASCADE, related_name="telemetry")
|
||||||
|
|
@ -50,6 +50,10 @@ class TelemetryPacket(models.Model):
|
||||||
payload = models.JSONField(blank=True, default=dict)
|
payload = models.JSONField(blank=True, default=dict)
|
||||||
raw_data = models.JSONField(blank=True, default=dict)
|
raw_data = models.JSONField(blank=True, default=dict)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
def __str__(self):
|
||||||
|
return f"packet {self.satellite} {self.lat} {self.lon} {self.alt} {self.timestamp}"
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-timestamp"]
|
||||||
|
|
||||||
|
|
||||||
class PreditctionTemplate(models.Model):
|
class PreditctionTemplate(models.Model):
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from django.contrib.auth.password_validation import validate_password
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from .services.tawhiri import LATEST_DATASET_KEYWORD
|
||||||
from .validators import (
|
from .validators import (
|
||||||
validate_custom_curve, rate_clip,
|
validate_custom_curve, rate_clip,
|
||||||
_rfc3339_to_timestamp, base64_to_curve
|
_rfc3339_to_timestamp, base64_to_curve
|
||||||
|
|
@ -21,13 +22,23 @@ PROFILE_STANDARD = "standard_profile"
|
||||||
PROFILE_FLOAT = "float_profile"
|
PROFILE_FLOAT = "float_profile"
|
||||||
PROFILE_REVERSE = "reverse_profile"
|
PROFILE_REVERSE = "reverse_profile"
|
||||||
PROFILE_CUSTOM = "custom_profile"
|
PROFILE_CUSTOM = "custom_profile"
|
||||||
LATEST_DATASET_KEYWORD = "latest"
|
|
||||||
SUPPORTED_PROFILES = [PROFILE_STANDARD, PROFILE_FLOAT, PROFILE_REVERSE, PROFILE_CUSTOM]
|
SUPPORTED_PROFILES = [PROFILE_STANDARD, PROFILE_FLOAT, PROFILE_REVERSE, PROFILE_CUSTOM]
|
||||||
|
|
||||||
|
|
||||||
class PredictionRequestSerializer(serializers.Serializer):
|
class PredictionRequestSerializer(serializers.Serializer):
|
||||||
launch_latitude = serializers.FloatField(min_value=-90, max_value=90)
|
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_datetime = serializers.DateTimeField()
|
||||||
launch_altitude = serializers.FloatField(required=False)
|
launch_altitude = serializers.FloatField(required=False)
|
||||||
format = serializers.CharField(default="json")
|
format = serializers.CharField(default="json")
|
||||||
|
|
@ -124,6 +135,7 @@ class TelemetryPacketSerializer(serializers.ModelSerializer):
|
||||||
model = TelemetryPacket
|
model = TelemetryPacket
|
||||||
fields = ['id', 'timestamp', 'lat', 'lon', 'alt', 'payload']
|
fields = ['id', 'timestamp', 'lat', 'lon', 'alt', 'payload']
|
||||||
read_only_fields = ['id']
|
read_only_fields = ['id']
|
||||||
|
extra_kwargs = {'timestamp': {'required': False}}
|
||||||
|
|
||||||
|
|
||||||
class SavedPointSerializer(serializers.ModelSerializer):
|
class SavedPointSerializer(serializers.ModelSerializer):
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,14 @@ from typing import Any
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
from collections import OrderedDict
|
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:
|
class TawhiriClient:
|
||||||
BASE_URL = "https://fly.stratonautica.ru/api/v2/"
|
BASE_URL = "http://127.0.0.1:8080/api/v1/prediction"
|
||||||
TIMEOUT = 15
|
TIMEOUT = 15
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -36,7 +42,20 @@ class TawhiriClient:
|
||||||
query["burst_altitude"] = params.get("burst_altitude")
|
query["burst_altitude"] = params.get("burst_altitude")
|
||||||
query["descent_rate"] = params.get("descent_rate")
|
query["descent_rate"] = params.get("descent_rate")
|
||||||
query["interpolate"] = str(params.get("interpolate", False)).lower()
|
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["format"] = params.get("format", "json")
|
||||||
query["pred_type"] = "single" # <-- в конце
|
query["pred_type"] = "single" # <-- в конце
|
||||||
|
|
||||||
|
|
|
||||||
74
stratoflights_api/test_launch_bounds.py
Normal file
74
stratoflights_api/test_launch_bounds.py
Normal 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")
|
||||||
|
|
@ -156,16 +156,47 @@ class TelemetryListCreateView(generics.ListCreateAPIView):
|
||||||
if not serializer.is_valid():
|
if not serializer.is_valid():
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
satellite = Satellite.objects.get(id=pk)
|
||||||
|
except Satellite.DoesNotExist:
|
||||||
|
return Response({"detail": "Satellite not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
validated_data = serializer.validated_data
|
validated_data = serializer.validated_data
|
||||||
|
|
||||||
TelemetryPacket.objects.create(timestamp=time.time(),
|
packet = TelemetryPacket.objects.create(
|
||||||
satellite=Satellite.objects.get(id=pk),
|
satellite=satellite,
|
||||||
lat=validated_data["lat"],
|
user=request.user if request.user.is_authenticated else None,
|
||||||
lon=validated_data["lon"],
|
timestamp=validated_data.get('timestamp', int(time.time())),
|
||||||
alt=validated_data["alt"],
|
lat=validated_data['lat'],
|
||||||
payload=validated_data['payload'],
|
lon=validated_data['lon'],
|
||||||
|
alt=validated_data['alt'],
|
||||||
|
payload=validated_data.get('payload', {}),
|
||||||
)
|
)
|
||||||
return Response(serializer.errors, status=status.HTTP_201_CREATED)
|
|
||||||
|
# Broadcast to WebSocket subscribers so the tracking page updates live
|
||||||
|
try:
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
from .consumers import SatelliteTelemetryConsumer
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
if channel_layer is not None:
|
||||||
|
async_to_sync(SatelliteTelemetryConsumer.broadcast_to_satellite_group)(
|
||||||
|
str(pk),
|
||||||
|
{
|
||||||
|
'id': str(packet.id),
|
||||||
|
'timestamp': packet.timestamp,
|
||||||
|
'lat': packet.lat,
|
||||||
|
'lon': packet.lon,
|
||||||
|
'alt': packet.alt,
|
||||||
|
'payload': packet.payload,
|
||||||
|
'raw_data': {},
|
||||||
|
},
|
||||||
|
channel_layer,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # WS broadcast is best-effort; don't fail the REST response
|
||||||
|
|
||||||
|
return Response({'id': str(packet.id)}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
class SessionView(APIView):
|
class SessionView(APIView):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue