#!/usr/bin/env python3 """Marriages in Italy by month, rite and province: leap years, Lent, May, November and retrograde Venus and Mars. Source of the numbers on https://startarot.online/it/research/matrimoni-e-superstizioni-italia Needs Python 3.10+ and pip install numpy pandas plus ``curl``. The raw tables (about 25 MB) go to $IT_MARRIAGES_DATA (default: ./italy-marriages-data next to this script). Steps, in order: python3 italy_marriages.py fetch # ISTAT and Eurostat tables python3 italy_marriages.py compute # tables + calendar + estimates -> $IT_MARRIAGES_DATA python3 italy_marriages.py validate # months against years, rites against totals, provinces against regions python3 italy_marriages.py export # page JSON + dataset CSV (+ a copy of this script in the repo) Run from the repo, export writes (GENERATED — regenerate, do not hand-edit): frontend/src/lib/research/italy-marriages.json data the page renders frontend/static/research/italy-marriages.csv the open dataset (long format) frontend/static/research/italy_marriages.py a copy of this script A copy downloaded from the page writes the JSON and CSV next to itself. What is counted --------------- Marriages CELEBRATED in Italy (religious "concordatario" marriages and civil ones), by the month and the province where they were celebrated, as the civil registrars report them to ISTAT on form D.3. Civil unions (from June 2016) are not marriages and are not counted. Sources ------- * ISTAT, I.Stat / esploradati, dataflow 24_186_DF_DCIS_MATRDEMO_7 "Matrimoni — rito, mese di celebrazione": marriages by month (and year) × rite (all / religious / civil) × territory (Italy, the five parts, 21 regions and autonomous provinces, the provinces), 2004–2024. Territory names: codelist CL_ITTER107. * Eurostat, demo_nind (indicator MARRIAGE, Italy): marriages by year, 1960–2024. The national series is Eurostat's for 1960–2003 and ISTAT's from 2004: the two agree to the unit in every year but 2012, where Eurostat prints 208,947 and ISTAT (the table and its 2012 report) 207,138 (``KNOWN_DIFFERENCES``). * ISTAT reports "Matrimoni, unioni civili, separazioni e divorzi" for 2023 and 2024: the shares by day of the week (printed figures only — no series is published; see ``WEEKDAY_REPORTS``). * retrograde-periods.csv of https://startarot.online/research/retrograde-planets-1900-2100 (Venus and Mars stations, computed with the Swiss Ephemeris). The date of each marriage is collected (form D.3) but not published: no microdata file of marriages exists among ISTAT's public, standard or research files (checked 23 October 2026). Days of the week, the 17th and "beautiful" dates therefore cannot be measured here. Method ------ * Leap years: each year against its centred 2×4-year moving average (``cycle_ratios``), nationally on the 1960–2024 series (1962–2017) and by province on ISTAT's (2006–2017: leap years 2008, 2012, 2016). Years whose five-year window touches 2020 are left out. * Months (``monthly_models``), 2004–2024 without 2020 and 2021 (COVID-19: receptions were banned until 15 June 2021), for religious and civil marriages separately: log marriages per day on year and month effects, each month's own linear drift, the number of Fridays, Saturdays, Sundays and public holidays, the share of the month in Lent (Ash Wednesday to Holy Saturday) and the shares with Venus and with Mars retrograde. Significance by randomisation: Easter dates swapped between years, the retrograde calendars shifted round the span. Civil marriages are the control: the Church's calendar should not move them. * Months against their neighbours (``neighbour_test``): log(per-day rate of a month) − the mean of its two neighbours, averaged over the years, religious minus civil — where the Church side leaves a gap the season does not explain (May for the folk saying and Ovid's "mense malas Maio nubere", November for the month of the dead). May is judged on the years when April holds no Lent (``may_clean``); November also against the year's average day (``november_vs_average``), since the January–February base itself emptied over the period. * Provinces whose borders changed in 2004–2024 are merged into stable units (``MERGES``); Sardinia, whose provinces were redrawn twice and whose four 2005–2016 provinces are missing from the table (2006–2016), is one unit. Raw files are checked against the SHA-256 of their CONTENT (``content_digest``: the parsed rows, sorted), pinned in ``CONTENT_SHA256``: the services restamp their files on every release, so a byte hash would change with nothing changed. A different content digest stops ``fetch``: ISTAT or Eurostat revised the numbers — look at what changed, then update the pin. """ from __future__ import annotations import argparse import calendar as cal_mod import datetime as dt import hashlib import json import os import re import shutil import subprocess import sys import time from pathlib import Path import numpy as np import pandas as pd HERE = Path(__file__).resolve().parent REPO_ROOT = HERE.parents[1] if len(HERE.parents) > 1 else HERE SLUG = 'matrimoni-e-superstizioni-italia' FILE_STEM = 'italy-marriages' if (REPO_ROOT / 'frontend' / 'src' / 'lib' / 'research').is_dir(): JSON_OUT = REPO_ROOT / 'frontend' / 'src' / 'lib' / 'research' / f'{FILE_STEM}.json' CSV_OUT = REPO_ROOT / 'frontend' / 'static' / 'research' / f'{FILE_STEM}.csv' SCRIPT_COPY = REPO_ROOT / 'frontend' / 'static' / 'research' / 'italy_marriages.py' RETRO_CSV = REPO_ROOT / 'frontend' / 'static' / 'research' / 'retrograde-periods.csv' else: JSON_OUT = HERE / f'{FILE_STEM}.json' CSV_OUT = HERE / f'{FILE_STEM}.csv' SCRIPT_COPY = None RETRO_CSV = HERE / 'retrograde-periods.csv' RETRO_URL = 'https://startarot.online/research/retrograde-periods.csv' DATA = Path(os.environ.get('IT_MARRIAGES_DATA') or HERE / 'italy-marriages-data') RAW = DATA / 'raw' UA = 'Mozilla/5.0 (research script; startarot.online)' ISTAT_SDMX = 'https://esploradati.istat.it/SDMXWS/rest/' SOURCES = { 'istat_month_rite': (ISTAT_SDMX + 'data/IT1,24_186_DF_DCIS_MATRDEMO_7,1.0/all', 'application/vnd.sdmx.data+csv;version=1.0.0', 'istat_month_rite.csv'), 'istat_territories': (ISTAT_SDMX + 'codelist/IT1/CL_ITTER107/latest', 'application/xml', 'istat_cl_itter107.xml'), 'eurostat_annual': ('https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/demo_nind' '?geo=IT&indic_de=MARRIAGE&format=JSON&lang=EN', 'application/json', 'eurostat_demo_nind_it.json'), } # SHA-256 of each source's parsed content (see the docstring); ``fetch`` prints the digest of an unpinned one. CONTENT_SHA256: dict[str, str] = { 'istat_month_rite': 'e623ad1535ef7fa5df92bb71e4b6f0c210bb2e5e6e19ebd92cf69a2b242cf048', 'istat_territories': '3ba6e73f681c5fc40f611641401f4816f37d1215752546220dbe2b230e131a18', 'eurostat_annual': '44cb8cdfd5dcce9fcb3564383652880d98b8810363c10a531a3cd94f1d2a9b2a', } FIRST_YEAR_ISTAT, LAST_YEAR_ISTAT = 2004, 2024 FIRST_YEAR_EU, LAST_YEAR_EU = 1960, 2024 SHOCK_YEARS = {2020} # COVID-19: 2020 lost two thirds of its religious weddings, and receptions stayed banned until 15 June 2021. EXCLUDED_YEARS = {2020: 'COVID-19: lockdown from March, ceremonies limited for the rest of the year', 2021: 'COVID-19: wedding receptions banned until 15 June 2021, a backlog after'} MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEPT', 'OCT', 'NOV', 'DEC'] RITES = {'ALL': 'all', 'REL': 'religious', 'CIVIL': 'civil'} # The three parts the page compares: North (North-West + North-East), Centre, South with the islands. MACRO = {'ITCD': 'Nord', 'ITE': 'Centro', 'ITFG': 'Mezzogiorno'} # Provinces whose borders changed in 2004–2024, merged into units with one border throughout: # Monza e della Brianza left Milano, Fermo left Ascoli Piceno, Barletta-Andria-Trani took comuni from Bari # and Foggia (all three in the table from 2010); seven comuni of the Valmarecchia left Pesaro e Urbino for # Rimini in 2009 (Pesaro −18 % in 2010 against −6 % for Italy). Sardinia is one unit (see the docstring). MERGES = { 'ITC45+IT108': ('Milano e Monza-Brianza', ['ITC45', 'IT108']), 'ITE34+IT109': ('Ascoli Piceno e Fermo', ['ITE34', 'IT109']), 'ITF41+ITF42+IT110': ('Bari, Foggia e Barletta-Andria-Trani', ['ITF41', 'ITF42', 'IT110']), 'ITE31+ITD59': ('Pesaro e Urbino e Rimini', ['ITE31', 'ITD59']), } SARDINIA = 'ITG2' # Years where Eurostat and ISTAT print different national totals; ISTAT, the source, is used. KNOWN_DIFFERENCES = {2012: 'Eurostat 208,947; ISTAT 207,138 (table and the report "Il matrimonio in Italia, anno 2012")'} # Day-of-week shares in ISTAT's reports (no series is published). Saturday and Tuesday are printed in the # text (per cent of the marriages of each rite; "all_saturday" counts marriages and civil unions together, as # the reports do). The other days are only drawn, in Figure 6: Friday is read off the 2024 chart (about 14 % # of religious and 15 % of civil marriages, ±1 point) — the second day after Saturday in church, about level # with Thursday at the town hall. The 2023 # report also ranks the dates: Saturday 17 June 2023, at the June peak, came only tenth. WEEKDAY_REPORTS = { 2023: {'url': 'https://www.istat.it/wp-content/uploads/2024/11/REPORT_MATRIMONI-UNIONI-SEPARAZIONI_dati-2023_22novembre2024.pdf', 'all_saturday': 46.2, 'religious_saturday': 53.1, 'religious_tuesday': 3.5, 'civil_tuesday': 6.1, 'june_17_rank': 10}, 2024: {'url': 'https://www.istat.it/wp-content/uploads/2026/01/MATRIMONI-UNIONI-SEPARAZIONI-DIVORZI_anno-2024.pdf', 'all_saturday': 46.2, 'religious_saturday': 54.8, 'religious_tuesday': 3.3, 'civil_tuesday': 6.3, 'religious_friday_from_chart': 14, 'civil_friday_from_chart': 15, 'friday_rank_from_chart': 2}, } def log(*a): print(*a, flush=True) # ------------------------------------------------------------------ downloads def curl(url: str, accept: str, dest: Path, tries: int = 6) -> None: dest.parent.mkdir(parents=True, exist_ok=True) if dest.exists() and dest.stat().st_size > 0: return tmp = dest.with_suffix(dest.suffix + '.part') for i in range(tries): r = subprocess.run(['curl', '-sSL', '-f', '-A', UA, '-H', f'Accept: {accept}', '-m', '600', '-o', str(tmp), url]) if r.returncode == 0 and tmp.stat().st_size > 0: tmp.rename(dest) return time.sleep(15 * (i + 1)) # the ISTAT service limits requests per minute raise RuntimeError(f'download failed: {url}') def content_rows(key: str) -> list[tuple]: """The rows a source contributes, as sorted tuples of strings (the base of ``content_digest``).""" p = RAW / SOURCES[key][2] if key == 'istat_month_rite': t = pd.read_csv(p, dtype=str) cols = ['REF_AREA', 'MARRIAGE_RITE', 'MONTH_CELEBRATION', 'TIME_PERIOD', 'OBS_VALUE'] return sorted(map(tuple, t[cols].fillna('').values.tolist())) if key == 'istat_territories': return sorted((c, n or '', par or '') for c, (n, par) in territories().items()) if key == 'eurostat_annual': return sorted((str(y), str(v)) for y, v in eurostat_series().items()) raise KeyError(key) def content_digest(key: str) -> str: h = hashlib.sha256() for row in content_rows(key): h.update(('\t'.join(row) + '\n').encode()) return h.hexdigest() def fetch(): digests = {} for key, (url, accept, name) in SOURCES.items(): log(f'{key}: {url}') curl(url, accept, RAW / name) d = content_digest(key) if CONTENT_SHA256.get(key) and CONTENT_SHA256[key] != d: raise RuntimeError(f'{name}: content SHA-256 {d} differs from the pinned {CONTENT_SHA256[key]} — the ' f'source revised its numbers; inspect them, then update CONTENT_SHA256["{key}"]') digests[key] = d if not RETRO_CSV.exists(): curl(RETRO_URL, 'text/csv', DATA / 'retrograde-periods.csv') missing = [k for k in SOURCES if not CONTENT_SHA256.get(k)] if missing: log('unpinned sources — paste into CONTENT_SHA256:') for k in missing: log(f" '{k}': '{digests[k]}',") # ------------------------------------------------------------------ reading def territories() -> dict[str, tuple[str | None, str | None]]: """Territory code -> (Italian name, parent code), from the SDMX codelist.""" s = (RAW / SOURCES['istat_territories'][2]).read_text(encoding='utf-8') out = {} for c in re.finditer(r']*>(.*?)', s, re.S): name = re.search(r'([^<]+)', c.group(2)) parent = re.search(r'\s* dict[int, int]: d = json.loads((RAW / SOURCES['eurostat_annual'][2]).read_text()) index = d['dimension']['time']['category']['index'] year_of = {i: int(y) for y, i in index.items()} return {year_of[int(i)]: int(round(v)) for i, v in d['value'].items()} def national_annual(eu: pd.Series, istat: pd.DataFrame) -> pd.DataFrame: """year, marriages, source: Eurostat before ISTAT's first year, ISTAT from it.""" it = istat[(istat.area == 'IT') & (istat.rite == 'ALL') & (istat.month == 0)].set_index('year').marriages rows = [{'year': int(y), 'marriages': int(v), 'source': 'Eurostat demo_nind'} for y, v in eu.items() if y < FIRST_YEAR_ISTAT] rows += [{'year': int(y), 'marriages': int(v), 'source': 'ISTAT 24_186_DF_DCIS_MATRDEMO_7'} for y, v in it.items()] return pd.DataFrame(rows).sort_values('year').reset_index(drop=True) def read_istat() -> pd.DataFrame: """Long table: area, rite, year, month (1–12, 0 = the year), marriages.""" t = pd.read_csv(RAW / SOURCES['istat_month_rite'][2], dtype={'REF_AREA': str, 'TIME_PERIOD': int}) t = t[t.DATA_TYPE == 'MARRG_FROM2017'] if 'DATA_TYPE' in t else t month = {m: i + 1 for i, m in enumerate(MONTHS)} | {'YEAR': 0} out = pd.DataFrame({'area': t.REF_AREA, 'rite': t.MARRIAGE_RITE, 'year': t.TIME_PERIOD, 'month': t.MONTH_CELEBRATION.map(month), 'marriages': t.OBS_VALUE.astype(int)}) if out.month.isna().any(): raise RuntimeError(f'unknown month labels: {sorted(t.MONTH_CELEBRATION[out.month.isna()].unique())}') out['month'] = out.month.astype(int) return out.sort_values(['area', 'rite', 'year', 'month']).reset_index(drop=True) def area_levels(names: dict, areas: set[str]) -> dict[str, list[str]]: """The codes of each level present in the table: regions (incl. the autonomous provinces of Bolzano and Trento, without the Trentino-Alto Adige total), provinces (NUTS-3 and the IT1xx codes of new ones).""" parts = {'ITC', 'ITD', 'ITE', 'ITF', 'ITG'} regions = sorted(a for a in areas if names.get(a, (None, None))[1] in parts and a != 'ITDA') provinces = sorted(a for a in areas if (len(a) == 5 and a[:4] in regions) or a.startswith('IT1')) return {'regions': regions, 'provinces': provinces} def region_of(code: str, names: dict) -> str: return code[:4] if not code.startswith('IT1') else names[code][1] def stable_units(istat: pd.DataFrame, names: dict, levels: dict) -> tuple[pd.DataFrame, dict]: """Marriages by stable unit (``MERGES``, Sardinia whole), every month and year, every rite.""" merged = {p: u for u, (_, ps) in MERGES.items() for p in ps} rows = [] unit_meta = {} for p in levels['provinces']: reg = region_of(p, names) if reg == SARDINIA: continue u = merged.get(p, p) unit_meta[u] = {'name': MERGES[u][0] if u in MERGES else names[p][0], 'region': reg} rows.append(istat[istat.area == p].assign(unit=u)) rows.append(istat[istat.area == SARDINIA].assign(unit=SARDINIA)) unit_meta[SARDINIA] = {'name': names[SARDINIA][0], 'region': SARDINIA} u = pd.concat(rows).groupby(['unit', 'rite', 'year', 'month'], as_index=False).marriages.sum() return u, unit_meta # ------------------------------------------------------------------ calendar def easter(y: int) -> dt.date: """Western Easter (Gregorian computus, Meeus/Jones/Butcher).""" a, b, c = y % 19, y // 100, y % 100 d, e = divmod(b, 4) f = (b + 8) // 25 g = (b - f + 1) // 3 h = (19 * a + b - d - g + 15) % 30 i, k = divmod(c, 4) l_ = (32 + 2 * e + 2 * i - h - k) % 7 m = (a + 11 * h + 22 * l_) // 451 month, day = divmod(h + l_ - 7 * m + 114, 31) return dt.date(y, month, day + 1) def public_holidays(y: int) -> set[dt.date]: """National public holidays (law 260/1949 as amended; 2 June again from 2001), with Easter Monday. Patron saints' days are local and left out.""" md = [(1, 1), (1, 6), (4, 25), (5, 1), (8, 15), (11, 1), (12, 8), (12, 25), (12, 26)] + [(6, 2)] * (y >= 2001) return {dt.date(y, m, d) for m, d in md} | {easter(y) + dt.timedelta(days=1)} def lent_days(e: dt.date) -> set[dt.date]: """Ash Wednesday (Easter − 46) to Holy Saturday.""" return {e + dt.timedelta(days=i) for i in range(-46, 0)} def retro_days(planet: str) -> set[dt.date]: p = RETRO_CSV if RETRO_CSV.exists() else DATA / 'retrograde-periods.csv' t = pd.read_csv(p) t = t[t.planet == planet] days = set() for _, r in t.iterrows(): a = dt.date.fromisoformat(r.station_retrograde_utc[:10]) b = dt.date.fromisoformat(r.station_direct_utc[:10]) days |= {a + dt.timedelta(days=i) for i in range((b - a).days + 1)} return days # Years the page's date checker accepts; the retrograde periods are exported for them. CHECKER_YEARS = (2026, 2035) def retrograde_upcoming() -> list[dict]: """Venus and Mars retrograde periods (station to station, UTC dates) that touch CHECKER_YEARS.""" p = RETRO_CSV if RETRO_CSV.exists() else DATA / 'retrograde-periods.csv' t = pd.read_csv(p) lo, hi = f'{CHECKER_YEARS[0]}-01-01', f'{CHECKER_YEARS[1]}-12-31' rows = [] for _, r in t[t.planet.isin(['Venus', 'Mars'])].iterrows(): a, b = r.station_retrograde_utc[:10], r.station_direct_utc[:10] if b >= lo and a <= hi: rows.append({'planet': r.planet, 'from': a, 'to': b}) return sorted(rows, key=lambda x: x['from']) def month_days(y: int, m: int) -> list[dt.date]: return [dt.date(y, m, d) for d in range(1, cal_mod.monthrange(y, m)[1] + 1)] def calendar(years: range) -> pd.DataFrame: """Per year and month: days, Fridays, Saturdays, Sundays, public holidays, days of Lent, days with Venus and with Mars retrograde.""" venus, mars = retro_days('Venus'), retro_days('Mars') rows = [] for y in years: hol, lent = public_holidays(y), lent_days(easter(y)) for m in range(1, 13): days = month_days(y, m) rows.append({'year': y, 'month': m, 'days': len(days), 'fridays': sum(d.weekday() == 4 for d in days), 'saturdays': sum(d.weekday() == 5 for d in days), 'sundays': sum(d.weekday() == 6 for d in days), 'holidays': sum(d in hol for d in days), 'lent_days': sum(d in lent for d in days), 'venus_retro_days': sum(d in venus for d in days), 'mars_retro_days': sum(d in mars for d in days)}) return pd.DataFrame(rows) # ------------------------------------------------------------------ estimates MA_WEIGHTS = (0.125, 0.25, 0.25, 0.25, 0.125) # centred 2×4 moving average: one weight per leap phase def cycle_ratios(s: pd.Series) -> pd.DataFrame: """log(marriages in a year / their centred 2×4-year moving average), with the year's place in the leap cycle (phase 0 = leap year). The 2×4 average gives every phase of the cycle the same weight, so it keeps the trend and drops the cycle; the mean log ratio by phase is the cycle itself. Years whose five-year window touches SHOCK_YEARS are left out.""" rows = [] for y in s.index: win = range(y - 2, y + 3) if all(w in s.index for w in win) and not (set(win) & SHOCK_YEARS): ma = sum(w_ * s[w] for w_, w in zip(MA_WEIGHTS, win)) rows.append({'year': int(y), 'phase': int(y % 4), 'log_ratio': float(np.log(s[y] / ma))}) return pd.DataFrame(rows, columns=['year', 'phase', 'log_ratio']) MODEL_YEARS = [y for y in range(FIRST_YEAR_ISTAT, LAST_YEAR_ISTAT + 1) if y not in EXCLUDED_YEARS] MODEL_TERMS = ['fridays', 'saturdays', 'sundays', 'holidays', 'lent_share', 'venus_share', 'mars_share'] PERMUTATIONS = 2000 SEED = 20261023 def model_frame(monthly: pd.DataFrame, cal: pd.DataFrame) -> pd.DataFrame: """``monthly``: year, month, marriages (one rite of one area).""" d = monthly[monthly.year.isin(MODEL_YEARS) & (monthly.month > 0)].merge(cal, on=['year', 'month']) d = d.sort_values(['year', 'month']).reset_index(drop=True) d['lent_share'] = d.lent_days / d.days d['venus_share'] = d.venus_retro_days / d.days d['mars_share'] = d.mars_retro_days / d.days d['y'] = np.log(d.marriages.clip(lower=0.5) / d.days) return d def design(d: pd.DataFrame) -> tuple[np.ndarray, list[str]]: """Year effects, month effects, a linear drift of each month's effect (the season moved as religious weddings fell from two thirds to two fifths of the total), and the calendar terms. January is the base.""" t = (d.year - 2014).astype(float) month = pd.get_dummies(d.month, prefix='m').astype(float).iloc[:, 1:] X = pd.concat([pd.get_dummies(d.year, prefix='y').astype(float), month, month.mul(t, axis=0).add_suffix('_drift'), d[MODEL_TERMS].astype(float)], axis=1) return X.values, list(X.columns) def fit(d: pd.DataFrame) -> tuple[dict[str, float], np.ndarray, dict[str, float]]: X, names = design(d) b, *_ = np.linalg.lstsq(X, d.y.values, rcond=None) res = d.y.values - X @ b dof = len(d) - np.linalg.matrix_rank(X) cov = (res @ res / dof) * np.linalg.pinv(X.T @ X) return dict(zip(names, b)), res, dict(zip(names, np.sqrt(np.diag(cov)))) def lent_shares(y: int, e: dt.date) -> np.ndarray: lent = lent_days(e) return np.array([sum(x in lent for x in month_days(y, m)) / cal_mod.monthrange(y, m)[1] for m in range(1, 13)]) def permutation_draws(d: pd.DataFrame, rng: np.random.Generator) -> dict[str, np.ndarray]: """The model's lent, Venus and Mars coefficients under PERMUTATIONS randomisations each: * Lent: every model year gets the Easter (day of the year) of another model year; * Venus, Mars: the day-by-day retrograde calendar shifted round 2004–2024 by a random offset of 60 days or more (a multiple of the synodic period nearly reproduces the real calendar; such draws belong to the null distribution all the same).""" years = MODEL_YEARS doy = {y: (easter(y) - dt.date(y, 1, 1)).days for y in years} shares = {(y, s): lent_shares(y, dt.date(y, 1, 1) + dt.timedelta(days=doy[s])) for y in years for s in years} rows = list(zip(d.year, d.month - 1)) out = {'lent_share': [], 'venus_share': [], 'mars_share': []} for _ in range(PERMUTATIONS): src = dict(zip(years, rng.permutation(years))) dp = d.copy() dp['lent_share'] = [shares[(y, src[y])][m] for y, m in rows] out['lent_share'].append(fit(dp)[0]['lent_share']) start, end = dt.date(FIRST_YEAR_ISTAT, 1, 1), dt.date(LAST_YEAR_ISTAT, 12, 31) span = (end - start).days + 1 all_days = [start + dt.timedelta(days=i) for i in range(span)] month_of_day = np.array([(x.year - start.year) * 12 + x.month - 1 for x in all_days]) days_in = np.bincount(month_of_day) row_month = ((d.year - start.year) * 12 + d.month - 1).values for planet, term in (('Venus', 'venus_share'), ('Mars', 'mars_share')): retro = retro_days(planet) v = np.array([x in retro for x in all_days], dtype=float) for _ in range(PERMUTATIONS): k = int(rng.integers(60, span - 60)) share = np.bincount(month_of_day, weights=np.roll(v, k), minlength=len(days_in)) / days_in dp = d.copy() dp[term] = share[row_month] out[term].append(fit(dp)[0][term]) return {k: np.array(v) for k, v in out.items()} def summary(obs: float, ols_se: float, perm: np.ndarray) -> dict: """Coefficient per whole month (a month spent entirely in Lent, with Venus retrograde, …), its randomisation p-value, and the smallest effect the data can show (80 % power, two-sided 5 %: about 2.8 × the spread of the permuted coefficients).""" mde = 2.8 * float(perm.std()) edges = np.linspace(-4 * perm.std(), 4 * perm.std(), 33) counts, _ = np.histogram(np.clip(perm, edges[0], edges[-1]), bins=edges) return {'coef': round(float(obs), 4), 'pct': round(100 * float(np.exp(obs) - 1), 1), 'perm_hist': {'edges': [round(float(e), 5) for e in edges], 'counts': counts.tolist()}, 'ols_se': round(float(ols_se), 4), 'perm_sd': round(float(perm.std()), 4), 'p_value': round(float((np.sum(np.abs(perm) >= abs(obs)) + 1) / (len(perm) + 1)), 4), 'mde_log': round(mde, 4), 'mde_pct_up': round(100 * (np.exp(mde) - 1), 1), 'mde_pct_down': round(100 * (np.exp(-mde) - 1), 1), # 95 % interval of the effect from the spread of the randomised coefficients 'ci95_pct': [round(100 * (float(np.exp(obs - 1.96 * perm.std())) - 1), 1), round(100 * (float(np.exp(obs + 1.96 * perm.std())) - 1), 1)]} def monthly_models(istat: pd.DataFrame, cal: pd.DataFrame) -> dict: """The calendar terms of the monthly model for religious and for civil marriages (Italy as a whole), each with a randomisation test instead of the textbook standard error (months of one year share a level and neighbouring months share weather, so the OLS error is too optimistic).""" rng = np.random.default_rng(SEED) out = {'years': MODEL_YEARS, 'excluded_years': EXCLUDED_YEARS, 'permutations': PERMUTATIONS} for rite in ('REL', 'CIVIL'): d = model_frame(istat[(istat.area == 'IT') & (istat.rite == rite)], cal) b, res, se = fit(d) perm = permutation_draws(d, rng) out[RITES[rite]] = { 'months': int(len(d)), 'residual_sd': round(float(res.std(ddof=0)), 4), **{k: summary(b[k], se[k], perm[k]) for k in ('lent_share', 'venus_share', 'mars_share')}, 'other_terms': {k: {'coef': round(float(b[k]), 4), 'pct': round(100 * float(np.exp(b[k]) - 1), 1), 'ols_se': round(float(se[k]), 4)} for k in ('fridays', 'saturdays', 'sundays', 'holidays')}, } return out def regional_lent(istat: pd.DataFrame, cal: pd.DataFrame, regions: list[str]) -> dict[str, dict]: """The Lent coefficient of each region's own monthly model, religious and civil (OLS only: the national model carries the randomisation test; here the regions are compared with each other).""" out = {} for code in regions: row = {} for rite in ('REL', 'CIVIL'): d = model_frame(istat[(istat.area == code) & (istat.rite == rite)], cal) b, _, se = fit(d) row[RITES[rite]] = {'coef': round(float(b['lent_share']), 4), 'ols_se': round(float(se['lent_share']), 4)} out[code] = row return out def per_day(istat: pd.DataFrame, area: str, rite: str) -> pd.DataFrame: """year × month table of marriages per day, as a share of the year's daily rate (1 = an average day).""" t = istat[(istat.area == area) & (istat.rite == rite) & istat.year.isin(MODEL_YEARS)] m = t[t.month > 0].pivot(index='year', columns='month', values='marriages') days = pd.DataFrame({mo: [cal_mod.monthrange(y, mo)[1] for y in m.index] for mo in range(1, 13)}, index=m.index) year = t[t.month == 0].set_index('year').marriages ydays = pd.Series([366 if cal_mod.isleap(y) else 365 for y in m.index], index=m.index) return (m / days).div(year / ydays, axis=0) def season_profiles(istat: pd.DataFrame) -> dict: """Mean per-day index of each month (1 = the year's average day), by area and rite.""" out = {} for area in ['IT', *MACRO]: out[area] = {RITES[r]: [round(float(v), 3) for v in per_day(istat, area, r).mean().values] for r in ('REL', 'CIVIL')} return out def neighbour_test(istat: pd.DataFrame, area: str = 'IT') -> list[dict]: """For each month: log(its per-day rate) − the mean log rate of the two neighbouring months, per year, religious and civil; then religious minus civil. A month the Church side avoids beyond what the season does to all weddings shows a negative difference. The neighbours of January and December wrap within the year. Mean ± its standard error over the model years.""" lr = {r: np.log(per_day(istat, area, r).clip(lower=1e-6)) for r in ('REL', 'CIVIL')} out = [] for m in range(1, 13): prev, nxt = (m - 2) % 12 + 1, m % 12 + 1 c = {r: lr[r][m] - (lr[r][prev] + lr[r][nxt]) / 2 for r in lr} diff = c['REL'] - c['CIVIL'] n = len(diff) out.append({'month': m, 'religious': round(float(c['REL'].mean()), 4), 'civil': round(float(c['CIVIL'].mean()), 4), 'difference': round(float(diff.mean()), 4), 'difference_se': round(float(diff.std(ddof=1) / np.sqrt(n)), 4), 'years_below_zero': int((diff < 0).sum()), 'years': int(n)}) return out def may_clean(istat: pd.DataFrame) -> dict: """May against its neighbours (religious minus civil, as in ``neighbour_test``) only in the model years when no day of April is in Lent: in the other years Lent empties April, the neighbour mean drops and May looks higher than it is (the all-years difference correlates with April's Lent days, r ≈ 0.8).""" years = [y for y in MODEL_YEARS if not any(d in lent_days(easter(y)) for d in month_days(y, 4))] lr = {r: np.log(per_day(istat, 'IT', r).loc[years]) for r in ('REL', 'CIVIL')} c = {r: lr[r][5] - (lr[r][4] + lr[r][6]) / 2 for r in lr} diff = c['REL'] - c['CIVIL'] return {'years': years, 'difference': round(float(diff.mean()), 4), 'difference_se': round(float(diff.std(ddof=1) / np.sqrt(len(diff))), 4), 'pct': round(100 * (float(np.exp(diff.mean())) - 1), 1)} def november_vs_average(istat: pd.DataFrame) -> dict: """November's per-day rate against the year's average day, religious divided by civil, per year: the November gap without January–February as the base. January–February church weddings fell much faster than the rest of the year (3.3 % of the year in 2004, 0.75 % in 2024), so the gap against them shrinks while November itself stays as empty as it was.""" p = {r: per_day(istat, 'IT', r) for r in ('REL', 'CIVIL')} ratio = p['REL'][11] / p['CIVIL'][11] jf = (p['REL'][1] + p['REL'][2]) / 2 return {'by_year': {int(y): round(float(v), 4) for y, v in ratio.items()}, 'religious_november_index': {int(y): round(float(v), 4) for y, v in p['REL'][11].items()}, 'religious_jan_feb_index': {int(y): round(float(v), 4) for y, v in jf.items()}} def november_gap(istat: pd.DataFrame, areas: list[str]) -> dict: """Religious weddings per day in November against January–February, divided by the same ratio for civil weddings: the November gap of the Church side, with winter weather and the year's level taken out by the civil control. Per year for Italy; for each area on the model years pooled (a small region can have a November without a single church wedding, and a yearly log ratio would be infinite).""" def gap(area): p = {r: per_day(istat, area, r) for r in ('REL', 'CIVIL')} g = {r: np.log(p[r][11] / ((p[r][1] + p[r][2]) / 2)) for r in p} return g['REL'] - g['CIVIL'] def pooled(area): t = istat[(istat.area == area) & istat.year.isin(MODEL_YEARS)] days = {m: sum(cal_mod.monthrange(y, m)[1] for y in MODEL_YEARS) for m in (1, 2, 11)} g = {} for r in ('REL', 'CIVIL'): n = t[t.rite == r].groupby('month').marriages.sum() g[r] = np.log((n[11] / days[11]) / ((n[1] + n[2]) / (days[1] + days[2]))) return float(g['REL'] - g['CIVIL']) it = gap('IT') return {'italy_by_year': {int(y): round(float(v), 4) for y, v in it.items()}, 'italy_mean_log': round(float(it.mean()), 4), 'italy_pct': round(100 * float(np.exp(it.mean()) - 1), 1), 'italy_years_below_zero': int((it < 0).sum()), 'by_area': {a: round(pooled(a), 4) for a in areas}} def easter_april(istat: pd.DataFrame) -> list[dict]: """Share of the year's religious and civil weddings celebrated in March and April, with the date of Easter: the raw picture behind the Lent term.""" rows = [] for y in MODEL_YEARS: e = easter(y) r = {} for rite in ('REL', 'CIVIL'): t = istat[(istat.area == 'IT') & (istat.rite == rite) & (istat.year == y)].set_index('month').marriages r[RITES[rite]] = {'march_pct': round(100 * float(t[3] / t[0]), 3), 'april_pct': round(100 * float(t[4] / t[0]), 3)} rows.append({'year': y, 'easter': e.isoformat(), 'easter_doy': (e - dt.date(y, 1, 1)).days, 'lent_days_april': int(sum(d in lent_days(e) for d in month_days(y, 4))), **r}) return rows def slope_test(x: np.ndarray, y: np.ndarray, rng: np.random.Generator) -> dict: """Least-squares slope of y on x over units (equal weight), tested by shuffling x between units.""" b = float(np.polyfit(x, y, 1)[0]) perm = np.array([np.polyfit(rng.permutation(x), y, 1)[0] for _ in range(PERMUTATIONS)]) return {'slope': round(b, 6), 'correlation': round(float(np.corrcoef(x, y)[0, 1]), 3), 'p_value': round(float((np.sum(np.abs(perm) >= abs(b)) + 1) / (len(perm) + 1)), 4)} def compute(): names = territories() istat = read_istat() levels = area_levels(names, set(istat.area)) units, unit_meta = stable_units(istat, names, levels) eu = pd.Series(eurostat_series()).sort_index() eu = eu[(eu.index >= FIRST_YEAR_EU) & (eu.index <= LAST_YEAR_EU)] cal = calendar(range(FIRST_YEAR_ISTAT, LAST_YEAR_ISTAT + 1)) istat.to_csv(DATA / 'istat_month_rite_long.csv', index=False) units.to_csv(DATA / 'stable_units.csv', index=False) eu.rename('marriages').rename_axis('year').to_csv(DATA / 'eurostat_annual.csv') nat = national_annual(eu, istat) nat.to_csv(DATA / 'national_annual.csv', index=False) cal.to_csv(DATA / 'calendar.csv', index=False) (DATA / 'territories.json').write_text(json.dumps({'names': names, 'levels': levels, 'units': unit_meta}, ensure_ascii=False, indent=1)) rng = np.random.default_rng(SEED + 1) # leap years: national (Eurostat 1960–2024; ISTAT by rite 2004–2024) and by stable unit nr = cycle_ratios(nat.set_index('year').marriages) by_rite = {} for rite in RITES: s = istat[(istat.area == 'IT') & (istat.rite == rite) & (istat.month == 0)].set_index('year').marriages by_rite[RITES[rite]] = cycle_ratios(s) unit_rows = [] for u, meta in unit_meta.items(): g = units[units.unit == u] allr = g[(g.rite == 'ALL') & (g.month == 0)].set_index('year').marriages rel = g[(g.rite == 'REL') & (g.month == 0)].set_index('year').marriages cr = cycle_ratios(allr) base = [y for y in range(FIRST_YEAR_ISTAT, 2020)] unit_rows.append({'code': u, 'name': meta['name'], 'region': meta['region'], 'religious_share_pct': round(100 * float(rel[base].sum() / allr[base].sum()), 1), 'leap_log': float(cr[cr.phase == 0].log_ratio.mean()), 'pre_leap_log': float(cr[cr.phase == 3].log_ratio.mean()), 'leap_years': int((cr.phase == 0).sum()), 'leap_below_zero': bool(cr[cr.phase == 0].log_ratio.mean() < 0), 'marriages_2024': int(allr[LAST_YEAR_ISTAT])}) ut = pd.DataFrame(unit_rows).sort_values('code').reset_index(drop=True) ut.to_csv(DATA / 'unit_summary.csv', index=False) leap_slope = slope_test(ut.religious_share_pct.to_numpy(float), ut.leap_log.to_numpy(float), rng) # months models = monthly_models(istat, cal) reg_lent = regional_lent(istat, cal, levels['regions']) reg_rows = [] for code in levels['regions']: t = istat[(istat.area == code) & (istat.month == 0) & istat.year.between(FIRST_YEAR_ISTAT, 2019)] share = t[t.rite == 'REL'].marriages.sum() / t[t.rite == 'ALL'].marriages.sum() reg_rows.append({'code': code, 'name': names[code][0], 'religious_share_pct': round(100 * float(share), 1), 'lent_religious_coef': reg_lent[code]['religious']['coef'], 'lent_religious_se': reg_lent[code]['religious']['ols_se'], 'lent_civil_coef': reg_lent[code]['civil']['coef']}) rt = pd.DataFrame(reg_rows) nov = november_gap(istat, ['IT', *MACRO, *levels['regions']]) rt['november_gap_log'] = rt.code.map(nov['by_area']) nov_rel = istat[(istat.rite == 'REL') & (istat.month == 11) & istat.year.isin(MODEL_YEARS)] rt['november_religious_marriages'] = rt.code.map(nov_rel.groupby('area').marriages.sum()).astype(int) rt.to_csv(DATA / 'region_summary.csv', index=False) lent_slope = slope_test(rt.religious_share_pct.to_numpy(float), rt.lent_religious_coef.to_numpy(float), rng) nov_slope = slope_test(rt.religious_share_pct.to_numpy(float), rt.november_gap_log.to_numpy(float), rng) pct = lambda v: round(100 * (float(np.exp(v)) - 1), 1) # noqa: E731 est = { 'national_leap': {int(p): {'mean_log_ratio': round(float(g.log_ratio.mean()), 4), 'years': [int(y) for y in g.year], 'below_zero': int((g.log_ratio < 0).sum())} for p, g in nr.groupby('phase')}, 'national_cycle': [[int(r.year), int(r.phase), round(float(r.log_ratio), 4)] for r in nr.itertuples()], 'istat_leap_by_rite': {k: {int(p): round(float(g.log_ratio.mean()), 4) for p, g in v.groupby('phase')} for k, v in by_rite.items()}, 'istat_cycle_by_rite': {k: [[int(r.year), int(r.phase), round(float(r.log_ratio), 4)] for r in v.itertuples()] for k, v in by_rite.items()}, 'unit_leap': {'units': int(len(ut)), 'median_leap_pct': pct(ut.leap_log.median()), 'median_pre_leap_pct': pct(ut.pre_leap_log.median()), 'share_below_zero': round(float((ut.leap_log < 0).mean()), 3), 'by_religious_share': leap_slope}, 'monthly_model': models, 'regional_lent': {'by_religious_share': lent_slope}, 'season_profiles': season_profiles(istat), 'neighbour_test': neighbour_test(istat), 'may_clean': may_clean(istat), 'november_vs_average': november_vs_average(istat), 'november_gap': {**{k: v for k, v in nov.items() if k != 'by_area'}, 'macro': {MACRO[a]: pct(nov['by_area'][a]) for a in MACRO}, 'by_religious_share': nov_slope}, 'easter_april': easter_april(istat), 'weekday_reports': WEEKDAY_REPORTS, } (DATA / 'estimates.json').write_text(json.dumps(est, ensure_ascii=False, indent=1)) log(f'ISTAT {istat.year.min()}–{istat.year.max()}, {istat.area.nunique()} areas; units {len(ut)}; ' f'national {nat.year.min()}–{nat.year.max()}, {nat.marriages.sum():,} marriages') for p, v in est['national_leap'].items(): log(f' national, phase {p}{" (leap)" if p == 0 else ""}: {pct(v["mean_log_ratio"]):+.1f} % ' f'({len(v["years"])} years, {v["below_zero"]} below the average)') for k, v in est['istat_leap_by_rite'].items(): log(f' ISTAT {k:9s} by phase: ' + ', '.join(f'{p}: {pct(x):+.1f} %' for p, x in v.items())) u = est['unit_leap'] log(f' units: leap median {u["median_leap_pct"]:+.1f} %, year before {u["median_pre_leap_pct"]:+.1f} %, below zero in ' f'{100 * u["share_below_zero"]:.0f} % of {u["units"]}; slope on religious share {leap_slope}') for rite in ('religious', 'civil'): m = models[rite] log(f' monthly model, {rite}: {m["months"]} months, residual sd {m["residual_sd"]}') for k in ('lent_share', 'venus_share', 'mars_share'): log(f' {k:12s} {m[k]["pct"]:+6.1f} % (p = {m[k]["p_value"]}, visible from ' f'{m[k]["mde_pct_up"]:+.1f} / {m[k]["mde_pct_down"]:+.1f} %)') for k, t in m['other_terms'].items(): log(f' {k:12s} {t["pct"]:+6.1f} % per day of the kind (OLS se {t["ols_se"]})') log(f' regional Lent vs religious share: {lent_slope}') for row in est['neighbour_test']: log(f' neighbours, month {row["month"]:2d}: religious {row["religious"]:+.3f}, civil {row["civil"]:+.3f}, ' f'difference {row["difference"]:+.3f} ± {row["difference_se"]:.3f} ({row["years_below_zero"]}/{row["years"]} below)') log(f' November gap: Italy {est["november_gap"]["italy_pct"]:+.1f} %, {est["november_gap"]["macro"]}, ' f'slope on religious share {nov_slope}') # ------------------------------------------------------------------ validation def validate(): """Cross-checks; any failure stops ``export``. Results -> $IT_MARRIAGES_DATA/validation.json.""" istat = pd.read_csv(DATA / 'istat_month_rite_long.csv', dtype={'area': str}) eu = pd.read_csv(DATA / 'eurostat_annual.csv').set_index('year').marriages terr = json.loads((DATA / 'territories.json').read_text()) names, levels = terr['names'], terr['levels'] units = pd.read_csv(DATA / 'stable_units.csv', dtype={'unit': str}) problems, checks = [], {} # 1. twelve months add up to the year, for every area and rite m = istat[istat.month > 0].groupby(['area', 'rite', 'year']).agg(total=('marriages', 'sum'), n=('month', 'size')) y = istat[istat.month == 0].set_index(['area', 'rite', 'year']).marriages j = m.join(y.rename('year_total'), how='outer') bad = j[(j.n != 12) | (j.total != j.year_total)] checks['months_vs_year_mismatches'] = int(len(bad)) problems += [f'{a} {r} {yr}: {row.n} months sum to {row.total}, year {row.year_total}' for (a, r, yr), row in bad.head(20).iterrows()] # 2. religious + civil = all p = istat.pivot_table(index=['area', 'year', 'month'], columns='rite', values='marriages') bad = p[(p.REL + p.CIVIL) != p.ALL] checks['rites_vs_all_mismatches'] = int(len(bad)) problems += [f'{a} {yr}-{mo:02d}: religious + civil {r.REL + r.CIVIL}, all {r.ALL}' for (a, yr, mo), r in bad.head(20).iterrows()] # 3. regions add up to Italy; provinces to their region (Sardinia's 2005–2016 provinces are missing) tot = istat[(istat.rite == 'ALL')].set_index(['area', 'year', 'month']).marriages reg = istat[istat.area.isin(levels['regions']) & (istat.rite == 'ALL')].groupby(['year', 'month']).marriages.sum() it = tot.loc['IT'] checks['regions_vs_italy_mismatches'] = int((reg != it.reindex(reg.index)).sum()) problems += [f'regions sum to {reg[k]}, Italy {it[k]} in {k}' for k in reg.index[reg != it.reindex(reg.index)][:20]] prov = istat[istat.area.isin(levels['provinces']) & (istat.rite == 'ALL')].copy() prov['region'] = [a[:4] if not a.startswith('IT1') else names[a][1] for a in prov.area] ps = prov.groupby(['region', 'year', 'month']).marriages.sum() rs = tot.reindex(ps.index) off = ps[ps != rs] sardinia_gap = [k for k in off.index if k[0] == 'ITG2' and 2005 <= k[1] <= 2016] checks['provinces_vs_region_mismatches'] = int(len(off) - len(sardinia_gap)) checks['sardinia_years_with_missing_provinces'] = sorted({int(k[1]) for k in sardinia_gap}) problems += [f'provinces of {k[0]} sum to {off[k]}, region {rs[k]} in {k[1]}-{k[2]:02d}' for k in off.index if k not in sardinia_gap][:20] # 4. stable units add up to Italy us = units[units.rite == 'ALL'].groupby(['year', 'month']).marriages.sum() checks['units_vs_italy_mismatches'] = int((us != it.reindex(us.index)).sum()) problems += [f'units sum to {us[k]}, Italy {it[k]} in {k}' for k in us.index[us != it.reindex(us.index)][:20]] # 5. every year of both series is there; ISTAT and Eurostat agree on the national total miss_eu = sorted(set(range(FIRST_YEAR_EU, LAST_YEAR_EU + 1)) - set(eu.index)) miss_it = sorted(set(range(FIRST_YEAR_ISTAT, LAST_YEAR_ISTAT + 1)) - set(it.index.get_level_values(0))) checks['eurostat_missing_years'], checks['istat_missing_years'] = miss_eu, miss_it problems += [f'Eurostat: no {y_}' for y_ in miss_eu] + [f'ISTAT: no {y_}' for y_ in miss_it] both = sorted(set(eu.index) & set(it.index.get_level_values(0))) rel = {int(y_): float(abs(eu[y_] - it[(y_, 0)]) / it[(y_, 0)]) for y_ in both} checks['istat_vs_eurostat_max_rel'] = round(max(rel.values()), 5) differ = {y_: r for y_, r in rel.items() if eu[y_] != it[(y_, 0)]} checks['istat_vs_eurostat_years_differing'] = sorted(differ) problems += [f'{y_}: Eurostat {eu[y_]}, ISTAT {it[(y_, 0)]}' for y_ in differ if y_ not in KNOWN_DIFFERENCES] # 6. every unit has every year n = units[(units.rite == 'ALL') & (units.month == 0)].groupby('unit').year.nunique() short = n[n != LAST_YEAR_ISTAT - FIRST_YEAR_ISTAT + 1] checks['units'] = int(len(n)) checks['units_with_missing_years'] = short.index.tolist() problems += [f'unit {u}: {k} years' for u, k in short.items()] (DATA / 'validation.json').write_text(json.dumps({'checks': checks, 'problems': problems}, ensure_ascii=False, indent=1)) for k, v in checks.items(): log(f'{k}: {v}') for p_ in problems: log(' PROBLEM', p_) log('validation ' + ('passed' if not problems else f'FAILED: {len(problems)} problems')) return not problems # ------------------------------------------------------------------ export def export(): val = json.loads((DATA / 'validation.json').read_text()) if val['problems']: raise SystemExit('validation failed — run validate and resolve its problems first') istat = pd.read_csv(DATA / 'istat_month_rite_long.csv', dtype={'area': str}) nat = pd.read_csv(DATA / 'national_annual.csv') eu_all = pd.read_csv(DATA / 'eurostat_annual.csv').set_index('year').marriages terr = json.loads((DATA / 'territories.json').read_text()) names, levels = terr['names'], terr['levels'] est = json.loads((DATA / 'estimates.json').read_text()) ut = pd.read_csv(DATA / 'unit_summary.csv', dtype={'code': str}) rt = pd.read_csv(DATA / 'region_summary.csv', dtype={'code': str}) cal = pd.read_csv(DATA / 'calendar.csv') it = istat[istat.area == 'IT'] nat_month = {RITES[r]: it[(it.rite == r) & (it.month > 0)][['year', 'month', 'marriages']].astype(int).values.tolist() for r in ('REL', 'CIVIL')} nat_year = {RITES[r]: it[(it.rite == r) & (it.month == 0)][['year', 'marriages']].astype(int).values.tolist() for r in RITES} pct = lambda v: round(100 * (float(np.exp(v)) - 1), 1) # noqa: E731 out = { 'generated_by': 'scripts/research/italy_marriages.py', 'first_year': FIRST_YEAR_EU, 'last_year': LAST_YEAR_EU, 'first_year_monthly': FIRST_YEAR_ISTAT, 'last_year_monthly': LAST_YEAR_ISTAT, 'checker_years': list(CHECKER_YEARS), 'total_marriages': int(nat[(nat.year >= FIRST_YEAR_EU) & (nat.year <= LAST_YEAR_EU)].marriages.sum()), 'total_marriages_monthly': int(it[(it.rite == 'ALL') & (it.month == 0)].marriages.sum()), 'national_annual': nat[['year', 'marriages']].astype(int).values.tolist(), # Venus and Mars retrograde periods for the page's date checker («Controlla la tua data») 'retrograde_upcoming': retrograde_upcoming(), # years where Eurostat prints another national total than ISTAT (ISTAT is used) 'eurostat_differences': [{'year': int(y), 'eurostat': int(eu_all[y]), 'istat': int(n)} for y, n in nat.set_index('year').marriages.items() if y >= FIRST_YEAR_ISTAT and y in eu_all and eu_all[y] != n], 'national_monthly': nat_month, 'national_annual_by_rite': nat_year, 'easter': {int(y): easter(int(y)).isoformat() for y in range(FIRST_YEAR_ISTAT, LAST_YEAR_ISTAT + 1)}, 'calendar_columns': ['year', 'month', 'days', 'lent_days', 'fridays', 'saturdays', 'sundays', 'holidays', 'venus_retro_days', 'mars_retro_days'], 'calendar': cal[['year', 'month', 'days', 'lent_days', 'fridays', 'saturdays', 'sundays', 'holidays', 'venus_retro_days', 'mars_retro_days']].astype(int).values.tolist(), 'macro_areas': MACRO, 'regions': [{'code': r.code, 'name': r.name, 'religious_share_pct': float(r.religious_share_pct), 'lent_religious_pct': pct(r.lent_religious_coef), 'lent_civil_pct': pct(r.lent_civil_coef), 'november_gap_pct': pct(r.november_gap_log), 'november_religious_marriages': int(r.november_religious_marriages)} for r in rt.sort_values('code').itertuples()], 'units': [{'code': r.code, 'name': r.name, 'region': r.region, 'religious_share_pct': float(r.religious_share_pct), 'leap_pct': pct(r.leap_log), 'pre_leap_pct': pct(r.pre_leap_log), 'leap_years': int(r.leap_years), 'leap_below_zero': bool(r.leap_below_zero), 'marriages_2024': int(r.marriages_2024)} for r in ut.itertuples()], 'estimates': est, 'validation': val['checks'], } JSON_OUT.parent.mkdir(parents=True, exist_ok=True) JSON_OUT.write_text(json.dumps(out, ensure_ascii=False, separators=(',', ':')) + '\n') # dataset: ISTAT's own rows (Italy, regions, provinces as published; religious and civil, months and # year) and the national series of all marriages (Eurostat to 2003, ISTAT from 2004) keep = ['IT', *levels['regions'], *levels['provinces']] level = {'IT': 'country'} | {a: 'region' for a in levels['regions']} | {a: 'province' for a in levels['provinces']} ds = istat[istat.area.isin(keep) & istat.rite.isin(['REL', 'CIVIL'])].copy() ds = ds.assign(level=ds.area.map(level), name=ds.area.map(lambda a: names[a][0]), period=ds.month.map(lambda m: 'Y' if m == 0 else f'M{m:02d}'), rite=ds.rite.map(RITES), source='ISTAT 24_186_DF_DCIS_MATRDEMO_7').rename(columns={'area': 'code'}) e = nat.assign(level='country', code='IT', name='Italia', period='Y', rite='all') ds = pd.concat([e, ds], ignore_index=True).sort_values(['level', 'code', 'rite', 'year', 'period']) CSV_OUT.parent.mkdir(parents=True, exist_ok=True) ds[['level', 'code', 'name', 'year', 'period', 'rite', 'marriages', 'source']].to_csv(CSV_OUT, index=False, lineterminator='\n') if SCRIPT_COPY: shutil.copyfile(__file__, SCRIPT_COPY) log(f'wrote {JSON_OUT} ({JSON_OUT.stat().st_size // 1024} KB), {CSV_OUT} ({CSV_OUT.stat().st_size // 1024} KB)') def main(): ap = argparse.ArgumentParser(description=__doc__.split('\n')[0]) ap.add_argument('step', choices=['fetch', 'compute', 'validate', 'export']) a = ap.parse_args() RAW.mkdir(parents=True, exist_ok=True) if a.step == 'fetch': fetch() if a.step == 'compute': compute() if a.step == 'validate': return 0 if validate() else 1 if a.step == 'export': export() return 0 if __name__ == '__main__': sys.exit(main())