File size: 5,556 Bytes
b7c46f4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
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