spb/countryatlas
Public
1from __future__ import annotations23from datetime import UTC, date, datetime45import numpy as np6import polars as pl7import pytest89from countryatlas.pipeline.changes import Series, compute_changes_and_events, detect_changes, detect_events10from countryatlas.registry import indicators_by_id111213def _series(ind: str, values: list[float], start: int = 2000, country: str = "CAN") -> Series:14 years = np.arange(start, start + len(values), dtype=np.int32)15 return Series(country, ind, [date(int(y), 1, 1) for y in years], years, np.array(values, dtype=float))161718def test_inflation_drop_headline_and_since() -> None:19 ind = indicators_by_id()["inflation"]20 vals = [2.0, 2.1, 1.9, 2.2, 2.0, 2.3, 8.1, 5.9, 5.5, 3.4] # 2015: 5.5 → 2016: 3.4 (−2.1 pts) hmm: last drop 2.121 vals = [2.0, 2.1, 1.9, 2.2, 2.0, 2.3, 2.1, 2.0, 6.8, 3.4]22 out = detect_changes(_series("inflation", vals, 2016), ind)23 kinds = {r["kind"]: r for r in out}24 assert "yoy_drop" in kinds25 h = kinds["yoy_drop"]["headline"]26 assert h.startswith("Inflation fell 3.4 points to 3.4 % in 2025")27 assert "largest drop" in h28 assert 0 < kinds["yoy_drop"]["severity"] <= 1293031def test_floor_blocks_small_moves() -> None:32 ind = indicators_by_id()["inflation"] # change_floor 2 points33 vals = [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 3.0] # +1 pt: huge z but below the floor34 out = detect_changes(_series("inflation", vals), ind)35 assert not any(r["kind"] in ("yoy_jump", "yoy_drop") for r in out)363738def test_record_high_and_relative_headline() -> None:39 ind = indicators_by_id()["gdp-per-capita"]40 vals = [1000 * 1.03**k for k in range(15)]41 vals[5] *= 0.9 # a dip: the series must not be monotone, otherwise records are (rightly) not newsworthy42 vals[-1] = vals[-2] * 1.2543 out = detect_changes(_series("gdp-per-capita", vals), ind)44 kinds = {r["kind"] for r in out}45 assert "record_high" in kinds and "yoy_jump" in kinds46 jump = next(r for r in out if r["kind"] == "yoy_jump")47 assert "25.0 %" in jump["headline"] and "US$" in jump["headline"]484950def test_sign_flip_and_events_history() -> None:51 ind = indicators_by_id()["gdp-growth"]52 vals = [3.0, 2.5, 3.1, 2.8, 3.0, 2.9, -4.5, 5.0, 2.0, -1.0]53 ch = detect_changes(_series("gdp-growth", vals), ind)54 assert any(r["kind"] == "sign_flip" for r in ch)55 ev = detect_events(_series("gdp-growth", vals), ind)56 flips = [r for r in ev if r["kind"] == "sign_flip"]57 assert len(flips) == 3 # 2006, 2007, 200958 assert any(r["kind"] == "yoy_drop" and r["year"] == 2006 for r in ev)596061def test_monotone_and_cumulative_series_are_silent() -> None:62 inds = indicators_by_id()63 vals = [100.0 * 1.02**k for k in range(30)] # strictly increasing: every point is a "record"64 ch = detect_changes(_series("population", vals), inds["population"])65 assert not any(r["kind"] in ("record_high", "n_year_high") for r in ch)66 rows = [{"country_id": "CAN", "indicator_id": "cumulative-co2", "period": date(1990 + k, 1, 1), "year": 1990 + k, "value": v}67 for k, v in enumerate(vals)]68 ch2, ev2 = compute_changes_and_events(pl.DataFrame(rows), inds)69 assert ch2.height == 0 and ev2.height == 0707172def test_changes_are_recent_and_weighted() -> None:73 inds = indicators_by_id()74 old = [2.0, 2.1, 1.9, 2.2, 2.0, 2.3, 2.1, 2.0, 6.8, 3.4] # series ends in 200875 rows = [{"country_id": "CAN", "indicator_id": "inflation", "period": date(1999 + k, 1, 1), "year": 1999 + k, "value": v}76 for k, v in enumerate(old)]77 rows += [{"country_id": "FRA", "indicator_id": "inflation", "period": date(2016 + k, 1, 1), "year": 2016 + k, "value": v}78 for k, v in enumerate(old)]79 from datetime import UTC, datetime8081 ch, ev = compute_changes_and_events(pl.DataFrame(rows), inds, headline_ids={"inflation"}, now=datetime(2026, 9, 1, tzinfo=UTC))82 assert set(ch["country_id"].to_list()) == {"FRA"} # Canada's 2008 drop is an event, not a change83 assert "CAN" in set(ev["country_id"].to_list())84 import json8586 d = json.loads(ch["detail"][0])87 assert d["weight"] == 1.0 and "raw_severity" in d88 ch_w, _ = compute_changes_and_events(pl.DataFrame(rows), inds, headline_ids=set(), now=datetime(2026, 9, 1, tzinfo=UTC))89 assert json.loads(ch_w["detail"][0])["weight"] == 0.9 # inflation is featured but not headline here90 assert ch_w["severity"][0] == pytest.approx(min(1.0, d["raw_severity"] * 0.9), rel=1e-3)919293def test_index_series_need_10pct_and_quarterly_is_annualised() -> None:94 inds = indicators_by_id()95 ind = inds["real-house-price-index"] # format index, ranking_eligible false96 assert ind.format == "index" and not ind.ranking_eligible97 smooth = [100 + k for k in range(12)]98 smooth[-1] = smooth[-2] * 1.05 # +5 %: big z, but below the 10 % rule99 assert not any(r["kind"] in ("yoy_jump", "yoy_drop") for r in detect_changes(_series("real-house-price-index", smooth), ind))100 big = list(smooth)101 big[-1] = big[-2] * 1.2 # +20 %102 assert any(r["kind"] == "yoy_jump" for r in detect_changes(_series("real-house-price-index", big), ind))103 # quarterly rows collapse to one value per year (last value for an index) → at most one detection per year104 rows = []105 for y in range(2015, 2026):106 for q, month in enumerate((1, 4, 7, 10)):107 v = 100 + (y - 2015) * 2 + q * 0.3108 if y == 2025:109 v = 150.0 + q # +~25 % jump in 2025110 rows.append({"country_id": "CAN", "indicator_id": "real-house-price-index", "period": date(y, month, 1), "year": y,111 "frequency": "Q", "value": v})112 ch, ev = compute_changes_and_events(pl.DataFrame(rows), inds, now=datetime(2026, 1, 1, tzinfo=UTC))113 assert ch.filter(pl.col("kind") == "yoy_jump").height == 1114 assert ev.group_by(["indicator_id", "year"]).len()["len"].max() <= 3115 assert ev["severity"].min() >= 0.35116 assert ch.filter(pl.col("kind") == "yoy_jump")["value"][0] == 153.0 # last quarter of 2025117118119def test_driver_over_frame() -> None:120 inds = indicators_by_id()121 rows = []122 for c in ("CAN", "FRA"):123 for k, v in enumerate([2.0, 2.1, 1.9, 2.2, 2.0, 2.3, 2.1, 2.0, 6.8, 3.4]):124 rows.append({"country_id": c, "indicator_id": "inflation", "period": date(2016 + k, 1, 1), "year": 2016 + k, "value": v})125 df = pl.DataFrame(rows)126 ch, ev = compute_changes_and_events(df, inds)127 assert ch.filter(pl.col("kind") == "yoy_drop").height == 2128 assert set(ch.columns) >= {"id", "country_id", "indicator_id", "kind", "headline", "severity", "detected_at"}129 assert ev.height >= 2130