diff --git a/backend/apps/ingest/constellation.py b/backend/apps/ingest/constellation.py
new file mode 100644
index 0000000..9086add
--- /dev/null
+++ b/backend/apps/ingest/constellation.py
@@ -0,0 +1,82 @@
+"""Identify the IAU constellation that contains an equatorial position.
+
+Uses the classic Roman (1987) point-in-constellation method: the official IAU
+boundaries are rectangular in RA/Dec *referred to equinox B1875.0*, so a given
+position is precessed back to B1875 and then matched against an ordered table of
+boundary segments (the first segment whose Dec floor and RA span contain the
+point wins). Boundary table: VI/42 "Identification of a Constellation from
+Position" (Roman 1987), bundled as ``data/constellation_boundaries.dat``.
+
+Kept dependency-free and closed-form, matching the parser's philosophy: the
+phone's degree-level orientation accuracy is far coarser than the arc-minute
+error of this precession, so no heavy astrometry library is warranted. The only
+ambiguity this cannot resolve is a trail that straddles a boundary — we report
+the constellation of each endpoint independently, which is exactly what the UI
+shows ("started in X, ended in Y").
+"""
+from __future__ import annotations
+
+import math
+from functools import lru_cache
+from pathlib import Path
+
+_DATA = Path(__file__).with_name("data") / "constellation_boundaries.dat"
+
+
+@lru_cache(maxsize=1)
+def _boundaries():
+ """Parsed boundary table as a list of (ra_low_h, ra_high_h, dec_low, abbr).
+
+ Rows are kept in file order; the lookup relies on that ordering (the table
+ is sorted by descending Dec floor, so the first containing row is correct).
+ """
+ rows = []
+ for line in _DATA.read_text().splitlines():
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ ra_low, ra_high, dec_low, abbr = line.split()
+ rows.append((float(ra_low), float(ra_high), float(dec_low), abbr))
+ return rows
+
+
+def _precess_j2000_to_b1875(ra_deg, dec_deg):
+ """Precess a mean equatorial position from J2000.0 to B1875.0.
+
+ IAU 1976 precession angles; T is the (negative) Julian-century interval from
+ J2000.0 to the B1875.0 epoch. Accurate to well under an arc-minute over this
+ 125-year span — comfortably finer than the boundary granularity.
+ """
+ # B1875.0 = JD 2405889.25 -> centuries from J2000.0 (JD 2451545.0).
+ t = (2405889.25 - 2451545.0) / 36525.0
+ arcsec = math.pi / (180.0 * 3600.0)
+ zeta = (2306.2181 * t + 0.30188 * t * t + 0.017998 * t**3) * arcsec
+ z = (2306.2181 * t + 1.09468 * t * t + 0.018203 * t**3) * arcsec
+ theta = (2004.3109 * t - 0.42665 * t * t - 0.041833 * t**3) * arcsec
+
+ ra = math.radians(ra_deg)
+ dec = math.radians(dec_deg)
+ a = math.cos(dec) * math.sin(ra + zeta)
+ b = math.cos(theta) * math.cos(dec) * math.cos(ra + zeta) - math.sin(theta) * math.sin(dec)
+ c = math.sin(theta) * math.cos(dec) * math.cos(ra + zeta) + math.cos(theta) * math.sin(dec)
+ ra_out = math.degrees(math.atan2(a, b) + z) % 360.0
+ dec_out = math.degrees(math.asin(max(-1.0, min(1.0, c))))
+ return ra_out, dec_out
+
+
+def constellation_abbr(ra_deg, dec_deg):
+ """IAU 3-letter abbreviation (e.g. ``"Ori"``) for a J2000 RA/Dec, or None.
+
+ Returns None when coordinates are missing or no boundary row matches (which
+ should not happen for a valid position — the table tiles the whole sphere).
+ """
+ if ra_deg is None or dec_deg is None:
+ return None
+ ra1875, dec1875 = _precess_j2000_to_b1875(ra_deg, dec_deg)
+ ra_h = ra1875 / 15.0 # boundary table RA is in hours
+ for ra_low, ra_high, dec_low, abbr in _boundaries():
+ if dec1875 < dec_low:
+ continue
+ if ra_low <= ra_h < ra_high:
+ return abbr
+ return None
diff --git a/backend/apps/ingest/data/constellation_boundaries.dat b/backend/apps/ingest/data/constellation_boundaries.dat
new file mode 100644
index 0000000..4166150
--- /dev/null
+++ b/backend/apps/ingest/data/constellation_boundaries.dat
@@ -0,0 +1,360 @@
+# IAU constellation boundaries, epoch B1875.0 (Roman 1987).
+# Source: VizieR VI/42 "Identification of a Constellation from Position".
+# Columns: RA_low(h) RA_high(h) Dec_low(deg) IAU_abbrev
+ 0.0000 24.0000 88.0000 UMi
+ 8.0000 14.5000 86.5000 UMi
+ 21.0000 23.0000 86.1667 UMi
+ 18.0000 21.0000 86.0000 UMi
+ 0.0000 8.0000 85.0000 Cep
+ 9.1667 10.6667 82.0000 Cam
+ 0.0000 5.0000 80.0000 Cep
+ 10.6667 14.5000 80.0000 Cam
+ 17.5000 18.0000 80.0000 UMi
+ 20.1667 21.0000 80.0000 Dra
+ 0.0000 3.5083 77.0000 Cep
+ 11.5000 13.5833 77.0000 Cam
+ 16.5333 17.5000 75.0000 UMi
+ 20.1667 20.6667 75.0000 Cep
+ 7.9667 9.1667 73.5000 Cam
+ 9.1667 11.3333 73.5000 Dra
+ 13.0000 16.5333 70.0000 UMi
+ 3.1000 3.4167 68.0000 Cas
+ 20.4167 20.6667 67.0000 Dra
+ 11.3333 12.0000 66.5000 Dra
+ 0.0000 0.3333 66.0000 Cep
+ 14.0000 15.6667 66.0000 UMi
+ 23.5833 24.0000 66.0000 Cep
+ 12.0000 13.5000 64.0000 Dra
+ 13.5000 14.4167 63.0000 Dra
+ 23.1667 23.5833 63.0000 Cep
+ 6.1000 7.0000 62.0000 Cam
+ 20.0000 20.4167 61.5000 Dra
+ 20.5367 20.6000 60.9167 Cep
+ 7.0000 7.9667 60.0000 Cam
+ 7.9667 8.4167 60.0000 UMa
+ 19.7667 20.0000 59.5000 Dra
+ 20.0000 20.5367 59.5000 Cep
+ 22.8667 23.1667 59.0833 Cep
+ 0.0000 2.4333 58.5000 Cas
+ 19.4167 19.7667 58.0000 Dra
+ 1.7000 1.9083 57.5000 Cas
+ 2.4333 3.1000 57.0000 Cas
+ 3.1000 3.1667 57.0000 Cam
+ 22.3167 22.8667 56.2500 Cep
+ 5.0000 6.1000 56.0000 Cam
+ 14.0333 14.4167 55.5000 UMa
+ 14.4167 19.4167 55.5000 Dra
+ 3.1667 3.3333 55.0000 Cam
+ 22.1333 22.3167 55.0000 Cep
+ 20.6000 21.9667 54.8333 Cep
+ 0.0000 1.7000 54.0000 Cas
+ 6.1000 6.5000 54.0000 Lyn
+ 12.0833 13.5000 53.0000 UMa
+ 15.2500 15.7500 53.0000 Dra
+ 21.9667 22.1333 52.7500 Cep
+ 3.3333 5.0000 52.5000 Cam
+ 22.8667 23.3333 52.5000 Cas
+ 15.7500 17.0000 51.5000 Dra
+ 2.0417 2.5167 50.5000 Per
+ 17.0000 18.2333 50.5000 Dra
+ 0.0000 1.3667 50.0000 Cas
+ 1.3667 1.6667 50.0000 Per
+ 6.5000 6.8000 50.0000 Lyn
+ 23.3333 24.0000 50.0000 Cas
+ 13.5000 14.0333 48.5000 UMa
+ 0.0000 1.1167 48.0000 Cas
+ 23.5833 24.0000 48.0000 Cas
+ 18.1750 18.2333 47.5000 Her
+ 18.2333 19.0833 47.5000 Dra
+ 19.0833 19.1667 47.5000 Cyg
+ 1.6667 2.0417 47.0000 Per
+ 8.4167 9.1667 47.0000 UMa
+ 0.1667 0.8667 46.0000 Cas
+ 12.0000 12.0833 45.0000 UMa
+ 6.8000 7.3667 44.5000 Lyn
+ 21.9083 21.9667 44.0000 Cyg
+ 21.8750 21.9083 43.7500 Cyg
+ 19.1667 19.4000 43.5000 Cyg
+ 9.1667 10.1667 42.0000 UMa
+ 10.1667 10.7833 40.0000 UMa
+ 15.4333 15.7500 40.0000 Boo
+ 15.7500 16.3333 40.0000 Her
+ 9.2500 9.5833 39.7500 Lyn
+ 0.0000 2.5167 36.7500 And
+ 2.5167 2.5667 36.7500 Per
+ 19.3583 19.4000 36.5000 Lyr
+ 4.5000 4.6917 36.0000 Per
+ 21.7333 21.8750 36.0000 Cyg
+ 21.8750 22.0000 36.0000 Lac
+ 6.5333 7.3667 35.5000 Aur
+ 7.3667 7.7500 35.5000 Lyn
+ 0.0000 2.0000 35.0000 And
+ 22.0000 22.8167 35.0000 Lac
+ 22.8167 22.8667 34.5000 Lac
+ 22.8667 23.5000 34.5000 And
+ 2.5667 2.7167 34.0000 Per
+ 10.7833 11.0000 34.0000 UMa
+ 12.0000 12.3333 34.0000 CVn
+ 7.7500 9.2500 33.5000 Lyn
+ 9.2500 9.8833 33.5000 LMi
+ 0.7167 1.4083 33.0000 And
+ 15.1833 15.4333 33.0000 Boo
+ 23.5000 23.7500 32.0833 And
+ 12.3333 13.2500 32.0000 CVn
+ 23.7500 24.0000 31.3333 And
+ 13.9583 14.0333 30.7500 CVn
+ 2.4167 2.7167 30.6667 Tri
+ 2.7167 4.5000 30.6667 Per
+ 4.5000 4.7500 30.0000 Aur
+ 18.1750 19.3583 30.0000 Lyr
+ 11.0000 12.0000 29.0000 UMa
+ 19.6667 20.9167 29.0000 Cyg
+ 4.7500 5.8833 28.5000 Aur
+ 9.8833 10.5000 28.5000 LMi
+ 13.2500 13.9583 28.5000 CVn
+ 0.0000 0.0667 28.0000 And
+ 1.4083 1.6667 28.0000 Tri
+ 5.8833 6.5333 28.0000 Aur
+ 7.8833 8.0000 28.0000 Gem
+ 20.9167 21.7333 28.0000 Cyg
+ 19.2583 19.6667 27.5000 Cyg
+ 1.9167 2.4167 27.2500 Tri
+ 16.1667 16.3333 27.0000 CrB
+ 15.0833 15.1833 26.0000 Boo
+ 15.1833 16.1667 26.0000 CrB
+ 18.3667 18.8667 26.0000 Lyr
+ 10.7500 11.0000 25.5000 LMi
+ 18.8667 19.2583 25.5000 Lyr
+ 1.6667 1.9167 25.0000 Tri
+ 0.7167 0.8500 23.7500 Psc
+ 10.5000 10.7500 23.5000 LMi
+ 21.2500 21.4167 23.5000 Vul
+ 5.7000 5.8833 22.8333 Tau
+ 0.0667 0.1417 22.0000 And
+ 15.9167 16.0333 22.0000 Ser
+ 5.8833 6.2167 21.5000 Gem
+ 19.8333 20.2500 21.2500 Vul
+ 18.8667 19.2500 21.0833 Vul
+ 0.1417 0.8500 21.0000 And
+ 20.2500 20.5667 20.5000 Vul
+ 7.8083 7.8833 20.0000 Gem
+ 20.5667 21.2500 19.5000 Vul
+ 19.2500 19.8333 19.1667 Vul
+ 3.2833 3.3667 19.0000 Ari
+ 18.8667 19.0000 18.5000 Sge
+ 5.7000 5.7667 18.0000 Ori
+ 6.2167 6.3083 17.5000 Gem
+ 19.0000 19.8333 16.1667 Sge
+ 4.9667 5.3333 16.0000 Tau
+ 15.9167 16.0833 16.0000 Her
+ 19.8333 20.2500 15.7500 Sge
+ 4.6167 4.9667 15.5000 Tau
+ 5.3333 5.6000 15.5000 Tau
+ 12.8333 13.5000 15.0000 Com
+ 17.2500 18.2500 14.3333 Her
+ 11.8667 12.8333 14.0000 Com
+ 7.5000 7.8083 13.5000 Gem
+ 16.7500 17.2500 12.8333 Her
+ 0.0000 0.1417 12.5000 Peg
+ 5.6000 5.7667 12.5000 Tau
+ 7.0000 7.5000 12.5000 Gem
+ 21.1167 21.3333 12.5000 Peg
+ 6.3083 6.9333 12.0000 Gem
+ 18.2500 18.8667 12.0000 Her
+ 20.8750 21.0500 11.8333 Del
+ 21.0500 21.1167 11.8333 Peg
+ 11.5167 11.8667 11.0000 Leo
+ 6.2417 6.3083 10.0000 Ori
+ 6.9333 7.0000 10.0000 Gem
+ 7.8083 7.9250 10.0000 Cnc
+ 23.8333 24.0000 10.0000 Peg
+ 1.6667 3.2833 9.9167 Ari
+ 20.1417 20.3000 8.5000 Del
+ 13.5000 15.0833 8.0000 Boo
+ 22.7500 23.8333 7.5000 Peg
+ 7.9250 9.2500 7.0000 Cnc
+ 9.2500 10.7500 7.0000 Leo
+ 18.2500 18.6622 6.2500 Oph
+ 18.6622 18.8667 6.2500 Aql
+ 20.8333 20.8750 6.0000 Del
+ 7.0000 7.0167 5.5000 CMi
+ 18.2500 18.4250 4.5000 Ser
+ 16.0833 16.7500 4.0000 Her
+ 18.2500 18.4250 3.0000 Oph
+ 21.4667 21.6667 2.7500 Peg
+ 0.0000 2.0000 2.0000 Psc
+ 18.5833 18.8667 2.0000 Ser
+ 20.3000 20.8333 2.0000 Del
+ 20.8333 21.3333 2.0000 Equ
+ 21.3333 21.4667 2.0000 Peg
+ 22.0000 22.7500 2.0000 Peg
+ 21.6667 22.0000 1.7500 Peg
+ 7.0167 7.2000 1.5000 CMi
+ 3.5833 4.6167 0.0000 Tau
+ 4.6167 4.6667 0.0000 Ori
+ 7.2000 8.0833 0.0000 CMi
+ 14.6667 15.0833 0.0000 Vir
+ 17.8333 18.2500 0.0000 Oph
+ 2.6500 3.2833 -01.7500 Cet
+ 3.2833 3.5833 -01.7500 Tau
+ 15.0833 16.2667 -03.2500 Ser
+ 4.6667 5.0833 -04.0000 Ori
+ 5.8333 6.2417 -04.0000 Ori
+ 17.8333 17.9667 -04.0000 Ser
+ 18.2500 18.5833 -04.0000 Ser
+ 18.5833 18.8667 -04.0000 Aql
+ 22.7500 23.8333 -04.0000 Psc
+ 10.7500 11.5167 -06.0000 Leo
+ 11.5167 11.8333 -06.0000 Vir
+ 0.0000 00.3333 -07.0000 Psc
+ 23.8333 24.0000 -07.0000 Psc
+ 14.2500 14.6667 -08.0000 Vir
+ 15.9167 16.2667 -08.0000 Oph
+ 20.0000 20.5333 -09.0000 Aql
+ 21.3333 21.8667 -09.0000 Aqr
+ 17.1667 17.9667 -10.0000 Oph
+ 5.8333 8.0833 -11.0000 Mon
+ 4.9167 5.0833 -11.0000 Eri
+ 5.0833 5.8333 -11.0000 Ori
+ 8.0833 8.3667 -11.0000 Hya
+ 9.5833 10.7500 -11.0000 Sex
+ 11.8333 12.8333 -11.0000 Vir
+ 17.5833 17.6667 -11.6667 Oph
+ 18.8667 20.0000 -12.0333 Aql
+ 4.8333 4.9167 -14.5000 Eri
+ 20.5333 21.3333 -15.0000 Aqr
+ 17.1667 18.2500 -16.0000 Ser
+ 18.2500 18.8667 -16.0000 Sct
+ 8.3667 8.5833 -17.0000 Hya
+ 16.2667 16.3750 -18.2500 Oph
+ 8.5833 9.0833 -19.0000 Hya
+ 10.7500 10.8333 -19.0000 Crt
+ 16.2667 16.3750 -19.2500 Sco
+ 15.6667 15.9167 -20.0000 Lib
+ 12.5833 12.8333 -22.0000 Crv
+ 12.8333 14.2500 -22.0000 Vir
+ 9.0833 9.7500 -24.0000 Hya
+ 1.6667 2.6500 -24.3833 Cet
+ 2.6500 3.7500 -24.3833 Eri
+ 10.8333 11.8333 -24.5000 Crt
+ 11.8333 12.5833 -24.5000 Crv
+ 14.2500 14.9167 -24.5000 Lib
+ 16.2667 16.7500 -24.5833 Oph
+ 0.0000 1.6667 -25.5000 Cet
+ 21.3333 21.8667 -25.5000 Cap
+ 21.8667 23.8333 -25.5000 Aqr
+ 23.8333 24.0000 -25.5000 Cet
+ 9.7500 10.2500 -26.5000 Hya
+ 4.7000 4.8333 -27.2500 Eri
+ 4.8333 6.1167 -27.2500 Lep
+ 20.0000 21.3333 -28.0000 Cap
+ 10.2500 10.5833 -29.1667 Hya
+ 12.5833 14.9167 -29.5000 Hya
+ 14.9167 15.6667 -29.5000 Lib
+ 15.6667 16.0000 -29.5000 Sco
+ 4.5833 4.7000 -30.0000 Eri
+ 16.7500 17.6000 -30.0000 Oph
+ 17.6000 17.8333 -30.0000 Sgr
+ 10.5833 10.8333 -31.1667 Hya
+ 6.1167 7.3667 -33.0000 CMa
+ 12.2500 12.5833 -33.0000 Hya
+ 10.8333 12.2500 -35.0000 Hya
+ 3.5000 3.7500 -36.0000 For
+ 8.3667 9.3667 -36.7500 Pyx
+ 4.2667 4.5833 -37.0000 Eri
+ 17.8333 19.1667 -37.0000 Sgr
+ 21.3333 23.0000 -37.0000 PsA
+ 23.0000 23.3333 -37.0000 Scl
+ 3.0000 3.5000 -39.5833 For
+ 9.3667 11.0000 -39.7500 Ant
+ 0.0000 1.6667 -40.0000 Scl
+ 1.6667 3.0000 -40.0000 For
+ 3.8667 4.2667 -40.0000 Eri
+ 23.3333 24.0000 -40.0000 Scl
+ 14.1667 14.9167 -42.0000 Cen
+ 15.6667 16.0000 -42.0000 Lup
+ 16.0000 16.4208 -42.0000 Sco
+ 4.8333 5.0000 -43.0000 Cae
+ 5.0000 6.5833 -43.0000 Col
+ 8.0000 8.3667 -43.0000 Pup
+ 3.4167 3.8667 -44.0000 Eri
+ 16.4208 17.8333 -45.5000 Sco
+ 17.8333 19.1667 -45.5000 CrA
+ 19.1667 20.3333 -45.5000 Sgr
+ 20.3333 21.3333 -45.5000 Mic
+ 3.0000 3.4167 -46.0000 Eri
+ 4.5000 4.8333 -46.5000 Cae
+ 15.3333 15.6667 -48.0000 Lup
+ 0.0000 2.3333 -48.1667 Phe
+ 2.6667 3.0000 -49.0000 Eri
+ 4.0833 4.2667 -49.0000 Hor
+ 4.2667 4.5000 -49.0000 Cae
+ 21.3333 22.0000 -50.0000 Gru
+ 6.0000 8.0000 -50.7500 Pup
+ 8.0000 8.1667 -50.7500 Vel
+ 2.4167 2.6667 -51.0000 Eri
+ 3.8333 4.0833 -51.0000 Hor
+ 0.0000 1.8333 -51.5000 Phe
+ 6.0000 6.1667 -52.5000 Car
+ 8.1667 8.4500 -53.0000 Vel
+ 3.5000 3.8333 -53.1667 Hor
+ 3.8333 4.0000 -53.1667 Dor
+ 0.0000 1.5833 -53.5000 Phe
+ 2.1667 2.4167 -54.0000 Eri
+ 4.5000 5.0000 -54.0000 Pic
+ 15.0500 15.3333 -54.0000 Lup
+ 8.4500 8.8333 -54.5000 Vel
+ 6.1667 6.5000 -55.0000 Car
+ 11.8333 12.8333 -55.0000 Cen
+ 14.1667 15.0500 -55.0000 Lup
+ 15.0500 15.3333 -55.0000 Nor
+ 4.0000 4.3333 -56.5000 Dor
+ 8.8333 11.0000 -56.5000 Vel
+ 11.0000 11.2500 -56.5000 Cen
+ 17.5000 18.0000 -57.0000 Ara
+ 18.0000 20.3333 -57.0000 Tel
+ 22.0000 23.3333 -57.0000 Gru
+ 3.2000 3.5000 -57.5000 Hor
+ 5.0000 5.5000 -57.5000 Pic
+ 6.5000 6.8333 -58.0000 Car
+ 0.0000 1.3333 -58.5000 Phe
+ 1.3333 2.1667 -58.5000 Eri
+ 23.3333 24.0000 -58.5000 Phe
+ 4.3333 4.5833 -59.0000 Dor
+ 15.3333 16.4208 -60.0000 Nor
+ 20.3333 21.3333 -60.0000 Ind
+ 5.5000 6.0000 -61.0000 Pic
+ 15.1667 15.3333 -61.0000 Cir
+ 16.4208 16.5833 -61.0000 Ara
+ 14.9167 15.1667 -63.5833 Cir
+ 16.5833 16.7500 -63.5833 Ara
+ 6.0000 6.8333 -64.0000 Pic
+ 6.8333 9.0333 -64.0000 Car
+ 11.2500 11.8333 -64.0000 Cen
+ 11.8333 12.8333 -64.0000 Cru
+ 12.8333 14.5333 -64.0000 Cen
+ 13.5000 13.6667 -65.0000 Cir
+ 16.7500 16.8333 -65.0000 Ara
+ 2.1667 3.2000 -67.5000 Hor
+ 3.2000 4.5833 -67.5000 Ret
+ 14.7500 14.9167 -67.5000 Cir
+ 16.8333 17.5000 -67.5000 Ara
+ 17.5000 18.0000 -67.5000 Pav
+ 22.0000 23.3333 -67.5000 Tuc
+ 4.5833 6.5833 -70.0000 Dor
+ 13.6667 14.7500 -70.0000 Cir
+ 14.7500 17.0000 -70.0000 TrA
+ 0.0000 1.3333 -75.0000 Tuc
+ 3.5000 4.5833 -75.0000 Hyi
+ 6.5833 9.0333 -75.0000 Vol
+ 9.0333 11.2500 -75.0000 Car
+ 11.2500 13.6667 -75.0000 Mus
+ 18.0000 21.3333 -75.0000 Pav
+ 21.3333 23.3333 -75.0000 Ind
+ 23.3333 24.0000 -75.0000 Tuc
+ 0.7500 1.3333 -76.0000 Tuc
+ 0.0000 3.5000 -82.5000 Hyi
+ 7.6667 13.6667 -82.5000 Cha
+ 13.6667 18.0000 -82.5000 Aps
+ 3.5000 7.6667 -85.0000 Men
+ 0.0000 24.0000 -90.0000 Oct
diff --git a/backend/apps/ingest/migrations/0003_parsedmeasurement_constellation.py b/backend/apps/ingest/migrations/0003_parsedmeasurement_constellation.py
new file mode 100644
index 0000000..a9bbd2d
--- /dev/null
+++ b/backend/apps/ingest/migrations/0003_parsedmeasurement_constellation.py
@@ -0,0 +1,20 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("ingest", "0002_parsedmeasurement"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="parsedmeasurement",
+ name="start_constellation",
+ field=models.CharField(blank=True, default="", max_length=3),
+ ),
+ migrations.AddField(
+ model_name="parsedmeasurement",
+ name="end_constellation",
+ field=models.CharField(blank=True, default="", max_length=3),
+ ),
+ ]
diff --git a/backend/apps/ingest/models.py b/backend/apps/ingest/models.py
index 51bfe11..933c807 100644
--- a/backend/apps/ingest/models.py
+++ b/backend/apps/ingest/models.py
@@ -71,6 +71,10 @@ class ParsedMeasurement(models.Model):
end_ra = models.FloatField(null=True, blank=True)
end_dec = models.FloatField(null=True, blank=True)
+ # IAU constellation (3-letter abbreviation) each trail endpoint falls in.
+ start_constellation = models.CharField(max_length=3, blank=True, default="")
+ end_constellation = models.CharField(max_length=3, blank=True, default="")
+
lat = models.FloatField(null=True, blank=True)
lon = models.FloatField(null=True, blank=True)
accuracy = models.FloatField(null=True, blank=True)
diff --git a/backend/apps/ingest/parser.py b/backend/apps/ingest/parser.py
index 109d0f6..65ca911 100644
--- a/backend/apps/ingest/parser.py
+++ b/backend/apps/ingest/parser.py
@@ -23,8 +23,11 @@
import math
from datetime import UTC, datetime
+from .constellation import constellation_abbr
+
# Bump when the parsing logic changes so stored records can be re-derived.
-PARSE_VERSION = 1
+# v2: added start/end IAU constellation.
+PARSE_VERSION = 2
class ParseError(ValueError):
@@ -191,6 +194,9 @@ def parse_and_store(raw):
data = dict(fields)
data["start_ra"], data["start_dec"] = start or (None, None)
data["end_ra"], data["end_dec"] = end or (None, None)
+ # Constellation each endpoint falls in (needs the derived RA/Dec).
+ data["start_constellation"] = constellation_abbr(data["start_ra"], data["start_dec"]) or ""
+ data["end_constellation"] = constellation_abbr(data["end_ra"], data["end_dec"]) or ""
# UTC is already absolute (epoch ms); resolve the site's civil time zone so
# the observer's local time can be shown alongside it.
data["event_tz"] = site_timezone(fields["lat"], fields["lon"]) or ""
diff --git a/backend/apps/web/api.py b/backend/apps/web/api.py
index fc423ed..3098763 100644
--- a/backend/apps/web/api.py
+++ b/backend/apps/web/api.py
@@ -67,6 +67,7 @@ class TrailPoint(Schema):
az: float | None = None
ra: float | None = None # equatorial, degrees (for the star map)
dec: float | None = None
+ constellation: str | None = None # IAU 3-letter abbreviation (e.g. "Ori")
class ReportDetail(Schema):
@@ -265,10 +266,12 @@ def report_detail(request, client_key: str):
"start": {
"alt": parsed.start_alt, "az": parsed.start_az,
"ra": parsed.start_ra, "dec": parsed.start_dec,
+ "constellation": parsed.start_constellation or None,
},
"end": {
"alt": parsed.end_alt, "az": parsed.end_az,
"ra": parsed.end_ra, "dec": parsed.end_dec,
+ "constellation": parsed.end_constellation or None,
},
}
diff --git a/backend/tests/test_parser.py b/backend/tests/test_parser.py
index d8b505b..3f3f285 100644
--- a/backend/tests/test_parser.py
+++ b/backend/tests/test_parser.py
@@ -7,6 +7,7 @@
from apps.devices.models import Device
from apps.ingest import parser
+from apps.ingest.constellation import constellation_abbr
from apps.ingest.models import RawIngest
from tests.test_auth_sync import _register, _token
@@ -59,6 +60,24 @@ def test_altaz_to_radec_without_location_is_none():
assert parser.altaz_to_radec(40, 90, None, None, when) is None
+@pytest.mark.parametrize(
+ "ra,dec,abbr",
+ [
+ (88.79, 7.41, "Ori"), # Betelgeuse
+ (37.95, 89.26, "UMi"), # Polaris
+ (279.23, 38.78, "Lyr"), # Vega
+ (101.29, -16.72, "CMa"), # Sirius
+ (247.35, -26.43, "Sco"), # Antares
+ ],
+)
+def test_constellation_for_known_stars(ra, dec, abbr):
+ assert constellation_abbr(ra, dec) == abbr
+
+
+def test_constellation_missing_coords_is_none():
+ assert constellation_abbr(None, None) is None
+
+
# ---- persistence + endpoint ----
def _device(client):
@@ -90,6 +109,7 @@ def test_parse_and_store_creates_record():
parsed = parser.parse_and_store(raw)
assert parsed.start_ra is not None and parsed.end_dec is not None
+ assert parsed.start_constellation and parsed.end_constellation # 3-letter IAU abbr
assert parsed.event_time is not None and parsed.parse_version == parser.PARSE_VERSION
raw.refresh_from_db()
assert raw.status == RawIngest.STATUS_PROCESSED
@@ -108,6 +128,7 @@ def test_report_detail_endpoint_returns_render_payload():
body = res.json()
assert body["status"] == "processed"
assert body["start"]["alt"] == 40.0 and body["start"]["ra"] is not None
+ assert body["start"]["constellation"] and body["end"]["constellation"]
assert body["end"]["az"] == 95.0
assert body["event_utc"] is not None
if _HAS_TZF:
diff --git a/frontend/app.js b/frontend/app.js
index 1cf2a37..8f04b3e 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -81,16 +81,31 @@ async function selectReport(key, tr) {
}
function showSkyCaption(d) {
- const cap = $('skyCaption');
const rendered = d ? MeteorSky.render(d) : false;
- if (!d) { cap.textContent = t('sky.pick'); return; }
- if (!rendered) { cap.textContent = t('sky.noCoords'); return; }
+ if (!d) { $('skyCaption').textContent = t('sky.pick'); return; }
+ if (!rendered) { $('skyCaption').textContent = t('sky.noCoords'); return; }
+ renderCaptionText(d);
+}
+
+// Build just the caption text (no sky re-render), so it can be refreshed when
+// the sky data loads or the language changes without re-triggering the dome.
+function renderCaptionText(d) {
+ const cap = $('skyCaption');
const tz = d.event_tz ? ` (${d.event_tz})` : '';
const s = d.start, e = d.end;
cap.innerHTML =
`${fmtLocal(d.event_local || d.event_utc)}${tz}
` +
- `${t('sky.startLabel')} ALT/AZ ${fmt(s.alt)}° / ${fmt(s.az)}° · RA/Dek ${fmt(s.ra, 1)}° / ${fmt(s.dec, 1)}°
` +
- `${t('sky.endLabel')} ALT/AZ ${fmt(e.alt)}° / ${fmt(e.az)}° · RA/Dek ${fmt(e.ra, 1)}° / ${fmt(e.dec, 1)}°`;
+ `${t('sky.startLabel')} ALT/AZ ${fmt(s.alt)}° / ${fmt(s.az)}° · RA/Dek ${fmt(s.ra, 1)}° / ${fmt(s.dec, 1)}°${constSuffix(s)}
` +
+ `${t('sky.endLabel')} ALT/AZ ${fmt(e.alt)}° / ${fmt(e.az)}° · RA/Dek ${fmt(e.ra, 1)}° / ${fmt(e.dec, 1)}°${constSuffix(e)}`;
+}
+
+// "· in " suffix for a trail endpoint, when known. The full
+// localized name comes from the sky data; until that loads we show the bare
+// abbreviation, then refresh the caption via onSkyData once it is available.
+function constSuffix(pt) {
+ if (!pt || !pt.constellation) return '';
+ const name = window.MeteorSky ? MeteorSky.constNameByAbbr(pt.constellation) : pt.constellation;
+ return ` · ${t('sky.in')} ${name}`;
}
let pollTimer = null;
@@ -121,6 +136,12 @@ async function startLogin() {
}, (interval || 2) * 1000);
}
+// Sky data finished loading: re-render the caption so constellation
+// abbreviations are replaced by their localized full names.
+window.onSkyData = function (detail) {
+ if (selectedDetail && detail === selectedDetail) renderCaptionText(selectedDetail);
+};
+
// Re-apply language-dependent dynamic content whenever the language changes.
window.onI18nApplied = function () {
refreshGridIntro();
diff --git a/frontend/i18n.js b/frontend/i18n.js
index dd6ad1e..0370a2d 100644
--- a/frontend/i18n.js
+++ b/frontend/i18n.js
@@ -24,7 +24,7 @@
loading: 'Načítám…',
loadFail: 'Detail měření se nepodařilo načíst.',
noCoords: 'Měření nemá GPS souřadnice nebo čas — stopu na obloze nelze vykreslit.',
- startLabel: 'Start', endLabel: 'Konec',
+ startLabel: 'Start', endLabel: 'Konec', in: 'v souhvězdí',
hint: 'Kolečkem přiblížíš, tažením posuneš, dvojklikem resetuješ pohled.',
},
dir: { N: 'S', NE: 'SV', E: 'V', SE: 'JV', S: 'J', SW: 'JZ', W: 'Z', NW: 'SZ' },
@@ -60,7 +60,7 @@
loading: 'Loading…',
loadFail: 'Could not load the measurement detail.',
noCoords: 'This measurement has no GPS location or time — the trail cannot be drawn.',
- startLabel: 'Start', endLabel: 'End',
+ startLabel: 'Start', endLabel: 'End', in: 'in',
hint: 'Scroll to zoom, drag to pan, double-click to reset the view.',
},
dir: { N: 'N', NE: 'NE', E: 'E', SE: 'SE', S: 'S', SW: 'SW', W: 'W', NW: 'NW' },
diff --git a/frontend/sky.js b/frontend/sky.js
index 741f6f8..ed1fc08 100644
--- a/frontend/sky.js
+++ b/frontend/sky.js
@@ -39,6 +39,13 @@
return { ra: f.geometry.coordinates[0], dec: f.geometry.coordinates[1], props: f.properties };
}),
};
+ // Index constellation props by IAU abbreviation (e.g. "Ori") so the
+ // caption can show a localized full name for a detected constellation.
+ dataCache.byAbbr = {};
+ res[2].features.forEach(function (f) {
+ var k = (f.properties.desig || f.id || '').toLowerCase();
+ if (k) dataCache.byAbbr[k] = f.properties;
+ });
return dataCache;
}).catch(function () { dataCache = { stars: [], lines: [], names: [] }; return dataCache; });
}
@@ -69,6 +76,13 @@
];
function dirLabel(k) { return global.MPI18n ? global.MPI18n.t('dir.' + k) : k; }
function constName(p) { var k = (global.MPI18n && global.MPI18n.lang === 'en') ? 'en' : 'cz'; return p[k] || p.en || p.name || ''; }
+ // Localized full constellation name for an IAU abbreviation (e.g. "Ori" ->
+ // "Orion"/"Orion"); falls back to the abbreviation until the data has loaded.
+ function constNameByAbbr(abbr) {
+ if (!abbr) return '';
+ var p = dataCache && dataCache.byAbbr && dataCache.byAbbr[String(abbr).toLowerCase()];
+ return p ? constName(p) : abbr;
+ }
function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); }
function build(detail, data) {
@@ -211,6 +225,9 @@
if (MeteorSky._detail !== detail) return;
var host = document.getElementById('skymap'); if (host) bind(host);
build(detail, data); reset(); paint();
+ // Names are now available; let the caption resolve localized constellation
+ // names (it may have rendered with bare abbreviations before the fetch).
+ if (typeof global.onSkyData === 'function') global.onSkyData(detail);
});
return true;
}
@@ -226,5 +243,8 @@
else { zoomAt(host.clientWidth / 2, act === 'in' ? 1.3 : 1 / 1.3); paint(); }
});
- global.MeteorSky = { render: render, redraw: redraw, refit: refit, _detail: null };
+ global.MeteorSky = {
+ render: render, redraw: redraw, refit: refit,
+ constNameByAbbr: constNameByAbbr, _detail: null,
+ };
})(window);