"""Angle-timing research helpers (BUG-1141). Offline; public v5 cases only.""" from __future__ import annotations from datetime import date from random import Random import numpy as np import pytest from scripts.research import angle_timing_lib as at def test_angle_diff_wraps_and_aspect_sep_takes_nearest_aspect(): assert at.angle_diff(359.0, 1.0) == pytest.approx(2.0) assert at.angle_diff(10.0, 190.0) == pytest.approx(180.0) seps = at.aspect_sep(np.array([0.0, 91.0, 178.5, 45.0]), np.array([0.0, 0.0, 0.0, 0.0])) assert list(np.round(seps, 3)) == [0.0, 1.0, 1.5, 45.0] @pytest.mark.parametrize("raw, precision, start, end", [ ({"date": "2001-03-04", "precision": "day"}, "day", date(2001, 3, 4), date(2001, 3, 4)), ({"date": "2001-02", "precision": "month"}, "month", date(2001, 2, 1), date(2001, 2, 28)), ({"date": "2001", "precision": "year"}, "year", date(2001, 1, 1), date(2001, 12, 31)), ]) def test_parse_event_spans(raw, precision, start, end): event = at.parse_event({**raw, "id": "x", "domain": "career"}) assert event is not None and (event.precision, event.start, event.end) == (precision, start, end) def test_parse_event_rejects_future_and_malformed(): assert at.parse_event({"date": "2030", "precision": "year"}) is None assert at.parse_event({"date": "2001-13", "precision": "month"}) is None assert at.parse_event({"date": "", "precision": "day"}) is None def test_sample_dates_by_technique_and_precision(): month = at.parse_event({"date": "2001-02", "precision": "month"}) year = at.parse_event({"date": "2001", "precision": "year"}) assert len(at.sample_dates(month, "TR")) == 28 assert len(at.sample_dates(month, "SP")) == 1 assert at.sample_dates(year, "TR") == [] assert len(at.sample_dates(year, "SA")) == 5 def test_truth_rank_semantics(): scores = np.zeros(121) scores[at.RADIUS_MAX] = 3 assert at.truth_rank(scores, 10) == 0.0 flat = np.ones(121) assert at.truth_rank(flat, 10) == pytest.approx(0.5) worst = np.ones(121); worst[at.RADIUS_MAX] = 0 assert at.truth_rank(worst, 10) == 1.0 def test_shuffle_keeps_precision_count_and_is_seeded(): case = at.load_cases()[0] a = at.shuffled_events(case, Random("seed-1")) b = at.shuffled_events(case, Random("seed-1")) assert a == b assert [e["precision"] for e in a] == [e["precision"] for e in case["events"]] assert [len(e["date"]) for e in a] == [len(str(e["date"])[:{"day": 10, "month": 7}.get(e["precision"], 4)]) for e in case["events"]] birth_year = int(case["birth"]["date"][:4]) assert all(int(e["date"][:4]) > birth_year for e in a) def test_natal_angles_move_about_a_quarter_degree_per_minute(): chart = at.CaseChart(at.load_cases()[0]) step_asc = at.angle_diff(chart.asc[1:], chart.asc[:-1]) step_mc = at.angle_diff(chart.mc[1:], chart.mc[:-1]) assert 0.05 < float(np.median(step_asc)) < 1.0 assert 0.2 < float(np.median(step_mc)) < 0.3 # the truth minute is the window centre assert chart.offsets[at.RADIUS_MAX] == 0 def test_separation_matrices_have_one_row_per_usable_event(): chart = at.CaseChart(at.load_cases()[0]) events = chart.events() tr = chart.separations("TR", events) sp = chart.separations("SP", events) assert tr.shape[0] == sum(1 for e in events if e.precision != "year") assert sp.shape == (len(events), len(chart.offsets)) assert np.isfinite(sp).all() and (sp >= 0).all() and (sp <= 45).all() def test_a1_runner_is_deterministic_and_reports_every_preregistered_test(): from scripts.research import angle_timing_research as runner cases = at.load_cases()[:2] first = runner.run_a1(cases, 3, lambda _m: None) second = runner.run_a1(cases, 3, lambda _m: None) assert at.stable_json(first) == at.stable_json(second) assert first["tests"] == 9 assert first["bonferroni_percentile"] == pytest.approx(100 * (1 - 0.05 / 9), abs=1e-3) for tech in at.TECHNIQUES: for orb in at.ORBS: for radius in runner.RADII: cell = first["table"][tech][str(orb)][str(radius)] assert 0.0 <= cell["real_mean_rank"] <= 1.0 assert 0.0 <= cell["percentile_beaten"] <= 100.0 assert len(first["shuffled_means"][tech][str(orb)][str(radius)]) == 3