#!/usr/bin/env python3
"""How common is each zodiac sign? Births by Sun sign in the countries that publish births by month.
Source of the numbers on https://startarot.online/research/rarest-zodiac-sign
Needs Python 3.10+ and
pip install numpy pandas pyarrow scipy pyswisseph openpyxl pyreaddbc dbfread
(pyreaddbc and dbfread only for Brazil). The raw data (about 1.5 GB to download, 30 MB kept) go to
$ZODIAC_DATA (default: ./zodiac-sign-data next to this script). Steps, in order:
python3 zodiac_sign_frequency.py fetch # all sources (resumable; Brazil's FTP is slow)
python3 zodiac_sign_frequency.py compute # sign counts per country-year -> $ZODIAC_DATA/counts.parquet
python3 zodiac_sign_frequency.py validate # error of every estimation step, measured on daily data
python3 zodiac_sign_frequency.py export # page JSON + dataset CSV (+ a copy of this script in the repo)
Sub-steps: fetch-eurostat|fetch-stff|fetch-france|fetch-ew|fetch-us|fetch-br|fetch-jp. If you already
have the SINASC parquet files of birth_sky.py (startarot.online/research/real-births-chart-frequencies),
point $SINASC_PARQUET at their folder and fetch-br reads them instead of the FTP.
Run from the repo, export writes (GENERATED — regenerate, do not hand-edit):
frontend/src/lib/research/zodiac-sign-frequency.json data the page renders
frontend/static/research/zodiac-sign-frequency.csv the open dataset (long format)
frontend/static/research/zodiac_sign_frequency.py a copy of this script
A copy downloaded from the page writes the JSON and CSV next to itself.
What is counted
---------------
The Sun's sign at the moment of birth, tropical and sidereal (Lahiri), for every live birth of a
country-year whose births are published by DATE OF OCCURRENCE. Sign ingresses are computed with Swiss
Ephemeris (built-in Moshier ephemeris; seconds of error, irrelevant at day resolution) and converted
to the country's civil clock with the IANA tz database (one zone per country: the most populous one;
for the USA the Central zone, nearest the population-weighted mean offset).
On the day of an ingress the births of that day are split by the share of the local day before and
after the ingress instant (time uniform over the day; error measured on Brazil's recorded times).
Where daily counts are published (France, England and Wales, the USA 1969–1988 and 1994–2014, Brazil,
Japan 2015–2024) the count is exact up to that split. Everywhere else monthly counts are spread over days:
a monotone cubic through the cumulative monthly totals (mean-preserving, continuous across years),
times a calendar-day and weekday template, renormalised to each month's total. The template is the
mean log ratio of daily births to the same smooth curve in the six daily series of five countries
(equal weight per country). Its error is measured by leaving each daily country out (``validate``): that measured
error is published as the uncertainty of the monthly countries, and signs closer than it are ties.
Expected share without any seasonality = the share of the calendar spent with the Sun in the sign
(the same days of the same years). Signs differ in length by up to 2 days because Earth's orbit is
elliptical: that alone makes winter signs rarer, and the page separates it from seasonality.
Sources and their limits
------------------------
Eurostat demo_fmonth — live births by month of occurrence, 1960–2024 (country coverage varies),
CC BY 4.0. Germany = DE_TOT (incl. the former GDR), France 1960–1967 = FX (metropolitan).
Human Fertility Database, Short-Term Fertility Fluctuations (STFF), CC BY 4.0 — from 2000, preferred
over Eurostat where both exist. Only the years STFF's metadata calls final (and by date of
occurrence), with all 12 months: e.g. Russia 2000–2020 (from 2021 STFF carries births by month of
registration), Slovakia 2000–2015. For a country STFF covers, Eurostat years after STFF's last final
year are not used either: they are the same preliminary national figures (Slovakia 2016–2024,
Poland 2023–2024, the 2024 figures of Italy, Spain, the Netherlands, Ireland, Lithuania, ...).
Not used, because the national series counts births by month of REGISTRATION: Taiwan, Ukraine,
Azerbaijan (a registration month shifts births by up to several weeks, more than a sign's width
matters). INSEE T79JNAIS — daily births, metropolitan France 1968–2024, Licence Ouverte 2.0.
ONS Births in England and Wales 2025, table 12 — daily births 1995–2024, OGL v3.0.
US daily births 1969–1988: NCHS natality files (U.S. government work) summed by day from the Google
BigQuery public natality sample (a 50 % sample, weighted, in some states and years — every count of
1969–1971 is even), as distributed with A. Vehtari's Birthdays case study
(github.com/avehtari/casestudies); yearly totals within 0.5 % of NCHS's published ones. 1994–2014: FiveThirtyEight
`births` (CC BY 4.0) — CDC/NCHS rows for 1994–2003, SSA rows for 2004–2014 (SSA counts run about 2 %
above CDC, with a jump in 2000). No US data for 1989–1993.
SINASC (Ministério da Saúde / DATASUS) — every Brazilian live birth 1997–2024 by date (1996, the
system's first and incomplete year, is left out) (and from 2006
hour); OpenDataSUS publishes it under CC BY-ND 3.0: we publish aggregates only.
e-Stat table 0003411915 (Vital Statistics of Japan) — daily births 2015–2024, Government of Japan
Standard Terms of Use (compatible with CC BY 4.0).
Priority per country-year: daily > STFF > Eurostat. Countries with under 100,000 births in total are
kept in the CSV but left out of the rankings. Nothing here describes people born elsewhere or in
other years; the pooled figures are weighted by births, so large countries and recent decades count
most.
"""
from __future__ import annotations
import argparse
import base64
import datetime as dt
import gzip
import html
import http.cookiejar
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
from zoneinfo import ZoneInfo
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 = 'rarest-zodiac-sign'
FILE_STEM = 'zodiac-sign-frequency'
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' / 'zodiac_sign_frequency.py'
else:
JSON_OUT = HERE / f'{FILE_STEM}.json'
CSV_OUT = HERE / f'{FILE_STEM}.csv'
SCRIPT_COPY = None
DATA = Path(os.environ.get('ZODIAC_DATA') or HERE / 'zodiac-sign-data')
RAW = DATA / 'raw'
UA = 'Mozilla/5.0 (research script; startarot.online)'
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius',
'Capricorn', 'Aquarius', 'Pisces']
ZODIACS = ['tropical', 'sidereal']
LAST_YEAR = 2024
MIN_BIRTHS_RANKED = 100_000
# Ties: a sign shares the rarest/commonest place if it is within the measurement margin of it in at
# least this share of BOOTSTRAP resamples of the country-years (see ``export``).
TIE_SHARE = 0.25
BOOTSTRAP = 400
J1970 = 2440587.5
# code: (name, IANA zone of the most populous part, region, hemisphere)
ENTITIES = {
'AT': ('Austria', 'Europe/Vienna', 'Western Europe', 'N'),
'BE': ('Belgium', 'Europe/Brussels', 'Western Europe', 'N'),
'CH': ('Switzerland', 'Europe/Zurich', 'Western Europe', 'N'),
'DE': ('Germany', 'Europe/Berlin', 'Western Europe', 'N'),
'FR': ('France (metropolitan)', 'Europe/Paris', 'Western Europe', 'N'),
'IE': ('Ireland', 'Europe/Dublin', 'Western Europe', 'N'),
'LI': ('Liechtenstein', 'Europe/Vaduz', 'Western Europe', 'N'),
'LU': ('Luxembourg', 'Europe/Luxembourg', 'Western Europe', 'N'),
'MC': ('Monaco', 'Europe/Monaco', 'Western Europe', 'N'),
'NL': ('Netherlands', 'Europe/Amsterdam', 'Western Europe', 'N'),
'UK': ('United Kingdom', 'Europe/London', 'Western Europe', 'N'),
'DK': ('Denmark', 'Europe/Copenhagen', 'Northern Europe', 'N'),
'EE': ('Estonia', 'Europe/Tallinn', 'Northern Europe', 'N'),
'FI': ('Finland', 'Europe/Helsinki', 'Northern Europe', 'N'),
'IS': ('Iceland', 'Atlantic/Reykjavik', 'Northern Europe', 'N'),
'LT': ('Lithuania', 'Europe/Vilnius', 'Northern Europe', 'N'),
'LV': ('Latvia', 'Europe/Riga', 'Northern Europe', 'N'),
'NO': ('Norway', 'Europe/Oslo', 'Northern Europe', 'N'),
'SE': ('Sweden', 'Europe/Stockholm', 'Northern Europe', 'N'),
'AD': ('Andorra', 'Europe/Andorra', 'Southern Europe', 'N'),
'AL': ('Albania', 'Europe/Tirane', 'Southern Europe', 'N'),
'BA': ('Bosnia and Herzegovina', 'Europe/Sarajevo', 'Southern Europe', 'N'),
'CY': ('Cyprus', 'Asia/Nicosia', 'Southern Europe', 'N'),
'EL': ('Greece', 'Europe/Athens', 'Southern Europe', 'N'),
'ES': ('Spain', 'Europe/Madrid', 'Southern Europe', 'N'),
'HR': ('Croatia', 'Europe/Zagreb', 'Southern Europe', 'N'),
'IT': ('Italy', 'Europe/Rome', 'Southern Europe', 'N'),
'ME': ('Montenegro', 'Europe/Podgorica', 'Southern Europe', 'N'),
'MK': ('North Macedonia', 'Europe/Skopje', 'Southern Europe', 'N'),
'MT': ('Malta', 'Europe/Malta', 'Southern Europe', 'N'),
'PT': ('Portugal', 'Europe/Lisbon', 'Southern Europe', 'N'),
'RS': ('Serbia', 'Europe/Belgrade', 'Southern Europe', 'N'),
'SI': ('Slovenia', 'Europe/Ljubljana', 'Southern Europe', 'N'),
'SM': ('San Marino', 'Europe/San_Marino', 'Southern Europe', 'N'),
'XK': ('Kosovo', 'Europe/Belgrade', 'Southern Europe', 'N'),
'BG': ('Bulgaria', 'Europe/Sofia', 'Eastern Europe', 'N'),
'BY': ('Belarus', 'Europe/Minsk', 'Eastern Europe', 'N'),
'CZ': ('Czechia', 'Europe/Prague', 'Eastern Europe', 'N'),
'HU': ('Hungary', 'Europe/Budapest', 'Eastern Europe', 'N'),
'MD': ('Moldova', 'Europe/Chisinau', 'Eastern Europe', 'N'),
'PL': ('Poland', 'Europe/Warsaw', 'Eastern Europe', 'N'),
'RO': ('Romania', 'Europe/Bucharest', 'Eastern Europe', 'N'),
'RU': ('Russia', 'Europe/Moscow', 'Eastern Europe', 'N'),
'SK': ('Slovakia', 'Europe/Bratislava', 'Eastern Europe', 'N'),
'AM': ('Armenia', 'Asia/Yerevan', 'Western Asia', 'N'),
'GE': ('Georgia', 'Asia/Tbilisi', 'Western Asia', 'N'),
'IL': ('Israel', 'Asia/Jerusalem', 'Western Asia', 'N'),
'TR': ('Türkiye', 'Europe/Istanbul', 'Western Asia', 'N'),
'JP': ('Japan', 'Asia/Tokyo', 'East and Southeast Asia', 'N'),
'KR': ('South Korea', 'Asia/Seoul', 'East and Southeast Asia', 'N'),
'SG': ('Singapore', 'Asia/Singapore', 'East and Southeast Asia', 'N'),
'CA': ('Canada', 'America/Toronto', 'North America', 'N'),
'GL': ('Greenland', 'America/Nuuk', 'North America', 'N'),
'US': ('United States', 'America/Chicago', 'North America', 'N'),
'BR': ('Brazil', 'America/Sao_Paulo', 'South America', 'S'),
'CL': ('Chile', 'America/Santiago', 'South America', 'S'),
'AU': ('Australia', 'Australia/Sydney', 'Oceania', 'S'),
'NZ': ('New Zealand', 'Pacific/Auckland', 'Oceania', 'S'),
}
# Eurostat geo -> entity (aggregates, DE without the GDR, FR with overseas from 1994 are not used)
EUROSTAT_GEO = {g: g for g in ENTITIES if g not in ('FR', 'DE', 'JP', 'KR', 'SG', 'CA', 'GL', 'US', 'BR', 'CL',
'AU', 'NZ', 'IL')}
EUROSTAT_GEO.update({'DE_TOT': 'DE', 'FX': 'FR'})
EUROSTAT_NOT_USED = {'UA': 'Ukraine: national monthly series by month of registration',
'AZ': 'Azerbaijan: national monthly series by month of registration'}
# STFF code -> (entity, first year, last year): only years that STFF metadata gives by date of
# OCCURRENCE (Slovakia from 2016 and Russia after 2020 are by month of registration; Poland's
# 2023–2024 by month of occurrence but year of registration, marked as to be replaced)
_EU_STFF = {'AUT': 'AT', 'BEL': 'BE', 'BGR': 'BG', 'CHE': 'CH', 'CZE': 'CZ', 'DNK': 'DK', 'ESP': 'ES', 'EST': 'EE',
'FIN': 'FI', 'GRC': 'EL', 'HRV': 'HR', 'HUN': 'HU', 'IRL': 'IE', 'ISL': 'IS', 'ITA': 'IT', 'LTU': 'LT',
'LUX': 'LU', 'LVA': 'LV', 'NLD': 'NL', 'NOR': 'NO', 'PRT': 'PT', 'ROU': 'RO', 'SRB': 'RS', 'SVN': 'SI',
'SWE': 'SE', 'DEUTNP': 'DE'}
# Last year STFF metadata (23.02.2026) calls FINAL; later years are preliminary — incomplete (late
# registrations) or by month of registration.
STFF_FINAL = {'AUT': 2024, 'BEL': 2024, 'BGR': 2024, 'CHE': 2024, 'CZE': 2024, 'DNK': 2024, 'ESP': 2023,
'EST': 2024, 'FIN': 2024, 'GRC': 2023, 'HRV': 2024, 'HUN': 2024, 'IRL': 2023, 'ISL': 2024,
'ITA': 2023, 'LTU': 2023, 'LUX': 2024, 'LVA': 2024, 'NLD': 2023, 'NOR': 2024, 'PRT': 2024,
'ROU': 2023, 'SRB': 2024, 'SVN': 2024, 'SWE': 2024, 'DEUTNP': 2024, 'AUS': 2023, 'CAN': 2023,
'CHL': 2022, 'ISR': 2023, 'JPN': 2024, 'KOR': 2023, 'NZL': 2024, 'SGP': 2024, 'USA': 2024,
'GRL': 2024, 'RUS': 2020, 'GBR_NP': 2022, 'SVK': 2015, 'POL': 2022}
STFF_CODES = {code: (ent, 2000, STFF_FINAL[code]) for code, ent in _EU_STFF.items()}
STFF_CODES.update({'AUS': ('AU', 2000, STFF_FINAL['AUS']), 'CAN': ('CA', 2000, STFF_FINAL['CAN']),
'CHL': ('CL', 2000, STFF_FINAL['CHL']), 'ISR': ('IL', 2000, STFF_FINAL['ISR']),
'JPN': ('JP', 2000, STFF_FINAL['JPN']), 'KOR': ('KR', 2000, STFF_FINAL['KOR']),
'NZL': ('NZ', 2000, STFF_FINAL['NZL']), 'SGP': ('SG', 2000, STFF_FINAL['SGP']),
'USA': ('US', 2000, STFF_FINAL['USA']), 'GRL': ('GL', 2000, STFF_FINAL['GRL']),
'RUS': ('RU', 2000, STFF_FINAL['RUS']), 'GBR_NP': ('UK', 2006, STFF_FINAL['GBR_NP']),
'SVK': ('SK', 2000, STFF_FINAL['SVK']), 'POL': ('PL', 2000, STFF_FINAL['POL'])})
# Series known to count births by month of REGISTRATION: the calibration of the weekday test below.
REGISTRATION_CONTROLS = {('Eurostat', 'UA'), ('Eurostat', 'AZ'), ('HFD STFF', 'TWN'), ('HFD STFF', 'UKR')}
# Weekday test: a registration office works Monday to Friday, so a series by month of registration
# grows with the number of working days in the month almost one for one (elasticity ≈ 1); births by
# date of occurrence barely do (scheduled births give 0.1–0.3). Series above this are not used.
MAX_WEEKDAY_ELASTICITY = 0.40
# Year-end step (log births per day) above which a country-decade is marked: four times the largest
# step of any decade of the daily series (0.05). Not used to drop data — the counts cannot tell
# births timed into January from births recorded there.
YEAR_END_STEP = 0.20
# An entity-year whose births of unknown month exceed this share of the year is not used.
MAX_UNKNOWN_MONTH = 0.02
STFF_NOT_USED = {'TWN': 'Taiwan: births by month of registration',
'UKR': 'Ukraine: births by month of registration'}
# Brazil from 1997: SINASC started in 1996 and covered about three quarters of that year's births,
# with coverage growing within the year (January 4 % below the later Januaries).
DAILY = {'FR': (1968, 2024), 'UK_EW': (1995, 2024), 'US_1969': (1969, 1988), 'US': (1994, 2014), 'BR': (1997, 2024),
'JP': (2015, 2024)}
# series of one country share a weight in the calendar template and leave the validation together
DAILY_COUNTRY = {'US_1969': 'US'}
DAILY_TZ = {'FR': 'Europe/Paris', 'UK_EW': 'Europe/London', 'US': 'America/Chicago', 'US_1969': 'America/Chicago',
'BR': 'America/Sao_Paulo', 'JP': 'Asia/Tokyo'}
DAILY_NAME = {'FR': 'metropolitan France', 'UK_EW': 'England and Wales', 'US': 'the USA', 'US_1969': 'the USA',
'BR': 'Brazil', 'JP': 'Japan'}
# daily series that ARE an entity of the tables (England and Wales is only part of the UK)
DAILY_AS_ENTITY = {'FR': 'FR', 'US_1969': 'US', 'US': 'US', 'BR': 'BR', 'JP': 'JP'}
def log(*a):
print(*a, flush=True)
# ------------------------------------------------------------------ downloads
def curl(url: str, dest: Path, tries: int = 8) -> 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 _ in range(tries):
r = subprocess.run(['curl', '-sSL', '-f', '-A', UA, '-m', '3600', '-C', '-', '-o', str(tmp), url])
if r.returncode == 0:
tmp.rename(dest)
return
time.sleep(5)
raise RuntimeError(f'download failed: {url}')
def fetch_eurostat():
gz = RAW / 'demo_fmonth.tsv.gz'
curl('https://ec.europa.eu/eurostat/api/dissemination/sdmx/2.1/data/demo_fmonth?format=TSV&compressed=true', gz)
def fetch_stff():
curl('https://www.humanfertility.org/File/GetDocumentFree/STFF/stff.csv', RAW / 'stff.csv')
def fetch_france():
curl('https://www.insee.fr/fr/statistiques/fichier/8582123/T79jnais.csv', RAW / 'fr_T79jnais.csv')
def fetch_ew():
curl('https://www.ons.gov.uk/file?uri=/peoplepopulationandcommunity/birthsdeathsandmarriages/livebirths/'
'datasets/birthsinenglandandwalesbirthregistrations/2025/2025birthregistrations.xlsx', RAW / 'ew2025.xlsx')
def fetch_us():
base = 'https://raw.githubusercontent.com/fivethirtyeight/data/master/births/'
curl(base + 'US_births_1994-2003_CDC_NCHS.csv', RAW / 'US_births_1994-2003_CDC_NCHS.csv')
curl(base + 'US_births_2000-2014_SSA.csv', RAW / 'US_births_2000-2014_SSA.csv')
curl('https://raw.githubusercontent.com/avehtari/casestudies/master/Birthdays/data/births_usa_1969.csv',
RAW / 'births_usa_1969.csv')
def fetch_br():
"""Brazil: births per local date and hour, one CSV per year (from DTNASC and HORANASC)."""
out_dir = RAW / 'br'
out_dir.mkdir(parents=True, exist_ok=True)
reuse = os.environ.get('SINASC_PARQUET')
for y in range(DAILY['BR'][0], DAILY['BR'][1] + 1):
out = out_dir / f'br_{y}.csv'
if out.exists():
continue
if reuse and (Path(reuse) / f'sinasc_{y}.parquet').exists():
df = pd.read_parquet(Path(reuse) / f'sinasc_{y}.parquet')
if 'HORANASC' not in df: # files before 2006 carry no birth time
df['HORANASC'] = pd.Series(pd.NA, index=df.index, dtype='string')
df = df[['DTNASC', 'HORANASC']]
else:
import pyreaddbc
from dbfread import DBF
dbc = out_dir / f'DNBR{y}.dbc'
dbf = out_dir / f'DNBR{y}.dbf'
curl(f'ftp://ftp.datasus.gov.br/dissemin/publicos/SINASC/1996_/Dados/DNRES/DNBR{y}.dbc', dbc)
pyreaddbc.dbc2dbf(str(dbc), str(dbf))
rows = [(r.get('DTNASC'), r.get('HORANASC')) for r in # HORANASC is absent before 2006
DBF(str(dbf), encoding='iso-8859-1', load=False, char_decode_errors='replace')]
df = pd.DataFrame(rows, columns=['DTNASC', 'HORANASC']).astype('string')
dbf.unlink()
dbc.unlink()
date = pd.to_datetime(df.DTNASC, format='%d%m%Y', errors='coerce')
hh = pd.to_numeric(df.HORANASC.str.slice(0, 2), errors='coerce')
mm = pd.to_numeric(df.HORANASC.str.slice(2, 4), errors='coerce')
hour = hh.where((hh >= 0) & (hh < 24) & (mm >= 0) & (mm < 60) & (df.HORANASC.str.len() == 4))
t = pd.DataFrame({'date': date, 'hour': hour.fillna(-1).astype(int)}).dropna(subset=['date'])
t = t[t.date.dt.year == y]
t.groupby(['date', 'hour']).size().rename('n').reset_index().to_csv(out, index=False)
log(f'BR {y}: {len(t):,} births with a valid date')
ESTAT_SID = '0003411915'
def _estat_opener():
cj = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
op.addheaders = [('User-Agent', UA)]
op.open(f'https://www.e-stat.go.jp/dbview?sid={ESTAT_SID}').read()
return op
def _estat_days(op, year, month):
"""National births per day of one month (e-Stat dbview; replicates the page's own request)."""
enc = lambda o: base64.b64encode(gzip.compress(json.dumps(o, ensure_ascii=False, separators=(',', ':')).encode())).decode()
sid = ESTAT_SID
matter = lambda mid, tbl, disp, **kw: {'matterId': mid, 'tableName': tbl, 'dispTableName': f'S9N8B_{sid}_{disp}', **kw}
item = lambda code, name='': {'name': name, 'code': code, 'explanation': ''}
tops = [matter(18, 'MH_HS9N8B_0000000001', 'HS', positionNum=1, allSelected=1, listData=[item('10040', '出生数')]),
matter(6, 'MH_BU9N8B_0001059317', 'BUN04', positionNum=2, allSelected=1, listData=[item('000000')]),
matter(2, 'MH_TMY9N8B_0001006171', 'TIME', positionNum=3, allSelected=1, listData=[item(f'{year}000000')]),
matter(5, 'MH_BU9N8B_0001059308', 'BUN03', positionNum=4, allSelected=0, listData=[item(f'{100 + 10 * month:05d}')])]
day = matter(4, 'MH_BU9N8B_0001059384', 'BUN02', positionNum=1, allSelected=1,
listData=[item('00100', '総数')] + [item(f'{100 + 10 * d:05d}', f'{d}日') for d in range(1, 32)])
hour = matter(3, 'MH_BU9N8B_0001059348', 'BUN01', positionNum=1, allSelected=1,
listData=[item('00100', '総数')] + [item(f'{110 + 10 * h:05d}', f'{h}時') for h in range(24)])
base = {'annotationFlg': 1, 'rowNoDataDispFlg': 0, 'colNoDataDispFlg': 0, 'commaType': 0, 'replaceSpChars': 0,
'graphAxis': 'horizontal', 'graphBasis': 'head', 'graphSort': 'asc', 'graphTitle': '', 'graphType': 'barChart',
'inputNumberOfCols': 100, 'inputNumberOfRows': 100, 'movementId': 0, 'leftMoveFlg': 0, 'rightMoveFlg': 0,
'underMoveFlg': 0, 'upMoveFlg': 0, 'currentCols': '', 'currentRows': '', 'mode': 'table', 'layoutName': ''}
data = dict(base, tops=enc(tops), apiTops=enc(tops), rows=enc([day]), cols=enc([hour]))
req = urllib.request.Request(f'https://www.e-stat.go.jp/dbview/api_get_result?sid={sid}', data=urllib.parse.urlencode(data).encode(),
headers={'X-Requested-With': 'XMLHttpRequest', 'Referer': f'https://www.e-stat.go.jp/dbview?sid={sid}',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'})
res = json.loads(op.open(req).read())
if res.get('error'):
raise RuntimeError(res.get('message'))
out = []
for tr in re.findall(r'
(.*?)
', res['table'], re.S):
cells = [html.unescape(re.sub(r'<[^>]+>', '', c)).strip() for c in re.findall(r']*>(.*?)', tr, re.S)]
if len(cells) >= 26 and '総数' not in cells[-26]:
v = cells[-25]
out.append((int(cells[-26].replace('日', '')), int(v.replace(',', '')) if v not in ('-', '…', '') else 0))
return out
def fetch_jp():
"""Japan, births per day: one request per month (e-Stat answers an occasional request with a
server error — retried with a fresh session); finished years are cached, so a rerun resumes."""
dest = RAW / 'jp_daily.csv'
if dest.exists():
return
part_dir = RAW / 'jp'
part_dir.mkdir(parents=True, exist_ok=True)
op = None
for y in range(DAILY['JP'][0], DAILY['JP'][1] + 1):
part = part_dir / f'jp_{y}.csv'
if part.exists():
continue
rows = []
for m in range(1, 13):
for attempt in range(6):
try:
op = op or _estat_opener()
days = _estat_days(op, y, m)
break
except Exception as exc: # noqa: BLE001 — any failure of the remote view: retry
if attempt == 5:
raise
log(f'JP {y}-{m:02d}: {exc}; retrying')
op = None
time.sleep(10 * (attempt + 1))
rows += [(f'{y}-{m:02d}-{d:02d}', n) for d, n in days if n]
time.sleep(0.7)
pd.DataFrame(rows, columns=['date', 'n']).to_csv(part, index=False)
log(f'JP {y} fetched')
pd.concat([pd.read_csv(f) for f in sorted(part_dir.glob('jp_*.csv'))]).to_csv(dest, index=False)
# ------------------------------------------------------------------ readers
def read_eurostat(raw: bool = False) -> pd.DataFrame:
"""Monthly births. ``raw``: every geo, every value, with month 'UNK' and 'TOTAL' and the flag.
Otherwise used geos only, months 1–12, provisional values ('p') of the last two years dropped."""
t = pd.read_csv(RAW / 'demo_fmonth.tsv.gz', sep='\t', dtype=str)
key = t.columns[0]
parts = t[key].str.split(',', expand=True)
parts.columns = ['freq', 'unit', 'month', 'geo']
t = pd.concat([parts, t.drop(columns=key)], axis=1)
t = t.melt(id_vars=['freq', 'unit', 'month', 'geo'], var_name='year', value_name='v')
t['year'] = t.year.str.strip().astype(int)
t['flag'] = t.v.str.extract(r'([a-z]+)\s*$')[0].fillna('')
t['v'] = pd.to_numeric(t.v.str.replace(r'[^0-9.]', '', regex=True), errors='coerce')
t = t[t.v.notna()].rename(columns={'v': 'births'})
if raw:
return t[['geo', 'year', 'month', 'births', 'flag']]
t = t[t.month.str.match(r'^M\d\d$') & t.geo.isin(EUROSTAT_GEO)]
# 'p' (provisional) matters only in a series' last two years: older years keep a flag nobody
# cleared (Denmark 1996–1997, Czechia 1996) and have no other source
last = t.groupby('geo').year.transform('max')
t = t[~(t.flag.str.contains('p') & (t.year >= last - 1))]
t['month'] = t.month.str[1:].astype(int)
t['entity'] = t.geo.map(EUROSTAT_GEO)
return t[['entity', 'year', 'month', 'births']]
def unknown_month_share() -> pd.DataFrame:
"""entity, year, source, share of the year's births with unknown month."""
e = read_eurostat(raw=True)
e = e[e.geo.isin(EUROSTAT_GEO)]
w = e.pivot_table(index=['geo', 'year'], columns='month', values='births', aggfunc='sum')
known = w[[f'M{m:02d}' for m in range(1, 13)]].sum(axis=1, min_count=12)
unk = w['UNK'].fillna(0) if 'UNK' in w else 0
out = pd.DataFrame({'unknown': unk, 'known': known}).dropna().reset_index()
out = out.assign(entity=out.geo.map(EUROSTAT_GEO), source='Eurostat')
s = pd.read_csv(RAW / 'stff.csv', na_values='.')
rows = []
for code, (ent, y0, y1) in STFF_CODES.items():
for _, r in s[(s.CountryCode == code) & s.Year.between(y0, y1)].iterrows():
known = float(sum(r[m] for m in ['January', 'February', 'March', 'April', 'May', 'June', 'July',
'August', 'September', 'October', 'November', 'December']))
total = float(r.TOT) if pd.notna(r.TOT) else known
# unknown = what the twelve months miss of the annual total (UNK is often left empty)
rows.append((ent, int(r.Year), 'HFD STFF', max(total - known, 0.0), known))
st = pd.DataFrame(rows, columns=['entity', 'year', 'source', 'unknown', 'known'])
out = pd.concat([out[['entity', 'year', 'source', 'unknown', 'known']], st[['entity', 'year', 'source', 'unknown', 'known']]])
out['share'] = out.unknown / (out.unknown + out.known)
return out
def weekday_elasticity(g: pd.DataFrame) -> tuple[float, float, int]:
"""How monthly counts follow the number of Monday–Friday days in the month: slope of log births
per day on log(weekday share of the month), with year and calendar-month effects removed.
≈ 1 for counts by registration (offices closed at weekends), 0.1–0.3 for births by date.
Outlier years (``suspect_years``) are left out first: Belarus with its January 2005 at a third
of normal scores 0.67, without that year 0.34."""
g = g[g.births > 0].copy()
full = g.groupby('year').month.nunique()
g = g[g.year.isin(full[full == 12].index)]
if g.year.nunique() >= 3:
bad = suspect_years(g.assign(entity='x', source='x'))
g = g[~g.year.isin(bad.year)]
if g.year.nunique() < 3:
return float('nan'), float('nan'), int(g.year.nunique())
first = pd.to_datetime(dict(year=g.year, month=g.month, day=1))
dim = first.dt.days_in_month.values
wdays = np.array([np.sum(pd.date_range(f, periods=n).weekday < 5) for f, n in zip(first, dim)])
y = np.log(g.births.values / dim)
x = np.log(wdays / dim)
yr, mo = g.year.values, g.month.values
def demean(v):
v = v.copy()
for _ in range(20):
v -= pd.Series(v).groupby(yr).transform('mean').values
v -= pd.Series(v).groupby(mo).transform('mean').values
return v
yd, xd = demean(y), demean(x)
beta = float((xd * yd).sum() / (xd * xd).sum())
resid = yd - beta * xd
dof = max(len(y) - g.year.nunique() - 12, 1)
se = float(np.sqrt((resid ** 2).sum() / dof / (xd * xd).sum()))
return beta, se, int(g.year.nunique())
def registration_screen(daily: dict[str, pd.Series]) -> pd.DataFrame:
"""Weekday elasticity of every monthly series (used and not), of the known registration-month
series and of the daily series summed to months (known occurrence)."""
rows = []
e = read_eurostat(raw=True)
e = e[e.month.str.match(r'^M\d\d$') & (e.geo.isin(EUROSTAT_GEO) | e.geo.isin(EUROSTAT_NOT_USED))]
e = e.assign(month=e.month.str[1:].astype(int))
for geo, g in e.groupby('geo'):
b, se, n = weekday_elasticity(g)
kind = 'registration (known)' if ('Eurostat', geo) in REGISTRATION_CONTROLS else 'monthly'
rows.append(('Eurostat', geo, EUROSTAT_GEO.get(geo), kind, b, se, n))
st = pd.read_csv(RAW / 'stff.csv', na_values='.')
months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September',
'October', 'November', 'December']
def stff_rows(code, y0, y1):
out = []
for _, r in st[(st.CountryCode == code) & st.Year.between(y0, y1)].iterrows():
out += [(int(r.Year), i, r[m]) for i, m in enumerate(months, start=1)]
return pd.DataFrame(out, columns=['year', 'month', 'births']).dropna()
for code, (ent, y0, y1) in STFF_CODES.items():
b, se, n = weekday_elasticity(stff_rows(code, y0, y1))
rows.append(('HFD STFF', code, ent, 'monthly', b, se, n))
for code in STFF_NOT_USED:
b, se, n = weekday_elasticity(stff_rows(code, 2000, LAST_YEAR))
rows.append(('HFD STFF', code, None, 'registration (known)', b, se, n))
b, se, n = weekday_elasticity(stff_rows('RUS', 2021, LAST_YEAR))
rows.append(('HFD STFF', 'RUS 2021+', None, 'registration (known)', b, se, n))
for key, s in daily.items():
m = s.resample('MS').sum()
g = pd.DataFrame({'year': m.index.year, 'month': m.index.month, 'births': m.values})
b, se, n = weekday_elasticity(g)
rows.append((DAILY_SOURCE[key], key, DAILY_AS_ENTITY.get(key), 'occurrence (daily)', b, se, n))
return pd.DataFrame(rows, columns=['source', 'series', 'entity', 'kind', 'elasticity', 'se', 'years'])
def read_stff() -> pd.DataFrame:
s = pd.read_csv(RAW / 'stff.csv', na_values='.')
months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September',
'October', 'November', 'December']
rows = []
for code, (ent, y0, y1) in STFF_CODES.items():
for _, r in s[(s.CountryCode == code) & s.Year.between(y0, y1)].iterrows():
for i, m in enumerate(months, start=1):
rows.append((ent, int(r.Year), i, r[m]))
return pd.DataFrame(rows, columns=['entity', 'year', 'month', 'births']).dropna()
def read_daily(key: str) -> pd.Series:
if key == 'FR':
f = pd.read_csv(RAW / 'fr_T79jnais.csv', sep=';', dtype=str)
f = f[(f.CHAMP2 == 'FM') & (f.JOUR != 'MM') & (f.NBNAIS != 'so')] # 'so' = no such date
idx = pd.to_datetime(f.ANNEE_1968_2024 + '-' + f.MOIS + '-' + f.JOUR, errors='coerce')
s = pd.Series(f.NBNAIS.astype(int).values, index=idx)
elif key == 'UK_EW':
import openpyxl
ws = openpyxl.load_workbook(RAW / 'ew2025.xlsx', read_only=True)['Table_12']
rows = list(ws.iter_rows(values_only=True))
h = [i for i, r in enumerate(rows) if r[0] == 'Date'][0]
hdr, out = rows[h], {}
for r in rows[h + 1:]:
if not r[0] or '/' not in str(r[0]):
continue
dd, mm = map(int, str(r[0]).split('/'))
for c, yr in enumerate(hdr[2:], start=2):
if isinstance(r[c], (int, float)):
try:
out[pd.Timestamp(year=int(yr), month=mm, day=dd)] = int(r[c])
except ValueError:
pass
s = pd.Series(out)
elif key == 'US':
a = pd.read_csv(RAW / 'US_births_1994-2003_CDC_NCHS.csv')
b = pd.read_csv(RAW / 'US_births_2000-2014_SSA.csv')
t = pd.concat([a[a.year < 2004], b[b.year >= 2004]])
s = pd.Series(t.births.values, index=pd.to_datetime(dict(year=t.year, month=t.month, day=t.date_of_month)))
elif key == 'US_1969':
t = pd.read_csv(RAW / 'births_usa_1969.csv')
s = pd.Series(t.births.values, index=pd.to_datetime(dict(year=t.year, month=t.month, day=t.day)))
elif key == 'BR':
t = pd.concat([pd.read_csv(f, parse_dates=['date']) for f in sorted((RAW / 'br').glob('br_*.csv'))])
s = t.groupby('date').n.sum()
elif key == 'JP':
t = pd.read_csv(RAW / 'jp_daily.csv', parse_dates=['date'])
s = pd.Series(t.n.values, index=t.date)
else:
raise KeyError(key)
s = s[~s.index.isna()].sort_index()
s = s[~s.index.duplicated()]
y0, y1 = DAILY[key]
s = s[(s.index.year >= y0) & (s.index.year <= y1)].astype(float)
full = pd.date_range(s.index[0], s.index[-1])
if len(full) != len(s):
raise RuntimeError(f'{key}: {len(full) - len(s)} missing days')
return s
def br_hour_profile() -> pd.DataFrame:
"""Brazil 2006–2024: births per date × local hour (hour −1 = no valid time)."""
t = pd.concat([pd.read_csv(f, parse_dates=['date']) for f in sorted((RAW / 'br').glob('br_*.csv'))])
return t[t.date.dt.year >= 2006]
# ------------------------------------------------------------------ astronomy
_swe = None
def swe():
global _swe
if _swe is None:
import swisseph
swisseph.set_sid_mode(swisseph.SIDM_LAHIRI)
_swe = swisseph
return _swe
_ING = {}
def ingresses(sidereal: bool):
"""(jd_ut array, sign entered array) for 1958–2027."""
if sidereal in _ING:
return _ING[sidereal]
s = swe()
flag = s.FLG_MOSEPH | (s.FLG_SIDEREAL if sidereal else 0)
jd = s.julday(1958, 1, 1)
end = s.julday(2027, 1, 1)
k = int(s.calc_ut(jd, s.SUN, flag)[0][0] // 30)
jds, ks = [], []
while True:
k = (k + 1) % 12
jd = s.solcross_ut(30.0 * k, jd, flag)
if jd > end:
break
jds.append(jd)
ks.append(k)
jd += 1
_ING[sidereal] = (np.array(jds), np.array(ks))
return _ING[sidereal]
def day_bounds_jd(dates: pd.DatetimeIndex, tz: str) -> tuple[np.ndarray, np.ndarray]:
z = ZoneInfo(tz)
days = list(dates) + [dates[-1] + pd.Timedelta(days=1)]
jd = np.array([J1970 + dt.datetime(d.year, d.month, d.day, tzinfo=z).timestamp() / 86400 for d in days])
return jd[:-1], jd[1:]
def sign_fractions(dates: pd.DatetimeIndex, tz: str, sidereal: bool, shift_hours: float = 0.0,
hour_weights: np.ndarray | None = None) -> np.ndarray:
"""[n_days, 12] share of each local civil day's births with the Sun in each sign.
Births are uniform over the real length of the day unless ``hour_weights`` [n_days, 24] (local
clock hours) are given. ``shift_hours`` moves the zone (sensitivity test)."""
ing_jd, ing_k = ingresses(sidereal)
start, end = day_bounds_jd(dates, tz)
start = start - shift_hours / 24
end = end - shift_hours / 24
i0 = np.searchsorted(ing_jd, start, side='right') - 1
sign0 = ing_k[i0]
F = np.zeros((len(dates), 12))
F[np.arange(len(dates)), sign0] = 1.0
j = i0 + 1
has = ing_jd[j] < end
for i in np.where(has)[0]:
f = (ing_jd[j[i]] - start[i]) / (end[i] - start[i])
if hour_weights is not None and hour_weights[i].sum() > 0:
w = hour_weights[i] / hour_weights[i].sum()
x = f * 24
h = int(x)
f = w[:h].sum() + (w[h] * (x - h) if h < 24 else 0)
F[i, sign0[i]] = f
F[i, ing_k[j[i]]] += 1 - f
return F
# ------------------------------------------------------------------ monthly -> daily
def smooth_daily(mon: pd.Series) -> pd.Series:
"""Mean-preserving daily curve: monotone cubic (PCHIP) through cumulative monthly totals,
per contiguous run of months."""
from scipy.interpolate import PchipInterpolator
mon = mon.sort_index()
runs, cur = [], [mon.index[0]]
for a, b in zip(mon.index[:-1], mon.index[1:]):
if (b.year - a.year) * 12 + b.month - a.month == 1:
cur.append(b)
else:
runs.append(cur)
cur = [b]
runs.append(cur)
out = []
for run in runs:
m = mon.loc[run]
edges = list(m.index) + [m.index[-1] + pd.offsets.MonthBegin(1)]
x = np.array([(e - edges[0]).days for e in edges], float)
c = np.concatenate([[0], np.cumsum(m.values)])
idx = pd.date_range(edges[0], edges[-1] - pd.Timedelta(days=1))
if len(run) == 1:
out.append(pd.Series(m.values[0] / len(idx), index=idx))
continue
p = PchipInterpolator(x, c)
out.append(pd.Series(np.diff(p(np.arange(len(idx) + 1, dtype=float))), index=idx))
return pd.concat(out)
def calendar_template(daily: dict[str, pd.Series]) -> tuple[pd.Series, pd.Series]:
"""Calendar-day (MM-DD) and weekday log-effects relative to the smooth curve, equal weight per
daily series, fitted jointly by alternating means."""
by_country = {}
for key, s in daily.items():
base = smooth_daily(s.resample('MS').sum()).reindex(s.index)
by_country.setdefault(DAILY_COUNTRY.get(key, key), []).append(np.log(s / base))
parts = []
for rs in by_country.values(): # equal weight per country: each contributes its mean over its years
r = pd.concat(rs)
parts.append((r, r.index.strftime('%m-%d'), r.index.weekday))
h = pd.Series(0.0, index=sorted({m for _, md, _ in parts for m in md}))
w = pd.Series(0.0, index=range(7))
for _ in range(10):
h = pd.concat([(r - w.reindex(wd).values).groupby(md).mean() for r, md, wd in parts], axis=1).mean(axis=1)
w = pd.concat([(r - h.reindex(md).values).groupby(wd).mean() for r, md, wd in parts], axis=1).mean(axis=1)
return h, w
def monthly_to_daily(mon: pd.Series, h: pd.Series, w: pd.Series) -> pd.Series:
base = smooth_daily(mon)
idx = base.index
est = base * np.exp(h.reindex(idx.strftime('%m-%d')).fillna(0).values + w.reindex(idx.weekday).values)
per = idx.to_period('M')
est = est / est.groupby(per).transform('sum').values * mon.reindex(per.to_timestamp()).values
return est
def sign_table(daily: pd.Series, tz: str, **kw) -> pd.DataFrame:
"""Births per (year, zodiac, sign) from a daily series."""
out = []
for z in ZODIACS:
F = sign_fractions(daily.index, tz, z == 'sidereal', **kw)
t = pd.DataFrame(F * daily.values[:, None], columns=SIGNS)
t['year'] = daily.index.year
g = t.groupby('year').sum()
out.append(g.stack().rename('births').reset_index().rename(columns={'level_1': 'sign'}).assign(zodiac=z))
return pd.concat(out)
def day_share_table(years: list[int], tz: str) -> pd.DataFrame:
"""Share of each calendar year spent with the Sun in each sign (the no-seasonality expectation)."""
idx = pd.date_range(f'{min(years)}-01-01', f'{max(years)}-12-31')
idx = idx[idx.year.isin(years)]
return sign_table(pd.Series(1.0, index=idx), tz).rename(columns={'births': 'days'})
# ------------------------------------------------------------------ compute
def load_all_daily() -> dict[str, pd.Series]:
return {k: read_daily(k) for k in DAILY}
def monthly_sources(screen: pd.DataFrame | None = None) -> pd.DataFrame:
"""entity, year, month, births, source — one source per entity-year (daily > STFF > Eurostat),
complete years only, after the data-basis screens (weekday test, unknown months, outlier
years); what each screen removed is written to $ZODIAC_DATA/excluded_years.csv."""
e = read_eurostat().assign(source='Eurostat')
s = read_stff().assign(source='HFD STFF')
removed = []
# For a country STFF covers, Eurostat years after STFF's last FINAL year are the same
# preliminary national figures STFF leaves out (Slovakia 2016–2024 by month of registration,
# Poland 2023–2024, most 2024 values): not used either.
stff_last = {}
for code, (ent, y0, y1) in STFF_CODES.items():
stff_last[ent] = max(stff_last.get(ent, 0), y1)
late = e.entity.map(stff_last).notna() & (e.year > e.entity.map(stff_last))
for (ent, y), g in e[late & (e.year <= LAST_YEAR)].groupby(['entity', 'year']):
if g.month.nunique() == 12:
removed.append((ent, int(y), 'Eurostat', 'after the last final HFD year', float(stff_last[ent]),
int(g.births.sum())))
e = e[~late]
t = pd.concat([e, s])
t = t[t.year <= LAST_YEAR]
# 1. series that behave like counts by month of registration
if screen is not None:
bad = screen[(screen.kind == 'monthly') & (screen.elasticity > MAX_WEEKDAY_ELASTICITY) & screen.entity.notna()]
for _, r in bad.iterrows():
hit = (t.source == r.source) & (t.entity == r.entity)
for y in sorted(t[hit].year.unique()):
removed.append((r.entity, int(y), r.source, 'weekday test', round(float(r.elasticity), 2),
int(t[hit & (t.year == y)].births.sum())))
t = t[~hit]
# 2. years with many births of unknown month
unk = unknown_month_share()
unk = unk[unk.share > MAX_UNKNOWN_MONTH]
for _, r in unk.iterrows():
hit = (t.source == r.source) & (t.entity == r.entity) & (t.year == r.year)
if hit.any():
removed.append((r.entity, int(r.year), r.source, 'unknown month', round(float(r.share) * 100, 1),
int(t[hit].births.sum())))
t = t[~hit]
full = t.groupby(['entity', 'year', 'source']).month.nunique()
full = full[full == 12].reset_index()[['entity', 'year', 'source']]
t = t.merge(full)
# HFD STFF first: its curators check the date basis country by country; Eurostat's monthly table
# carries a few registration-month years (e.g. Germany and Ireland 2002, Slovakia 2008)
rank = {'HFD STFF': 0, 'Eurostat': 1}
best = full.assign(r=full.source.map(rank)).sort_values('r').drop_duplicates(['entity', 'year'])
t = t.merge(best[['entity', 'year', 'source']])
daily_years = {(DAILY_AS_ENTITY[k], y) for k, (y0, y1) in DAILY.items() if k in DAILY_AS_ENTITY
for y in range(y0, y1 + 1)}
# Brazil 1996 (partial first year of SINASC) is not replaced by anything either
daily_years |= {('BR', 1996)}
keep = [(a, b) not in daily_years for a, b in zip(t.entity, t.year)]
t = t[keep]
# 3. outlier years
out = suspect_years(t)
for _, r in out.iterrows():
removed.append((r.entity, int(r.year), r.source, f'outlier month {int(r.month)}', float(r.deviation_pct), int(r.births)))
pd.DataFrame(removed, columns=['entity', 'year', 'source', 'reason', 'value', 'births']).to_csv(
DATA / 'excluded_years.csv', index=False)
drop = set(zip(out.entity, out.year))
return t[[(a, b) not in drop for a, b in zip(t.entity, t.year)]]
def suspect_years(t: pd.DataFrame, rel: float = 0.20, z: float = 5.0) -> pd.DataFrame:
"""Entity-years whose monthly profile breaks from the neighbouring years (±3) in a way no real
birth cohort does: some month more than ``rel`` off the neighbours' median profile (per day) and
more than ``z`` Poisson sigmas. Catches registration-month and keying errors (Belarus January
2005 at a third of normal, Poland December 1995); real shocks such as the COVID-19 dip in Spain,
December 2020 – January 2021 (−17 %), stay below it."""
t = t.assign(dim=pd.to_datetime(dict(year=t.year, month=t.month, day=1)).dt.days_in_month)
out = []
for ent, g in t.groupby('entity'):
P = g.pivot_table(index='year', columns='month', values='births')
D = g.pivot_table(index='year', columns='month', values='dim')
r = (P / D).div((P / D).sum(axis=1), axis=0)
for y in P.index:
nb = [k for k in P.index if k != y and abs(k - y) <= 3]
if len(nb) < 2: # at the edge of a series or after a gap: the four nearest years
nb = sorted((k for k in P.index if k != y), key=lambda k: abs(k - y))[:4]
if len(nb) < 2:
continue
exp = r.loc[nb].median() * D.loc[y]
exp = exp / exp.sum() * P.loc[y].sum()
dev = (P.loc[y] - exp) / exp
zz = (P.loc[y] - exp) / np.sqrt(exp)
hit = (dev.abs() > rel) & (zz.abs() > z)
if hit.any():
m = int(dev.abs()[hit].idxmax())
out.append((ent, int(y), g.source.iloc[0], m, round(float(dev[m]) * 100, 1), int(P.loc[y].sum())))
return pd.DataFrame(out, columns=['entity', 'year', 'source', 'month', 'deviation_pct', 'births'])
def compute():
daily = load_all_daily()
screen = registration_screen(daily)
screen.to_csv(DATA / 'registration_screen.csv', index=False)
h, w = calendar_template(daily)
pd.DataFrame({'log_effect': h}).to_csv(DATA / 'template_calendar_day.csv')
pd.DataFrame({'log_effect': w}).to_csv(DATA / 'template_weekday.csv')
rows = []
for key, ent in DAILY_AS_ENTITY.items():
t = sign_table(daily[key], ENTITIES[ent][1])
rows.append(t.assign(entity=ent, method='daily', source=DAILY_SOURCE[key]))
mon = monthly_sources(screen)
for (ent, src), g in mon.groupby(['entity', 'source']):
m = g.assign(d=pd.to_datetime(dict(year=g.year, month=g.month, day=1))).set_index('d').births.sort_index()
est = monthly_to_daily(m, h, w)
rows.append(sign_table(est, ENTITIES[ent][1]).assign(entity=ent, method='monthly', source=src))
log(f'{ent} {src}: {m.index.year.min()}–{m.index.year.max()}, {int(m.sum()):,} births')
counts = pd.concat(rows, ignore_index=True)
# Year-end step: the jump in births per day from December to January, minus the mean of the
# neighbouring steps (November to December, January to February). A smooth season gives about
# zero; births moved, or dates recorded, from December into January give a step.
jd = []
def year_end_steps(m: pd.Series):
r = np.log(m / m.index.days_in_month)
for y in sorted(set(m.index.year)):
keys = [pd.Timestamp(a, b, 1) for a, b in ((y, 11), (y, 12), (y + 1, 1), (y + 1, 2))]
if all(k in r.index for k in keys):
nov, dec, jan, feb = (r[k] for k in keys)
yield int(y), float((jan - dec) - ((dec - nov) + (feb - jan)) / 2)
for key, s in daily.items():
for y, v in year_end_steps(s.resample('MS').sum()):
jd.append((key, DAILY_AS_ENTITY.get(key), 'daily', y, v))
for ent, g in mon.groupby('entity'):
m = g.assign(d=pd.to_datetime(dict(year=g.year, month=g.month, day=1))).set_index('d').births.sort_index()
for y, v in year_end_steps(m):
jd.append((ent, ent, 'monthly', y, v))
pd.DataFrame(jd, columns=['series', 'entity', 'method', 'year', 'step']).to_csv(
DATA / 'year_end_step.csv', index=False)
# disagreement between the two monthly sources on the years both have
h_w = (h, w)
e_all = read_eurostat().assign(source='Eurostat')
s_all = read_stff().assign(source='HFD STFF')
both = e_all.merge(s_all, on=['entity', 'year', 'month'], suffixes=('_e', '_s'))
full = both.groupby(['entity', 'year']).month.nunique()
both = both.merge(full[full == 12].reset_index()[['entity', 'year']])
sdiff = {}
for ent, g in both.groupby('entity'):
g = g.assign(d=pd.to_datetime(dict(year=g.year, month=g.month, day=1))).set_index('d').sort_index()
res = {}
for z in ZODIACS:
a = sign_table(monthly_to_daily(g.births_e, *h_w), ENTITIES[ent][1])
b = sign_table(monthly_to_daily(g.births_s, *h_w), ENTITIES[ent][1])
sa = shares(a[a.zodiac == z], [])
sb = shares(b[b.zodiac == z], [])
res[z] = round(float((sa - sb).abs().values.max()), 3)
sdiff[ent] = {'years': [int(g.year.min()), int(g.year.max())], 'n_years': int(g.year.nunique()), **res}
(DATA / 'source_diff.json').write_text(json.dumps(sdiff, indent=1))
# expectation without seasonality: the same entity-years, by days
exp = []
for ent, g in counts.groupby('entity'):
ys = sorted(g.year.unique())
exp.append(day_share_table(ys, ENTITIES[ent][1]).assign(entity=ent))
counts = counts.merge(pd.concat(exp), on=['entity', 'year', 'zodiac', 'sign'])
counts.to_parquet(DATA / 'counts.parquet')
log(f'counts: {counts.entity.nunique()} entities, {counts[counts.zodiac == "tropical"].births.sum():,.0f} births')
DAILY_SOURCE = {'FR': 'INSEE (daily)', 'UK_EW': 'ONS (daily)', 'US': 'CDC/SSA via FiveThirtyEight (daily)',
'US_1969': 'NCHS via Google BigQuery (daily)',
'BR': 'SINASC (daily)', 'JP': 'e-Stat (daily)'}
# ------------------------------------------------------------------ validate
def shares(t: pd.DataFrame, by: list[str]) -> pd.DataFrame:
if not by:
t, by = t.assign(_all=0), ['_all']
g = t.groupby(by + ['sign']).births.sum().unstack('sign')[SIGNS]
return g.div(g.sum(axis=1), axis=0) * 100
def validate():
daily = load_all_daily()
res = {'leave_one_out': {}, 'timezone_shift': {}, 'within_day': {}, 'overlap': {}}
for key, s in daily.items():
others = {k: v for k, v in daily.items() if DAILY_COUNTRY.get(k, k) != DAILY_COUNTRY.get(key, key)}
h, w = calendar_template(others)
tz = DAILY_TZ[key]
exact = sign_table(s, tz)
mon = s.resample('MS').sum()
models = {'uniform_within_month': pd.Series(mon.reindex(s.index.to_period('M').to_timestamp()).values
/ s.index.days_in_month, index=s.index),
'smooth': smooth_daily(mon).reindex(s.index),
'smooth_with_template': monthly_to_daily(mon, h, w).reindex(s.index)}
out = {}
for name, est in models.items():
et = sign_table(est, tz)
r = {}
for z in ZODIACS:
ex, es = exact[exact.zodiac == z], et[et.zodiac == z]
for per, f in (('year', lambda y: y), ('decade', lambda y: y // 10 * 10), ('all', lambda y: 0)):
a = shares(ex.assign(p=ex.year.map(f)), ['p'])
b = shares(es.assign(p=es.year.map(f)), ['p'])
if per == 'decade': # complete decades only
n = ex.assign(p=ex.year.map(f)).groupby('p').year.nunique()
ok = n[n == 10].index
a, b = a.loc[ok], b.loc[ok]
e = (b - a).abs()
r[f'{z}_{per}'] = {'max_pp': round(float(e.values.max()), 3), 'mean_pp': round(float(e.values.mean()), 3)} if len(e) else None
out[name] = r
res['leave_one_out'][key] = out
log(key, json.dumps(out['smooth_with_template']))
# time-zone sensitivity: ±3 h
sh = {}
for d in (-3, 3):
t = sign_table(s, tz, shift_hours=d)
e = (shares(t[t.zodiac == 'tropical'], []) - shares(exact[exact.zodiac == 'tropical'], [])).abs()
sh[f'{d:+d}h'] = round(float(e.values.max()), 3)
res['timezone_shift'][key] = sh
# within-day split: Brazil 2006–2024 at recorded hours vs uniform
hp = br_hour_profile()
hp = hp[hp.hour >= 0].pivot_table(index='date', columns='hour', values='n', aggfunc='sum').fillna(0)
hp = hp.reindex(columns=range(24), fill_value=0)
s = daily['BR']
s = s[s.index.year >= 2006]
W = hp.reindex(s.index).fillna(0).values
uni = sign_table(s, DAILY_TZ['BR'])
rec = []
for z in ZODIACS:
F = sign_fractions(s.index, DAILY_TZ['BR'], z == 'sidereal', hour_weights=W)
t = pd.DataFrame(F * s.values[:, None], columns=SIGNS).assign(year=s.index.year).groupby('year').sum()
rec.append(t.stack().rename('births').reset_index().rename(columns={'level_1': 'sign'}).assign(zodiac=z))
rec = pd.concat(rec)
for z in ZODIACS:
a = shares(uni[uni.zodiac == z], ['year'])
b = shares(rec[rec.zodiac == z], ['year'])
res['within_day'][z] = {'max_pp_year': round(float((a - b).abs().values.max()), 3),
'max_pp_all': round(float((shares(uni[uni.zodiac == z], []) - shares(rec[rec.zodiac == z], [])).abs().values.max()), 3)}
# Eurostat vs STFF where both have the same entity-year
e = read_eurostat()
st = read_stff()
both = e.merge(st, on=['entity', 'year', 'month'], suffixes=('_e', '_s'))
full = both.groupby(['entity', 'year']).month.nunique()
both = both.merge(full[full == 12].reset_index()[['entity', 'year']])
for ent, g in both.groupby('entity'):
tot = g.groupby('year')[['births_e', 'births_s']].sum()
se = g.pivot_table(index='year', columns='month', values='births_e')
ss = g.pivot_table(index='year', columns='month', values='births_s')
res['overlap'][ent] = {'years': [int(tot.index.min()), int(tot.index.max())],
'max_total_diff_pct': round(float(((tot.births_s / tot.births_e - 1).abs() * 100).max()), 2),
'max_month_share_diff_pp': round(float(((ss.div(ss.sum(1), axis=0) - se.div(se.sum(1), axis=0)).abs() * 100).values.max()), 3)}
(DATA / 'validation.json').write_text(json.dumps(res, indent=1))
log(json.dumps(res['timezone_shift']), json.dumps(res['within_day']), json.dumps(res['overlap']))
# ------------------------------------------------------------------ export
def r3(x: float) -> float:
return round(float(x), 3)
def block(t: pd.DataFrame, u: float, ties: tuple[list, list] | None = None) -> dict:
"""Shares of one zodiac for a set of entity-years. Expected = each entity-year's births spread
over its days (no seasonality), so the sign lengths of exactly those years and places."""
t = t.assign(total=t.groupby(['entity', 'year']).births.transform('sum'),
dtot=t.groupby(['entity', 'year']).days.transform('sum'))
t = t.assign(exp=t.total * t.days / t.dtot)
g = t.groupby('sign')[['births', 'exp']].sum().reindex(SIGNS)
share = g.births / g.births.sum() * 100
exp = g.exp / g.exp.sum() * 100
lo, hi = share.min(), share.max()
return {
'births': [int(round(v)) for v in g.births],
'shares': [r3(v) for v in share],
'expected': [r3(v) for v in exp],
'index': [round(float(v), 1) for v in share / exp * 100],
'rarest': ties[0] if ties else [s for s in SIGNS if share[s] - lo <= u],
'commonest': ties[1] if ties else [s for s in SIGNS if hi - share[s] <= u],
'rarest_sign': share.idxmin(),
'commonest_sign': share.idxmax(),
'ratio': round(float(hi / lo), 3),
}
def export():
counts = pd.read_parquet(DATA / 'counts.parquet')
val = json.loads((DATA / 'validation.json').read_text())
excluded = pd.read_csv(DATA / 'excluded_years.csv')
screen = pd.read_csv(DATA / 'registration_screen.csv')
steps = pd.read_csv(DATA / 'year_end_step.csv')
steps['decade'] = steps.year // 10 * 10
sdec = steps.groupby(['series', 'entity', 'method', 'decade'], dropna=False).step.agg(['mean', 'size']).reset_index()
sdec = sdec.rename(columns={'mean': 'step', 'size': 'n'})
step_control = float(sdec[(sdec.method == 'daily') & (sdec.n >= 5)].step.abs().max())
step_flags = {}
for _, r in sdec[(sdec.method == 'monthly') & (sdec.step > YEAR_END_STEP) & (sdec.n >= 3)].iterrows():
step_flags.setdefault(r.entity, []).append({'decade': int(r.decade), 'step': round(float(r.step), 3),
'ratio': round(float(np.exp(r.step)), 2)})
loo = val['leave_one_out']
# measured error of the monthly route (leave-one-country-out), and of the daily route (the split
# of an ingress day at uniform time, measured on Brazil's recorded hours)
um = {z: {p: max(v['smooth_with_template'][f'{z}_{p}']['max_pp'] for v in loo.values()
if v['smooth_with_template'][f'{z}_{p}']) for p in ('year', 'decade', 'all')} for z in ZODIACS}
ud = {z: {'year': val['within_day'][z]['max_pp_year'], 'decade': val['within_day'][z]['max_pp_year'],
'all': val['within_day'][z]['max_pp_all']} for z in ZODIACS}
sdiff = json.loads((DATA / 'source_diff.json').read_text())
rng = np.random.default_rng(20261017)
def margin(t: pd.DataFrame, z: str, period: str) -> float:
"""Measurement margin: the measured error of the route (monthly or daily, weighted by
births) + the disagreement between Eurostat and HFD for the countries in the block
(weighted by their monthly births)."""
tz = t[t.zodiac == z]
fm = tz[tz.method == 'monthly'].births.sum() / tz.births.sum()
mb = tz[tz.method == 'monthly'].groupby('entity').births.sum()
src = float(sum(mb[e] * sdiff.get(e, {}).get(z, 0.0) for e in mb.index) / tz.births.sum()) if len(mb) else 0.0
return round(fm * um[z][period] + (1 - fm) * ud[z][period] + src, 3)
def ties(tz: pd.DataFrame, u: float) -> tuple[list, list]:
"""Signs tied for rarest / commonest: within the margin ``u`` of the extreme, in the data or
in at least TIE_SHARE of resamples of the years (within each country, so the mix of
countries stays fixed). Complete counts have no sampling error, but an order that flips
from year to year is not a property of the country."""
P = tz.pivot_table(index=['entity', 'year'], columns='sign', values='births')[SIGNS]
Y = P.values
sh0 = Y.sum(axis=0) / Y.sum() * 100
lo = (sh0 - sh0.min() <= u)
hi = (sh0.max() - sh0 <= u)
n = len(Y)
if n >= 2:
W = np.zeros((BOOTSTRAP, n))
ents = P.index.get_level_values(0).values
for e in np.unique(ents):
idx = np.where(ents == e)[0]
W[:, idx] = rng.multinomial(len(idx), np.full(len(idx), 1 / len(idx)), size=BOOTSTRAP)
B = W @ Y
sh = B / B.sum(axis=1, keepdims=True) * 100
lo |= ((sh - sh.min(axis=1, keepdims=True)) <= u).mean(axis=0) >= TIE_SHARE
hi |= ((sh.max(axis=1, keepdims=True) - sh) <= u).mean(axis=0) >= TIE_SHARE
return [s for s, k in zip(SIGNS, lo) if k], [s for s, k in zip(SIGNS, hi) if k]
def both(t: pd.DataFrame, period: str = 'all') -> dict:
out = {}
for z in ZODIACS:
u = margin(t, z, period)
tz = t[t.zodiac == z]
out[z] = dict(block(tz, u, ties(tz, u)), uncertainty_pp=u)
return out
trop = counts[counts.zodiac == 'tropical']
totals = trop.groupby('entity').births.sum()
ranked = [e for e in totals.index if totals[e] >= MIN_BIRTHS_RANKED]
countries = []
for ent in sorted(totals.index, key=lambda e: -totals[e]):
t = counts[counts.entity == ent]
tt = t[t.zodiac == 'tropical']
srcs = tt.groupby('source').year.agg(['min', 'max', 'nunique']).reset_index().sort_values('min')
runs = [] # contiguous runs of years per source, in order
for src, g in tt.groupby('source'):
ys = sorted(g.year.unique())
start = prev = ys[0]
for y in ys[1:] + [None]:
if y is None or y != prev + 1:
runs.append({'source': src, 'years': [int(start), int(prev)]})
start = y
prev = y if y is not None else prev
runs.sort(key=lambda r: r['years'][0])
decades = {}
for d, g in t.groupby(t.year // 10 * 10):
n = g.year.nunique()
b = both(g, 'decade')
decades[str(int(d))] = {'years': [int(g.year.min()), int(g.year.max())], 'n_years': int(n),
'births': int(round(g[g.zodiac == 'tropical'].births.sum())),
**{z: {'shares': [round(v, 2) for v in b[z]['shares']],
'expected': [round(v, 2) for v in b[z]['expected']],
'rarest': b[z]['rarest'], 'commonest': b[z]['commonest'],
'uncertainty_pp': b[z]['uncertainty_pp']} for z in ZODIACS}}
name, tz, region, hemi = ENTITIES[ent]
countries.append({
'code': ent, 'name': name, 'region': region, 'hemisphere': hemi, 'time_zone': tz,
'ranked': ent in ranked,
'years': [int(tt.year.min()), int(tt.year.max())], 'n_years': int(tt.year.nunique()),
'births': int(round(totals[ent])),
'monthly_share_pct': round(float(tt[tt.method == 'monthly'].births.sum() / totals[ent] * 100), 1),
'sources': [{'source': r.source, 'years': [int(r['min']), int(r['max'])], 'n_years': int(r['nunique'])}
for _, r in srcs.iterrows()],
'source_runs': runs,
**both(t),
'decades': decades,
'year_end_step': step_flags.get(ent, []),
'source_disagreement_pp': sdiff.get(ent),
})
rk = counts[counts.entity.isin(ranked)]
pooled = both(rk)
by_region = []
for region in dict.fromkeys(v[2] for v in ENTITIES.values()):
ents = [e for e in ranked if ENTITIES[e][2] == region]
if ents:
g = rk[rk.entity.isin(ents)]
by_region.append({'region': region, 'countries': ents,
'births': int(round(g[g.zodiac == 'tropical'].births.sum())), **both(g)})
by_hemisphere = {}
for hemi in ('N', 'S'):
ents = [e for e in ranked if ENTITIES[e][3] == hemi]
g = rk[rk.entity.isin(ents)]
by_hemisphere[hemi] = {'countries': ents, 'births': int(round(g[g.zodiac == 'tropical'].births.sum())), **both(g)}
# the same countries in every decade: those with every year from 1960
# the same countries in every decade: every year 1960–2019 (all complete decades); requiring
# 2024 too dropped Spain, Italy and the Netherlands over one preliminary year
LONG_RUN = (1960, 2019)
long_ents = [e for e in ranked
if set(range(LONG_RUN[0], LONG_RUN[1] + 1)) <= set(trop[trop.entity == e].year)]
long_run = []
for d in range(1960, LAST_YEAR + 1, 10):
g = rk[rk.entity.isin(long_ents) & (rk.year >= d) & (rk.year < d + 10)]
long_run.append({'decade': d, 'years': [int(g.year.min()), int(g.year.max())],
'births': int(round(g[g.zodiac == 'tropical'].births.sum())), **both(g, 'decade')})
# pooled by decade over all ranked countries (the mix of countries changes)
pooled_decades = []
for d in range(1960, LAST_YEAR + 1, 10):
g = rk[(rk.year >= d) & (rk.year < d + 10)]
pooled_decades.append({'decade': d, 'countries': int(g.entity.nunique()),
'births': int(round(g[g.zodiac == 'tropical'].births.sum())), **both(g, 'decade')})
# sign lengths, 1960–2024, in days (geocentric; independent of the zone)
lengths = {}
for z in ZODIACS:
jd, k = ingresses(z == 'sidereal')
y = np.array([swe().revjul(j)[0] for j in jd])
dur = np.diff(jd)
sign = k[:-1]
ok = (y[:-1] >= 1960) & (y[:-1] <= LAST_YEAR)
lengths[z] = [round(float(dur[ok & (sign == i)].mean()), 3) for i in range(12)]
# holidays in the daily series: births on the day against the smooth seasonal curve with the
# series' own weekday effect, and what the dips take from Capricorn (those days at the curve)
holidays = []
HOLI = ['12-24', '12-25', '12-26', '12-31', '01-01']
for key in ('FR', 'UK_EW', 'US_1969', 'US', 'BR', 'JP'):
s = read_daily(key)
base = smooth_daily(s.resample('MS').sum()).reindex(s.index)
r = np.log(s / base)
md = s.index.strftime('%m-%d')
wd = pd.Series(r.values, index=s.index)[~md.isin(HOLI)].groupby(s.index[~md.isin(HOLI)].weekday).mean()
expect = base * np.exp(wd.reindex(s.index.weekday).values)
eff = {d: round(float((s[md == d] / expect[md == d]).mean() - 1) * 100, 1) for d in HOLI}
cf = s.where(~md.isin(HOLI), expect)
a = sign_table(s, DAILY_TZ[key])
b = sign_table(cf, DAILY_TZ[key])
sh = lambda t: shares(t[t.zodiac == 'tropical'], []).iloc[0]
holidays.append({'series': DAILY_NAME[key], 'years': list(DAILY[key]), 'effect_pct': eff,
'capricorn_pp': r3(sh(a)['Capricorn']),
'capricorn_without_dips_pp': r3(sh(b)['Capricorn'])})
# The year of births: births on each calendar day against the year's daily mean, averaged over
# the years of a daily series; the northern line is the mean of the northern daily countries
# (the two US series count as one), Brazil is drawn on its own (southern seasons).
def day_index(s: pd.Series) -> pd.Series:
r = s / s.groupby(s.index.year).transform('mean')
return r.groupby(s.index.strftime('%m-%d')).mean()
day_series = {k: read_daily(k) for k in DAILY}
us = pd.concat([day_series['US_1969'], day_series['US']])
north = pd.concat([day_index(day_series['FR']), day_index(day_series['UK_EW']), day_index(us),
day_index(day_series['JP'])], axis=1).mean(axis=1)
brazil = day_index(day_series['BR'])
md = [d.strftime('%m-%d') for d in pd.date_range('2024-01-01', '2024-12-31')]
# mean day of the year (0 = 1 January, in a leap-year calendar) of each tropical ingress, UT
ing_jd, ing_k = ingresses(False)
starts = {}
for j, k in zip(ing_jd, ing_k):
y, m, d, h = swe().revjul(j)
if 1960 <= y <= LAST_YEAR:
doy = (dt.date(2024, m, d) - dt.date(2024, 1, 1)).days + h / 24
starts.setdefault(int(k), []).append(doy)
calendar = {
'days': md,
'north': [round(float(north.get(d, np.nan)) * 100, 2) for d in md],
'brazil': [round(float(brazil.get(d, np.nan)) * 100, 2) for d in md],
'north_series': ['metropolitan France', 'England and Wales', 'the USA', 'Japan'],
# every daily country on its own (the two US series as one), for the chart's toggles
'series': [{'name': name, 'years': years, 'values': [round(float(v.get(d, np.nan)) * 100, 1) for d in md]}
for name, years, v in (('France (metropolitan)', list(DAILY['FR']), day_index(day_series['FR'])),
('England and Wales', list(DAILY['UK_EW']), day_index(day_series['UK_EW'])),
('United States', [DAILY['US_1969'][0], DAILY['US'][1]], day_index(us)),
('Japan', list(DAILY['JP']), day_index(day_series['JP'])),
('Brazil', list(DAILY['BR']), day_index(day_series['BR'])))],
'sign_start_day': [round(float(np.mean(starts[k])), 2) for k in range(12)],
}
summary = {
'swisseph_version': swe().version,
'last_year': LAST_YEAR,
'births_total': int(round(totals.sum())),
'births_ranked': int(round(totals[ranked].sum())),
'countries_total': int(len(totals)),
'countries_ranked': len(ranked),
'min_births_ranked': MIN_BIRTHS_RANKED,
'years': [int(trop.year.min()), int(trop.year.max())],
'country_years': int(trop.groupby(['entity', 'year']).ngroups),
'births_daily': int(round(trop[trop.method == 'daily'].births.sum())),
'births_monthly': int(round(trop[trop.method == 'monthly'].births.sum())),
}
excluded_births = int(excluded.births.sum()) if len(excluded) else 0
out = {
'signs': SIGNS,
'zodiacs': ZODIACS,
'summary': summary,
'sign_length_days': lengths,
'pooled': pooled,
'by_hemisphere': by_hemisphere,
'by_region': by_region,
'long_run': {'countries': long_ents, 'years_required': list(LONG_RUN), 'decades': long_run},
'pooled_decades': pooled_decades,
'countries': countries,
'uncertainty': {'monthly_pp': um, 'daily_pp': ud},
'validation': {
'leave_one_out': [{'series': DAILY_NAME[k], 'years': list(DAILY[k]), **v} for k, v in loo.items()],
'timezone_shift_3h_max_pp': max(max(v.values()) for v in val['timezone_shift'].values()),
'within_day': val['within_day'],
'eurostat_vs_stff': val['overlap'],
},
'holidays': holidays,
'calendar': calendar,
'year_end_step': {'flag_above': YEAR_END_STEP, 'daily_max_abs': round(step_control, 3),
'daily_max_ratio': round(float(np.exp(step_control)), 3)},
'registration_screen': {
'max_elasticity': MAX_WEEKDAY_ELASTICITY,
'series': [{'source': r.source, 'series': (ENTITIES[r.entity][0] if isinstance(r.entity, str) and r.entity in ENTITIES and r.kind == 'monthly' else (DAILY_NAME.get(r.series) or r.series)),
'code': r.series, 'kind': r.kind, 'elasticity': round(float(r.elasticity), 3), 'se': round(float(r.se), 3),
'years': int(r.years)} for _, r in screen.dropna(subset=['elasticity']).iterrows()],
},
'max_unknown_month_pct': MAX_UNKNOWN_MONTH * 100,
'ties': {'share_of_resamples': TIE_SHARE, 'resamples': BOOTSTRAP},
'excluded_years': excluded.assign(name=excluded.entity.map(lambda e: ENTITIES[e][0])).to_dict(orient='records'),
'excluded_births': excluded_births,
'not_used': [{'source': 'Eurostat', 'code': c, 'reason': r} for c, r in EUROSTAT_NOT_USED.items()]
+ [{'source': 'HFD STFF', 'code': c, 'reason': r} for c, r in STFF_NOT_USED.items()],
'daily_series': [{'series': DAILY_NAME[k], 'years': list(DAILY[k]), 'source': DAILY_SOURCE[k]} for k in DAILY],
}
JSON_OUT.parent.mkdir(parents=True, exist_ok=True)
JSON_OUT.write_text(json.dumps(out, ensure_ascii=False, separators=(',', ':')) + '\n')
# dataset: one row per country, year, zodiac and sign
c = counts.copy()
c['total'] = c.groupby(['entity', 'year', 'zodiac']).births.transform('sum')
c['dtot'] = c.groupby(['entity', 'year', 'zodiac']).days.transform('sum')
c['share_pct'] = (c.births / c.total * 100).round(3)
c['expected_share_pct'] = (c.days / c.dtot * 100).round(3)
c['country'] = c.entity.map(lambda e: ENTITIES[e][0])
c['sign_order'] = c.sign.map(SIGNS.index)
c = c.sort_values(['entity', 'year', 'zodiac', 'sign_order'])
c['births_estimated'] = c.births.round(1)
c = c.rename(columns={'entity': 'country_code'})
CSV_OUT.parent.mkdir(parents=True, exist_ok=True)
c[['country_code', 'country', 'year', 'zodiac', 'sign', 'births_estimated', 'share_pct', 'expected_share_pct',
'method', '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', 'fetch-eurostat', 'fetch-stff', 'fetch-france', 'fetch-ew', 'fetch-us',
'fetch-br', 'fetch-jp', 'compute', 'validate', 'export'])
a = ap.parse_args()
RAW.mkdir(parents=True, exist_ok=True)
for name, fn in (('eurostat', fetch_eurostat), ('stff', fetch_stff), ('france', fetch_france), ('ew', fetch_ew),
('us', fetch_us), ('jp', fetch_jp), ('br', fetch_br)):
if a.step in ('fetch', f'fetch-{name}'):
fn()
if a.step == 'compute':
compute()
if a.step == 'validate':
validate()
if a.step == 'export':
export()
return 0
if __name__ == '__main__':
sys.exit(main())