-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_objects.py
More file actions
553 lines (437 loc) · 23.9 KB
/
Copy pathtest_objects.py
File metadata and controls
553 lines (437 loc) · 23.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
"""Tests for objects.py and the four catalogue files behind it.
The constellation test is the load-bearing one: it checks our boundary lookup
against BSC5's own constellation assignment for every star that carries one,
which is 2,121 independent cases rather than a handful of hand-picked vectors.
"""
import datetime as dt
import json
import math
import pytest
import objects
import sky
ZURICH = (47.38, 8.54)
WHEN = dt.datetime(2026, 8, 5)
def _target(name, lat=ZURICH[0], lon=ZURICH[1], when=WHEN):
jd = sky.julian(when)
return sky.resolve_target(name, jd, lat, (sky.gmst_hours(jd) + lon / 15.0) % 24)
# ---------------------------------------------------------- constellations
def test_constellation_agrees_with_bsc5_for_every_star():
"""The whole catalogue, not a sample. BSC5 records which constellation
each star belongs to; we derive it from the IAU boundaries. They should
never disagree, and if they do the boundary scan or the precession is
wrong."""
stars = [s for s in sky._load("stars.json") if s.get("c")]
assert len(stars) > 2000, "expected the bright-star catalogue to be loaded"
bad = [(s.get("n") or s["hr"], s["c"], objects.constellation(s["ra"], s["de"]))
for s in stars
if (objects.constellation(s["ra"], s["de"]) or "").lower() != s["c"].lower()]
assert not bad, f"{len(bad)} stars in the wrong constellation, e.g. {bad[:5]}"
@pytest.mark.parametrize("ra_h, dec, want", [
(5.919, 7.407, "Ori"), # Betelgeuse
(6.752, -16.716, "CMa"), # Sirius
(18.615, 38.784, "Lyr"), # Vega
(2.530, 89.264, "UMi"), # Polaris, hard case: precession near the pole
(0.712, 41.269, "And"), # M31
])
def test_constellation_known_objects(ra_h, dec, want):
assert objects.constellation(ra_h, dec) == want
def test_constellation_covers_the_whole_sphere():
"""The boundaries tile the sky, so no position may come back None."""
misses = [(ra, dec)
for ra in [h * 0.5 for h in range(48)]
for dec in range(-89, 90, 7)
if objects.constellation(ra, dec) is None]
assert not misses, f"{len(misses)} positions fell outside every boundary"
def test_boundary_table_order_is_preserved():
"""Roman's arrangement is scanned top down and the first match wins, so
the file must stay in its original order. Sorting it would silently give
wrong answers rather than fail, which is why this is a test."""
rows = sky._load("constellations.json")
assert len(rows) > 300
decs = [r[2] for r in rows]
assert decs != sorted(decs), "boundary file looks sorted; the lookup needs source order"
assert decs[0] > decs[-1], "expected the table to run north to south"
# ------------------------------------------------------------- star extras
def test_starinfo_covers_the_chart():
info = sky._load("starinfo.json")
stars = sky._load("stars.json")
assert len(info) > 2000
# Every key must be a star we actually draw; a stray HR number means the
# join in build_starinfo.py drifted.
known = {str(s["hr"]) for s in stars}
assert set(info) <= known
def test_every_starinfo_row_has_something_worth_having():
for hr, rec in sky._load("starinfo.json").items():
assert rec, f"HR {hr} has an empty record"
assert set(rec) <= {"sp", "ly", "ly_err", "sep", "dmag", "var"}
# Distance and its error travel together or not at all.
assert ("ly" in rec) == ("ly_err" in rec)
@pytest.mark.parametrize("name, low, high", [
("Sirius", 8.0, 9.5),
("Vega", 24.0, 27.0),
("Arcturus", 34.0, 40.0),
("Aldebaran", 60.0, 70.0),
])
def test_known_star_distances(name, low, high):
"""Nearby stars have parallaxes good to a fraction of a percent, so these
should land on the published values."""
hr = next(s["hr"] for s in sky._load("stars.json") if s.get("n") == name)
got = objects.distance_ly(hr)
assert got is not None, f"no distance for {name}"
assert low <= got[0] <= high, f"{name}: {got[0]} ly outside {low}-{high}"
assert got[1] == "good"
def test_distant_stars_are_flagged_as_uncertain():
"""Deneb's parallax carries a 56% error. The point of shipping the error
is that the page can decline to state a figure, so this must not come
back as 'good'."""
hr = next(s["hr"] for s in sky._load("stars.json") if s.get("n") == "Deneb")
got = objects.distance_ly(hr)
if got is not None: # absent is also an acceptable answer
assert got[1] != "good"
def test_spectral_types_look_like_spectral_types():
info = sky._load("starinfo.json")
sp = [r["sp"] for r in info.values() if "sp" in r]
assert len(sp) > 2000
# Harvard classes, plus the older Yerkes luminosity prefixes BSC5 still
# carries for 37 stars -- "gK4" is a K4 giant, "sgG9" a G9 subgiant,
# "dF5" a dwarf, "cK2" a supergiant. Anything rendering the spectral type
# has to strip those before reading the class off the front.
lead = "OBAFGKMSCRNWD+pe" + "gsdc"
odd = [s for s in sp if s[0] not in lead]
assert not odd, f"unexpected leading character: {odd[:5]}"
def test_star_info_is_empty_not_an_error_for_unknown_hr():
assert objects.star_info(999999) == {}
assert objects.variable_info(999999) == {}
assert objects.distance_ly(999999) is None
# --------------------------------------------------------------- variables
def test_algol_period():
"""Algol is the reason this file exists: a 2.867-day eclipsing binary
whose next minimum is fully predictable."""
hr = next(s["hr"] for s in sky._load("stars.json") if s.get("n") == "Algol")
rec = objects.variable_info(hr)
assert rec.get("period") == pytest.approx(2.8673, abs=1e-3)
assert rec.get("max") == pytest.approx(2.12, abs=0.05)
assert rec.get("min") == pytest.approx(3.39, abs=0.05)
def test_variables_are_all_stars_we_draw():
known = {str(s["hr"]) for s in sky._load("stars.json")}
assert set(sky._load("variables.json")) <= known
def test_variable_ranges_are_the_right_way_round():
"""max is the brightest magnitude, min the faintest, so numerically
max < min. Getting these backwards would invert every range on the site."""
for hr, rec in sky._load("variables.json").items():
if "max" in rec and "min" in rec:
assert rec["max"] <= rec["min"], f"HR {hr} range inverted"
def test_amplitudes_are_not_stored_as_minima():
"""GCVS puts the amplitude of the variation in the same column as the
magnitude at minimum, distinguished only by a flag. Misreading one as the
other made Epsilon Eridani vary between magnitude 3.73 and 0.05, which
would be the brightest object in the night sky."""
v = sky._load("variables.json")
assert any("amp" in r for r in v.values()), "no amplitudes recorded at all"
for hr, rec in v.items():
# An amplitude is a difference, so it is small; a minimum magnitude
# for anything in this catalogue is not.
if "amp" in rec:
assert rec["amp"] < 12, f"HR {hr} amplitude {rec['amp']} looks like a magnitude"
assert not ("amp" in rec and "min" in rec), f"HR {hr} has both"
def test_epsilon_eridani_amplitude_specifically():
"""The star that exposed the bug."""
hr = next((s["hr"] for s in sky._load("stars.json")
if s.get("n") == "Epsilon Eridani"), None)
if hr is None:
pytest.skip("Epsilon Eridani not in stars.json under that name")
rec = objects.variable_info(hr)
assert "min" not in rec, "0.05 must not be recorded as a minimum magnitude"
# -------------------------------------------------------------- deep sky
def test_dso_sizes_land_on_the_right_objects():
"""deepsky.json's name field is not unique -- NGC205 is labelled M31 and
NGC595 is labelled M33 -- so a size joined by name alone lands on the
wrong object. M31 must be the big one."""
assert objects.dso_size("NGC224").get("maj") == pytest.approx(178.0)
assert objects.dso_size("NGC205").get("maj") == pytest.approx(17.0)
assert objects.dso_size("NGC598").get("maj") == pytest.approx(73.0)
def test_dso_sizes_reference_real_objects():
known = {o["id"] for o in sky._load("deepsky.json")}
assert set(sky._load("dsoinfo.json")) <= known
def test_dso_minor_axis_never_exceeds_major():
for oid, rec in sky._load("dsoinfo.json").items():
if "min" in rec:
assert rec["min"] <= rec["maj"], f"{oid} minor axis larger than major"
def test_dso_size_is_empty_not_an_error_for_unknown_id():
assert objects.dso_size("NGC999999") == {}
# -------------------------------------------------- rise, transit and set
def test_sun_rise_and_set_agree_with_sky_py():
"""sky.py samples the Sun's arc at ten-minute steps and interpolates;
this is closed form. They should land within a couple of minutes, and a
larger gap means one of them is wrong."""
mine = objects.rise_transit_set(_target("Sun"), *ZURICH, WHEN)
theirs = sky.sun_events(WHEN, *ZURICH)
for ours, name in ((mine["rise"], "sunrise"), (mine["set"], "sunset")):
gap = abs((ours - theirs[name]).total_seconds()) / 60
assert gap < 5, f"{name} differs by {gap:.1f} minutes"
def test_transit_altitude_is_the_geometric_one():
"""An object's greatest altitude is 90 - |latitude - declination|,
exactly. Anything else means the transit search drifted."""
for name in ("Sirius", "Vega", "M31"):
t = _target(name)
got = objects.rise_transit_set(t, *ZURICH, WHEN)
want = 90.0 - abs(ZURICH[0] - t["dec"])
assert got["transit_alt"] == pytest.approx(want, abs=0.6)
def test_rise_and_set_straddle_transit():
got = objects.rise_transit_set(_target("Sirius"), *ZURICH, WHEN)
assert got["rise"] < got["transit"] < got["set"]
assert got["up_hours"] == pytest.approx(
(got["set"] - got["rise"]).total_seconds() / 3600, abs=0.05)
@pytest.mark.parametrize("name, lat, expect", [
("Polaris", 70, "circumpolar"),
("Polaris", -33, "never_rises"),
("Southern Cross", 47.38, "never_rises"),
("Southern Cross", -33, "circumpolar"),
])
def test_circumpolar_and_never_rising(name, lat, expect):
"""At extreme latitudes half the catalogue is one or the other, and these
are answers rather than errors."""
got = objects.rise_transit_set(_target(name, lat=lat), lat, 8.54, WHEN)
assert got.get(expect) is True
assert "transit" in got, "a transit time is still meaningful either way"
@pytest.mark.parametrize("date, expect", [
(dt.datetime(2026, 6, 21), "circumpolar"), # midnight sun
(dt.datetime(2026, 12, 21), "never_rises"), # polar night
])
def test_arctic_sun(date, expect):
lat, lon = 69.6, 18.96 # Tromso
got = objects.rise_transit_set(_target("Sun", lat=lat, lon=lon, when=date),
lat, lon, date)
assert got.get(expect) is True
# ------------------------------------------------------ best night of year
def test_best_night_lands_in_the_right_season():
"""Andromeda is an autumn object from the northern hemisphere and Orion a
winter one. If these drift into the wrong half of the year the scan is
weighting something incorrectly."""
m31 = objects.best_this_year(_target("M31"), *ZURICH, WHEN)
assert m31["when_utc"].month in (9, 10, 11, 12)
orion = objects.best_this_year(_target("Orion Nebula"), *ZURICH, WHEN)
assert orion["when_utc"].month in (11, 12, 1, 2)
def test_best_night_prefers_a_dark_moon():
"""Moonlight is the discount in the score, so the winner should never be
a night with a bright Moon when a darker one was available."""
for name in ("M31", "M13", "Orion Nebula"):
got = objects.best_this_year(_target(name), *ZURICH, WHEN)
assert got["moon_illum"] < 0.35, f"{name} chose a moonlit night"
def test_best_night_is_none_when_never_visible():
assert objects.best_this_year(_target("Southern Cross"), *ZURICH, WHEN) is None
def test_best_night_skipped_for_sun_and_moon():
for name in ("Sun", "Moon"):
assert objects.best_this_year(_target(name), *ZURICH, WHEN) is None
def test_best_night_is_cheap():
"""Closed form per night, not a sampled sky. The naive version costs
about 200 ms, seven times the most expensive thing the service does."""
import time
t = _target("M31")
start = time.perf_counter()
objects.best_this_year(t, *ZURICH, WHEN)
assert (time.perf_counter() - start) < 0.05
# ----------------------------------------------------------- planet facts
def test_planet_apparent_sizes_are_plausible():
"""Ranges a planet's disc actually spans, seen from Earth."""
jd = sky.julian(WHEN)
lst = (sky.gmst_hours(jd) + ZURICH[1] / 15.0) % 24
bounds = {"Mercury": (4, 14), "Venus": (9, 67), "Mars": (3, 26),
"Jupiter": (29, 51), "Saturn": (14, 21),
"Uranus": (3, 4.2), "Neptune": (2, 2.5)}
for name, (lo, hi) in bounds.items():
got = objects.planet_facts(name, jd, ZURICH[0], lst)["apparent_arcsec"]
assert lo <= got <= hi, f"{name} {got} arcsec outside {lo}-{hi}"
def test_outer_planets_are_always_nearly_full():
jd = sky.julian(WHEN)
lst = (sky.gmst_hours(jd) + ZURICH[1] / 15.0) % 24
for name in ("Jupiter", "Saturn", "Uranus", "Neptune"):
assert objects.planet_facts(name, jd, ZURICH[0], lst)["illuminated"] > 0.95
def test_inner_planets_stay_near_the_sun():
"""Mercury never gets more than about 28 degrees from the Sun and Venus
never more than about 47. A larger elongation means the angle is being
computed in the wrong frame -- which is exactly what using sky.planet()'s
'elon' field (heliocentric longitude) instead would produce."""
for name, limit in (("Mercury", 30), ("Venus", 48)):
worst = max(
objects.planet_facts(name, sky.julian(WHEN + dt.timedelta(days=d)),
ZURICH[0], 0.0)["elongation"]
for d in range(0, 400, 5))
assert worst <= limit, f"{name} reached {worst} degrees from the Sun"
def test_saturn_rings_open_after_the_2025_crossing():
"""2025 was a ring-plane crossing, so the rings were edge-on and all but
invisible. They widen from there towards a maximum near 27 degrees."""
angles = [objects.planet_facts("Saturn", sky.julian(dt.datetime(y, 8, 5)),
ZURICH[0], 0.0)["ring_angle"]
for y in (2025, 2026, 2027, 2029, 2032)]
assert angles[0] < 6, "should be nearly edge-on just after the crossing"
assert angles == sorted(angles), "should open steadily over these years"
assert max(angles) < 28, "cannot open wider than the tilt allows"
def test_light_minutes_match_the_distance():
jd = sky.julian(WHEN)
f = objects.planet_facts("Saturn", jd, ZURICH[0], 0.0)
assert f["light_minutes"] == pytest.approx(f["distance_au"] * 8.3167, rel=0.01)
# ------------------------------------------------------- spectra and variables
@pytest.mark.parametrize("name, want", [
("Betelgeuse", "red supergiant"),
("Vega", "white main-sequence star"),
("Arcturus", "orange giant"),
("Rigel", "blue-white supergiant"),
("Aldebaran", "orange giant"),
])
def test_spectral_descriptions(name, want):
hr = next(s["hr"] for s in sky._load("stars.json") if s.get("n") == name)
assert objects.describe_spectrum(objects.star_info(hr).get("sp")) == want
@pytest.mark.parametrize("sp", ["", None, "?", "Xq"])
def test_unreadable_spectra_say_nothing(sp):
"""A wrong description of a star is worse than no description."""
assert objects.describe_spectrum(sp) is None
def test_yerkes_prefix_notation():
"""BSC5 still uses the old prefixes for a few dozen stars: gK4 is a K4
giant, not a star of class 'g'."""
assert objects.describe_spectrum("gK4") == "orange giant"
assert objects.describe_spectrum("sgG9") == "yellow subgiant"
assert objects.describe_spectrum("dF5") == "yellow-white main-sequence star"
def test_algol_minima_are_one_period_apart():
hr = next(s["hr"] for s in sky._load("stars.json") if s.get("n") == "Algol")
period = objects.variable_info(hr)["period"]
first = objects.next_minimum(hr, WHEN)
assert first is not None and first > WHEN
second = objects.next_minimum(hr, first + dt.timedelta(minutes=1))
gap = (second - first).total_seconds() / 86400
assert gap == pytest.approx(period, abs=1e-3)
def test_pulsating_variables_get_no_minimum():
"""The GCVS epoch marks maximum light for pulsating stars, so reporting
it as a minimum would be wrong in a way nobody would notice."""
hr = next(s["hr"] for s in sky._load("stars.json") if s.get("n") == "Betelgeuse")
assert objects.next_minimum(hr, WHEN) is None
def test_next_minimum_is_none_for_ordinary_stars():
hr = next(s["hr"] for s in sky._load("stars.json") if s.get("n") == "Vega")
assert objects.next_minimum(hr, WHEN) is None
# ------------------------------------------------------------ file hygiene
@pytest.mark.parametrize("name", ["constellations.json", "starinfo.json",
"variables.json", "dsoinfo.json"])
def test_catalogue_files_are_compact(name):
"""These ship in the repo and are read on every cold start; a
pretty-printed rewrite would bloat them for no gain."""
raw = open(f"{sky.BASE}/{name}").read()
assert ", " not in raw[:2000], f"{name} looks pretty-printed"
json.loads(raw)
# ---------------------------------------------------------- meteor showers
@pytest.mark.parametrize("name, month", [
("Perseids", 8), ("Geminids", 12), ("Quadrantids", 1), ("Lyrids", 4),
])
def test_shower_best_night_is_the_actual_peak(name, month):
"""A shower's best night is the night the Earth reaches the debris, not
the night its radiant happens to sit highest in a dark sky. Scored like
an ordinary object the Perseids came back as 9 December -- four months
after the only night worth going out for."""
got = objects.best_this_year(_target(name), *ZURICH, WHEN)
assert got is not None, f"no peak found for {name}"
assert got["is_peak"] is True
assert got["when_utc"].month == month
def test_shower_radiant_altitude_is_measured_at_local_midnight():
"""Gemini is nearly overhead at midnight in December from Zurich. Reading
the altitude from the peak moment offset by longitude, rather than from
local midnight of the peak date, put it 10 degrees below the horizon."""
got = objects.best_this_year(_target("Geminids"), *ZURICH, WHEN)
assert got["radiant_alt"] > 45
assert got["radiant_alt"] <= got["transit_alt"] + 0.5
def test_shower_radiant_altitude_never_exceeds_the_geometric_maximum():
for name in ("Perseids", "Geminids", "Quadrantids", "Lyrids", "Orionids"):
got = objects.best_this_year(_target(name), *ZURICH, WHEN)
if got:
assert got["radiant_alt"] <= got["transit_alt"] + 0.5, name
def test_shower_peak_has_no_dark_hours_figure():
"""A peak is one night, so "hours of darkness available" is not a number
that means anything for it -- and formatting the None it returns is what
crashed the card renderer."""
got = objects.best_this_year(_target("Perseids"), *ZURICH, WHEN)
assert got.get("dark_hours") is None
# ------------------------------------------------- deep-sky positions
# Published J2000 positions, (RA hours, Dec degrees). These are the check
# that would have caught a precession sign error shipping to every chart:
# deepsky.json used to precess B1975 to J2000 with the wrong sign, landing
# every object near B1950 and putting all 739 of them 24 to 39 arcminutes
# out -- about a Moon-width, consistently, invisibly.
_KNOWN_POSITIONS = {
"NGC224": (0.71231, 41.26875), # M31
"NGC598": (1.56414, 30.66017), # M33
"NGC1976": (5.58814, -5.39111), # M42
"NGC5194": (13.49797, 47.19526), # M51
"NGC6205": (16.69488, 36.46131), # M13
"NGC6720": (18.89308, 33.02875), # M57
"NGC6853": (19.99340, 22.72139), # M27
}
def _separation_arcmin(ra1_h, de1, ra2_h, de2):
r1, d1, r2, d2 = (math.radians(x) for x in (ra1_h * 15, de1, ra2_h * 15, de2))
cos = math.sin(d1) * math.sin(d2) + math.cos(d1) * math.cos(d2) * math.cos(r1 - r2)
return math.degrees(math.acos(max(-1.0, min(1.0, cos)))) * 60
@pytest.mark.parametrize("oid, truth", sorted(_KNOWN_POSITIONS.items()))
def test_deep_sky_positions_match_published_j2000(oid, truth):
o = next((x for x in sky._load("deepsky.json") if x["id"] == oid), None)
assert o is not None, f"{oid} missing from deepsky.json"
off = _separation_arcmin(o["ra"], o["de"], *truth)
assert off < 3.0, f"{oid} is {off:.1f} arcmin from its published position"
def test_messier_numbers_are_not_shared_between_objects():
"""Seven were, before the Messier number started coming from RNGC's
cross-reference column instead of its prose -- the Beehive was five
different objects. The three names still shared are genuine
two-component objects."""
seen = {}
for o in sky._load("deepsky.json"):
n = o["n"]
if n.startswith("M") and n[1:].isdigit():
seen.setdefault(n, []).append(o["id"])
shared = {k: v for k, v in seen.items() if len(v) > 1}
assert set(shared) <= {"M76"}, f"unexpected shared Messier numbers: {shared}"
def test_the_messier_catalogue_is_essentially_complete():
"""108 of 110. M25 is an IC object and this catalogue is NGC-only by
licence choice; M40 is a double star, not a deep-sky object at all."""
have = {int(o["n"][1:]) for o in sky._load("deepsky.json")
if o["n"].startswith("M") and o["n"][1:].isdigit()}
assert set(range(1, 111)) - have == {25, 40}
def test_the_pleiades_are_in_there():
"""No NGC number exists for them, so they are hand-added. The most
looked-at cluster in the sky should not 404."""
o = next((x for x in sky._load("deepsky.json") if x["id"] == "M45"), None)
assert o is not None
assert o["cn"] == "Pleiades"
assert _separation_arcmin(o["ra"], o["de"], 3.79067, 24.11333) < 3.0
def test_famous_nebulae_are_typed_as_nebulae():
"""RNGC files these as "cluster with nebulosity", which is true and
unhelpful: the type code picks the glyph and the word, so the chart drew
the Orion Nebula with a gold cluster mark and its page read "Cluster in
Orion". Nobody calls it that."""
by_id = {o["id"]: o for o in sky._load("deepsky.json")}
for oid, name in (("NGC1976", "Orion"), ("NGC6523", "Lagoon"),
("NGC6514", "Trifid"), ("NGC6611", "Eagle"),
("NGC2237", "Rosette"), ("NGC2070", "Tarantula")):
assert by_id[oid]["t"] == "neb", f"{name} is typed {by_id[oid]['t']}"
def test_the_reclassification_touched_nothing_else():
"""Six objects change type and no object changes anything else. A type
override that moved a position or dropped an entry would be a much bigger
change than it looks, since every chart reads this file."""
d = sky._load("deepsky.json")
assert len(d) == 749
# The clusters that are genuinely clusters stay clusters.
by_id = {o["id"]: o for o in d}
assert by_id["NGC2264"]["t"] == "clu", "Christmas Tree Cluster is a cluster"
assert by_id["NGC869"]["t"] == "clu", "Double Cluster is a cluster"
assert by_id["NGC6205"]["t"] == "clu", "Hercules Cluster is a cluster"
def test_a_shower_stays_this_year_through_the_night_after_its_peak():
"""The peak is a moment; the shower is a night, and the night outlasts
the moment. Without a grace period a card shared hours before the peak --
which is exactly when people share it -- flips to a date a year away
while the shower is still falling."""
peak = dt.datetime(2026, 8, 13, 2, 10)
t = _target("Perseids")
for offset_h, want_year in ((-72, 2026), (-1, 2026), (+12, 2026),
(+21, 2026), (+45, 2027)):
when = peak + dt.timedelta(hours=offset_h)
got = objects.best_this_year(t, *ZURICH, when)
assert got["when_utc"].year == want_year, (
f"{offset_h:+}h from the peak: expected {want_year}, "
f"got {got['when_utc']:%Y-%m-%d}")
def test_shower_grace_is_one_day():
assert objects.SHOWER_GRACE_DAYS == 1.0