""" Nigeria's national calendar as events: federal public holidays, the Islamic holidays and Ramadan (computed, so marked is_estimate), and the December festive trading season. Generated rather than scraped, so history back to 2000 costs nothing — which matters, because the correlation step learns from history. Known limits, kept honest by is_estimate and the design doc: - Islamic dates use the Umm al-Qura calendar; Nigeria's dates follow the Sultan of Sokoto's moon-sighting announcement and can differ by a day. - The Federal Government sometimes declares extra or substitute days (e.g. a Monday when a holiday falls on Sunday, or a one-off holiday). Those aren't predictable; the news pipeline is where declarations will be caught. - School terms vary by state and aren't included yet. """ import sqlite3 from datetime import date, timedelta from hijridate import Gregorian, Hijri from intel.db import now_iso from intel.freshness import record_attempt from intel.handoff import DATA_STREAM, INTERFACE, produce SOURCE_ID = "calendar" COUNTRY = "ng" FIRST_YEAR = 2000 YEARS_AHEAD = 10 def easter(year: int) -> date: """Western Easter Sunday (anonymous Gregorian algorithm).""" a, b, c = year % 19, year // 100, year % 100 d, e = b // 4, b % 4 f = (b + 8) // 25 g = (b - f + 1) // 3 h = (19 * a + b - d - g + 15) % 30 i, k = c // 4, c % 4 l = (32 + 2 * e + 2 * i - h - k) % 7 m = (a + 11 * h + 22 * l) // 451 month = (h + l - 7 * m + 114) // 31 day = (h + l - 7 * m + 114) % 31 + 1 return date(year, month, day) def _hijri_to_greg(hy: int, hm: int, hd: int) -> date: return Hijri(hy, hm, hd).to_gregorian() def _hijri_years_overlapping(year: int) -> range: """The Hijri years whose dates can fall in this Gregorian year (a Hijri year is ~11 days shorter, so it's always the one in progress on 1 January and the next).""" hy = Gregorian(year, 1, 1).to_hijri().year return range(hy, hy + 2) def events_for_year(year: int) -> list[dict]: """[{key, type, title, starts_on, ends_on, is_estimate}] for one Gregorian year.""" out = [] def add(key, type_, title, start, end=None, estimate=False): if start.year == year or (end and end.year == year): out.append({"key": key, "type": type_, "title": title, "starts_on": start, "ends_on": end or start, "is_estimate": estimate}) add("new_year", "public_holiday", "New Year's Day", date(year, 1, 1)) gf = easter(year) - timedelta(days=2) add("good_friday", "public_holiday", "Good Friday", gf) add("easter_monday", "public_holiday", "Easter Monday", gf + timedelta(days=3)) add("workers_day", "public_holiday", "Workers' Day", date(year, 5, 1)) # Democracy Day moved from 29 May to 12 June from 2019. add("democracy_day", "public_holiday", "Democracy Day", date(year, 6, 12) if year >= 2019 else date(year, 5, 29)) add("independence_day", "public_holiday", "Independence Day", date(year, 10, 1)) add("christmas", "public_holiday", "Christmas Day", date(year, 12, 25)) add("boxing_day", "public_holiday", "Boxing Day", date(year, 12, 26)) # Festive trading season: VenThrive's own window for the December spending peak. add("festive_season", "season", "Festive trading season", date(year, 12, 15), date(year + 1, 1, 5), estimate=True) for hy in _hijri_years_overlapping(year): ramadan_start = _hijri_to_greg(hy, 9, 1) eid_fitr = _hijri_to_greg(hy, 10, 1) add("ramadan", "season", "Ramadan", ramadan_start, eid_fitr - timedelta(days=1), estimate=True) add("eid_al_fitr", "religious_holiday", "Eid al-Fitr", eid_fitr, eid_fitr + timedelta(days=1), estimate=True) eid_adha = _hijri_to_greg(hy, 12, 10) add("eid_al_adha", "religious_holiday", "Eid al-Adha", eid_adha, eid_adha + timedelta(days=1), estimate=True) add("mawlid", "religious_holiday", "Eid-el-Maulud (Mawlid)", _hijri_to_greg(hy, 3, 12), estimate=True) return out def ingest(con: sqlite3.Connection, first_year: int = FIRST_YEAR, last_year: int | None = None, run_id: str = "manual") -> dict: last_year = last_year or date.today().year + YEARS_AHEAD now = now_iso() n = 0 try: for year in range(first_year, last_year + 1): for e in events_for_year(year): ref = f"{e['key']}:{e['starts_on'].isoformat()}" con.execute( """insert into events (id, family, type, location_id, starts_on, ends_on, title, detail, is_estimate, source_id, source_ref, ingested_at) values (?, 'calendar', ?, ?, ?, ?, ?, null, ?, ?, ?, ?) on conflict (source_id, source_ref) do update set title = excluded.title, ends_on = excluded.ends_on, is_estimate = excluded.is_estimate""", (f"{SOURCE_ID}:{ref}", e["type"], COUNTRY, e["starts_on"].isoformat(), e["ends_on"].isoformat(), e["title"], int(e["is_estimate"]), SOURCE_ID, ref, now), ) n += 1 con.commit() except Exception as e: record_attempt(con, SOURCE_ID, ok=False, error=str(e)) raise record_attempt(con, SOURCE_ID, ok=True, latest_data_period=f"{last_year}-12-31") summary = {"years": f"{first_year}-{last_year}", "events": n} produce(con, run_id, DATA_STREAM, INTERFACE, {"kind": "calendar", **summary}) return summary