#!/usr/bin/env python3
"""How real birth times shape the birth chart: 95.7 million births in Brazil, Japan and the USA.
Source of the numbers on https://startarot.online/research/real-births-chart-frequencies (English)
and https://startarot.online/pt/research/mapa-astral-nascimentos-reais-brasil (Portuguese, Brazil).
Needs Python 3.10+, curl and unzip on PATH, and
pip install numpy pandas pyarrow pyswisseph pyreaddbc dbfread timezonefinder
The raw data (several GB) are downloaded into $BIRTH_SKY_DATA (default: ./birth-sky-data next to
this script). Steps, in order:
python3 birth_sky.py fetch # all sources (resumable; Brazil's FTP is slow, ~3 MB/min/stream)
python3 birth_sky.py compute # per-country-year weighted counts -> $BIRTH_SKY_DATA/counts/
python3 birth_sky.py export # page JSON + dataset CSV (+ a copy of this script in the repo)
Sub-steps can be run alone: fetch-br|fetch-jp|fetch-us [years], compute-br|compute-us [years],
compute-jp, compute-us-groups [years] (only the US out-of-hospital group), jp-weights (Japan with other
prefecture weights), degrade [years], mars-model.
Run from the repo, export writes (GENERATED — regenerate, do not hand-edit):
frontend/src/lib/research/real-births-chart-frequencies.json data both pages render
frontend/static/research/real-births-chart-frequencies.csv the open dataset (long format)
frontend/static/research/birth_sky.py a copy of this script
A copy downloaded from the page writes the JSON and CSV next to itself.
What is counted
---------------
For every birth (Brazil) or weighted cell (Japan, USA) we take the UT instant and place, and compute
with Swiss Ephemeris (data files sepl_18/semo_18, downloaded) and closed formulas checked against
swe.houses_armc / swe.house_pos (``selftest``: 0 mismatches expected):
- sect ("day chart"): geometric altitude of the Sun's centre > 0 — identical to the Sun in Placidus
houses 7–12; alternatives: whole-sign houses 7–12, apparent sunrise (altitude > −0.833°);
- for the Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn: tropical sign, Placidus house (by
ecliptic longitude, as chart software does), whole-sign house, Gauquelin 36 sectors (true position
with ecliptic latitude; sector 1 starts at rising, 10 at culmination);
- Ascendant and MC signs; Lahiri sidereal signs of the Sun, Moon, Ascendant; Sun × Ascendant pairs;
- "Sun, Mercury and Venus all in houses 7–12" (and by altitude).
Ladder of rungs (same places in every rung)
-------------------------------------------
B1 period astronomy every date of the year equal, time uniform over each local civil day
B2 real dates real births per date (season, weekday, holidays), time uniform per day
B3 spontaneous profile real births per date, spread by the local-clock hour profile of
spontaneous-onset births (Brazil: PARTO=1 & STTRABPART=2, meaningful from
2012; Japan: births at midwife homes + at home, pooled 2015–2024; USA:
DMETH_REC=1 & LD_INDL=N). "Spontaneous" is not "natural".
B4 real real births at their recorded time
B1–B3 are integrated on a 5-minute grid (Brazil: 288 steps over the real length of each local day,
23/24/25 h; Japan: 12 steps per recorded hour; USA: 5-minute bins).
Sources and their limits
------------------------
Brazil — SINASC, Ministério da Saúde / DATASUS, national files DNBR{year}.dbc, 2006–2024 (files before
2006 carry no birth time). Every live birth with a valid HORANASC (99.6–99.95 %), at the seat of its
municipality of OCCURRENCE (IBGE coordinates via github.com/kelvins/municipios-brasileiros, MIT),
local clock time → UT with the IANA zone of that point (DST history from the tz database; times in
the spring-forward gap are shifted by +1 h, repeated times take the first occurrence; both counted).
OpenDataSUS publishes SINASC under CC BY-ND 3.0: we publish aggregates only, with attribution.
Japan — Vital Statistics (人口動態統計), e-Stat table 0003411915 "births by place, month, day and hour",
2015–2024, national only; spread over the 47 prefectures by 2023 births by prefecture (e-Stat file
000040207154) at the prefectural offices (GSI coordinates). JST, no DST. Government of Japan
Standard Terms of Use (compatible with CC BY 4.0).
USA — NCHS Natality public-use files 2016–2024 (U.S. government work): month, weekday and clock time
(DOB_TT) but NO day of month and NO place. Births of (month, weekday) are spread equally over the
matching dates; places are Census county births (co-est2024-alldata, public domain) at Gazetteer
2024 county points, 1° cells per IANA zone. The error of exactly this imputation is measured on
Brazil with the same information removed (``degrade``; 2018 has DST): published with the US numbers.
One US group is also counted on its own, at its recorded times: births planned at home or in a
freestanding birth center (BFACIL 2 and 3; CSV scope ``out_of_hospital``). The file gives them no
place either, so they get the county weights of all births.
Nothing here describes people born outside these countries and years.
"""
from __future__ import annotations
import argparse
import base64
import calendar
import csv
import datetime as dt
import gzip
import html
import http.cookiejar
import io
import json
import os
import re
import subprocess
import sys
import time
import urllib.parse
import urllib.request
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 = 'real-births-chart-frequencies'
if (REPO_ROOT / 'frontend' / 'src' / 'lib' / 'research').is_dir():
JSON_OUT = REPO_ROOT / 'frontend' / 'src' / 'lib' / 'research' / f'{SLUG}.json'
CSV_OUT = REPO_ROOT / 'frontend' / 'static' / 'research' / f'{SLUG}.csv'
SCRIPT_COPY = REPO_ROOT / 'frontend' / 'static' / 'research' / 'birth_sky.py'
# Compact facts for the backend (AI astrologer block, natal-chart hint) — GENERATED.
FACTS_OUT = REPO_ROOT / 'backend' / 'app' / 'data' / 'birth_timing_facts.json'
HOUSE_JSON_OUT = REPO_ROOT / 'frontend' / 'src' / 'lib' / 'research' / 'house-frequency.json'
RISING_JSON = REPO_ROOT / 'frontend' / 'src' / 'lib' / 'research' / 'rising-sign-by-latitude.json'
else:
FACTS_OUT = None
HOUSE_JSON_OUT = None
RISING_JSON = None
JSON_OUT = HERE / f'{SLUG}.json'
CSV_OUT = HERE / f'{SLUG}.csv'
SCRIPT_COPY = None
DATA = Path(os.environ.get('BIRTH_SKY_DATA') or HERE / 'birth-sky-data')
COUNTS = DATA / 'counts'
BR_YEARS = list(range(2006, 2025))
JP_YEARS = list(range(2015, 2025))
US_YEARS = list(range(2016, 2025))
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius',
'Capricorn', 'Aquarius', 'Pisces']
RUNGS = ['B1_period_astronomy', 'B2_real_dates', 'B3_spontaneous_profile', 'B4_real']
KEY_SECTORS = [0, 1, 2, 9, 10, 11] # Gauquelin sectors 1–3 and 10–12
UA = 'Mozilla/5.0 (research script; startarot.online)'
def log(*a):
print(*a, flush=True)
# ------------------------------------------------------------------ downloads
def curl_resume(url: str, dest: Path, tries: int = 12) -> None:
"""Download with resume until the size matches Content-Length (curl handles http and ftp)."""
dest.parent.mkdir(parents=True, exist_ok=True)
head = subprocess.run(['curl', '-sI', '-m', '60', url], capture_output=True, text=True).stdout
m = re.search(r'content-length:\s*(\d+)', head, re.I)
want = int(m.group(1)) if m else None
for _ in range(tries):
have = dest.stat().st_size if dest.exists() else 0
if want is not None and have == want:
return
subprocess.run(['curl', '-s', '-m', '3600', '-C', '-', '-o', str(dest), url])
if want is None and dest.exists():
return
if want is not None and (not dest.exists() or dest.stat().st_size != want):
raise RuntimeError(f'download failed: {url}')
def curl_ranged(url: str, dest: Path, parts: int = 12) -> None:
"""Parallel ranged download (the CDC server throttles single streams)."""
head = subprocess.run(['curl', '-sI', '-m', '60', url], capture_output=True, text=True).stdout
size = int(re.search(r'content-length:\s*(\d+)', head, re.I).group(1))
chunk = -(-size // parts)
procs = []
for i in range(parts):
s, e = i * chunk, min(size, (i + 1) * chunk) - 1
part = dest.with_suffix(dest.suffix + f'.part{i}')
procs.append((part, s, e))
def get(part, s, e):
for _ in range(10):
have = part.stat().st_size if part.exists() else 0
if have == e - s + 1:
return
if have > e - s + 1:
part.unlink(); have = 0
with open(part, 'ab') as fh:
subprocess.run(['curl', '-s', '-m', '1800', '-r', f'{s + have}-{e}', url], stdout=fh)
raise RuntimeError(f'part failed: {part}')
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(parts) as ex:
list(ex.map(lambda p: get(*p), procs))
with open(dest, 'wb') as out:
for part, _, _ in procs:
out.write(part.read_bytes()); part.unlink()
if dest.stat().st_size != size:
raise RuntimeError(f'size mismatch {dest}')
def ensure_ephemeris() -> None:
d = DATA / 'ephe'
for f in ('sepl_18.se1', 'semo_18.se1'):
if not (d / f).exists():
curl_resume(f'https://raw.githubusercontent.com/aloistr/swisseph/master/ephe/{f}', d / f)
# ------------------------------------------------------------------ engine
_swe = None
def swe_mod():
global _swe
if _swe is None:
import swisseph as swe
ensure_ephemeris()
swe.set_ephe_path(str(DATA / 'ephe'))
swe.set_sid_mode(swe.SIDM_LAHIRI)
_swe = swe
return _swe
D2R = np.pi / 180.0
BODY_NAMES = ['sun', 'moon', 'mercury', 'venus', 'mars', 'jupiter', 'saturn']
def local_to_jd(dates, minutes, tz):
"""Local civil date + minutes after local midnight in IANA zone tz -> (jd_ut, n_gap, n_repeat)."""
idx = pd.DatetimeIndex(pd.to_datetime(dates) + pd.to_timedelta(np.asarray(minutes, float), unit='m'))
gap = idx.tz_localize(tz, nonexistent='NaT', ambiguous=np.ones(len(idx), bool)).isna()
rep = idx.tz_localize(tz, nonexistent='shift_forward', ambiguous='NaT').isna()
loc = idx.tz_localize(tz, nonexistent=pd.Timedelta('1h'), ambiguous=np.ones(len(idx), bool))
ut = loc.tz_convert('UTC').tz_localize(None)
return (ut - pd.Timestamp('1970-01-01')).total_seconds().values / 86400.0 + 2440587.5, int(gap.sum()), int(rep.sum())
def day_bounds_jd(dates, tz):
d0 = pd.DatetimeIndex(pd.to_datetime(dates))
to = lambda x: (x.tz_convert('UTC').tz_localize(None) - pd.Timestamp('1970-01-01')).total_seconds().values / 86400.0 + 2440587.5
a = d0.tz_localize(tz, nonexistent='shift_forward', ambiguous=np.ones(len(d0), bool))
b = (d0 + pd.Timedelta(days=1)).tz_localize(tz, nonexistent='shift_forward', ambiguous=np.ones(len(d0), bool))
return to(a), to(b)
def _per_unique(jd, fn, width):
u, inv = np.unique(jd, return_inverse=True)
out = np.empty((len(u), width))
for i, j in enumerate(u):
out[i] = fn(float(j))
return out[inv]
def _body(j, b):
swe = swe_mod()
x, fl = swe.calc_ut(j, b, swe.FLG_SWIEPH)
if not fl & swe.FLG_SWIEPH:
raise RuntimeError('Swiss Ephemeris data files not found')
return x[0], x[1]
def diurnal_position(ra, dec, armc, f):
"""Continuous diurnal position in [0, 36): 0 rising, 9 culmination, 18 setting, 27 lower culmination."""
H = (armc - ra + np.pi) % (2 * np.pi) - np.pi
dsa = np.arccos(np.clip(-np.tan(f) * np.tan(dec), -1.0, 1.0)); nsa = np.pi - dsa
above = np.abs(H) < dsa
Hl = (H % (2 * np.pi)) - np.pi
p = np.where(above, 9.0 * (H + dsa) / np.where(dsa > 0, dsa, 1), 27.0 + 9.0 * Hl / np.where(nsa > 0, nsa, 1))
return p % 36.0, H
def ecl_to_eq(lon, lat, e):
L = lon * D2R; B = lat * D2R
ra = np.arctan2(np.sin(L) * np.cos(e) - np.tan(B) * np.sin(e), np.cos(L))
dec = np.arcsin(np.sin(B) * np.cos(e) + np.cos(B) * np.sin(e) * np.sin(L))
return ra, dec
def features(jd, lat, lon):
swe = swe_mod()
bodies = dict(zip(BODY_NAMES, [swe.SUN, swe.MOON, swe.MERCURY, swe.VENUS, swe.MARS, swe.JUPITER, swe.SATURN]))
jd = np.asarray(jd, float); lat = np.asarray(lat, float); lon = np.asarray(lon, float)
day = np.floor(jd + 0.5)
eps = _per_unique(day, lambda j: [swe.calc_ut(j, swe.ECL_NUT)[0][0]], 1)[:, 0]
ayan = _per_unique(day, lambda j: [swe.get_ayanamsa_ut(j)], 1)[:, 0]
st = _per_unique(jd, lambda j: [swe.sidtime(j)], 1)[:, 0]
armc_deg = (st * 15.0 + lon) % 360.0
e = eps * D2R; a = armc_deg * D2R; f = lat * D2R
asc = np.degrees(np.arctan2(np.cos(a), -(np.sin(a) * np.cos(e) + np.tan(f) * np.sin(e)))) % 360.0
mc = np.degrees(np.arctan2(np.sin(a), np.cos(a) * np.cos(e))) % 360.0
asc_sign = (asc // 30).astype(np.int64)
F = {'asc_sign': asc_sign, 'mc_sign': (mc // 30).astype(np.int64),
'asc_sign_sid': (((asc - ayan) % 360) // 30).astype(np.int64), '_armc': armc_deg, '_eps': eps, '_asc': asc}
for name, b in bodies.items():
ll = _per_unique(jd, lambda j, b=b: _body(j, b), 2)
L, B = ll[:, 0], ll[:, 1]
sign = (L // 30).astype(np.int64)
ra0, dec0 = ecl_to_eq(L, np.zeros_like(L), e)
p0, _ = diurnal_position(ra0, dec0, a, f)
ra1, dec1 = ecl_to_eq(L, B, e)
p1, H1 = diurnal_position(ra1, dec1, a, f)
F[f'{name}_sign'] = sign
F[f'{name}_house'] = (12 - np.floor(p0 / 3)).astype(np.int64)
F[f'{name}_whole'] = ((sign - asc_sign) % 12 + 1).astype(np.int64)
F[f'{name}_gq'] = (np.floor(p1) + 1).astype(np.int64)
alt = np.degrees(np.arcsin(np.sin(f) * np.sin(dec1) + np.cos(f) * np.cos(dec1) * np.cos(H1)))
F[f'{name}_above'] = alt > 0
if name == 'sun':
F['sun_alt'] = alt
F['sun_sign_sid'] = (((L - ayan) % 360) // 30).astype(np.int64)
if name == 'moon':
F['moon_sign_sid'] = (((L - ayan) % 360) // 30).astype(np.int64)
F[f'_{name}_lon'] = L; F[f'_{name}_lat'] = B
return F
CAT = {**{f'{b}_{k}': n for b in BODY_NAMES for k, n in (('sign', 12), ('house', 12), ('whole', 12), ('gq', 36))},
'asc_sign': 12, 'mc_sign': 12, 'asc_sign_sid': 12, 'sun_sign_sid': 12, 'moon_sign_sid': 12}
OFFSET = {k: (1 if k.endswith(('_house', '_whole', '_gq')) else 0) for k in CAT}
SCALARS = ['sect_day', 'sect_day_whole', 'sect_day_apparent', 'smv_all_above', 'smv_any_above',
'smv_all_upper', 'smv_any_upper']
def accumulate(acc, F, w):
w = np.asarray(w, float)
acc['total'] = acc.get('total', 0.0) + float(w.sum())
for k, n in CAT.items():
acc[k] = acc.get(k, 0) + np.bincount(F[k] - OFFSET[k], weights=w, minlength=n)[:n]
masks = {'sect_day': F['sun_alt'] > 0, 'sect_day_whole': F['sun_whole'] >= 7,
'sect_day_apparent': F['sun_alt'] > -0.833,
'smv_all_above': F['sun_above'] & F['mercury_above'] & F['venus_above'],
'smv_any_above': F['sun_above'] | F['mercury_above'] | F['venus_above'],
'smv_all_upper': (F['sun_house'] >= 7) & (F['mercury_house'] >= 7) & (F['venus_house'] >= 7),
'smv_any_upper': (F['sun_house'] >= 7) | (F['mercury_house'] >= 7) | (F['venus_house'] >= 7)}
for k, m in masks.items():
acc[k] = acc.get(k, 0.0) + float(w[m].sum())
acc['sun_asc'] = acc.get('sun_asc', 0) + np.bincount(F['sun_sign'] * 12 + F['asc_sign'], weights=w, minlength=144)
return acc
def selftest(n=6000, seed=11):
swe = swe_mod()
rng = np.random.default_rng(seed)
jd = swe.julday(2024, 1, 1, 0) + rng.random(n) * 366
lat = rng.uniform(-50, 50, n); lon = rng.uniform(-180, 180, n)
F = features(jd, lat, lon)
bad = {'asc': 0, 'placidus': 0, 'gauquelin': 0}
for i in range(n):
armc, la, ep = float(F['_armc'][i]), float(lat[i]), float(F['_eps'][i])
_, ascmc = swe.houses_armc(armc, la, ep, b'P')
bad['asc'] += abs((ascmc[0] - F['_asc'][i] + 180) % 360 - 180) > 1e-6
for name in ('sun', 'moon', 'mars', 'saturn'):
L, B = float(F[f'_{name}_lon'][i]), float(F[f'_{name}_lat'][i])
bad['placidus'] += int(swe.house_pos(armc, la, ep, (L, 0.0), b'P')) != F[f'{name}_house'][i]
bad['gauquelin'] += int(swe.house_pos(armc, la, ep, (L, B), b'G')) != F[f'{name}_gq'][i]
return {'charts': n, **{k: int(v) for k, v in bad.items()}}
def save_counts(name, obj):
COUNTS.mkdir(parents=True, exist_ok=True)
def conv(x):
if isinstance(x, dict): return {k: conv(v) for k, v in x.items()}
if isinstance(x, (list, tuple)): return [conv(v) for v in x]
if isinstance(x, np.ndarray): return [float(v) for v in x]
if isinstance(x, (np.floating, np.integer)): return x.item()
return x
(COUNTS / f'{name}.json').write_text(json.dumps(conv(obj)))
def load_counts(name):
obj = json.loads((COUNTS / f'{name}.json').read_text())
def back(x):
if isinstance(x, dict): return {k: back(v) for k, v in x.items()}
if isinstance(x, list) and x and all(isinstance(v, (int, float)) for v in x): return np.array(x, float)
return x
return back(obj)
# ------------------------------------------------------------------ Brazil
BR_DIR = DATA / 'br'
BR_KEEP = ['DTNASC', 'HORANASC', 'CODMUNNASC', 'PARTO', 'STTRABPART', 'STCESPARTO']
def fetch_br(years):
import pyreaddbc
from dbfread import DBF
BR_DIR.mkdir(parents=True, exist_ok=True)
mun = BR_DIR / 'municipios.csv'
if not mun.exists():
curl_resume('https://raw.githubusercontent.com/kelvins/municipios-brasileiros/main/csv/municipios.csv', mun)
tzf = BR_DIR / 'municipios_tz.csv'
if not tzf.exists():
from timezonefinder import TimezoneFinder
tf = TimezoneFinder(); m = pd.read_csv(mun)
m['tz'] = [tf.timezone_at(lng=lo, lat=la) for la, lo in zip(m.latitude, m.longitude)]
m['cod6'] = m.codigo_ibge.astype(str).str[:6]
m[['cod6', 'codigo_ibge', 'nome', 'latitude', 'longitude', 'codigo_uf', 'tz']].to_csv(tzf, index=False)
for y in years:
pq = BR_DIR / f'sinasc_{y}.parquet'
if pq.exists():
continue
dbc = BR_DIR / f'DNBR{y}.dbc'; dbf = BR_DIR / f'DNBR{y}.dbf'
curl_resume(f'ftp://ftp.datasus.gov.br/dissemin/publicos/SINASC/1996_/Dados/DNRES/DNBR{y}.dbc', dbc)
pyreaddbc.dbc2dbf(str(dbc), str(dbf))
fields = [f.name for f in DBF(str(dbf), load=False).fields]
keep = [k for k in BR_KEEP if k in fields]
recs = [{k: r.get(k) for k in keep} for r in DBF(str(dbf), encoding='iso-8859-1', load=False, char_decode_errors='replace')]
pd.DataFrame.from_records(recs, columns=keep).astype('string').to_parquet(pq)
dbf.unlink(); dbc.unlink()
(BR_DIR / f'sinasc_{y}.source.txt').write_text(f'DNBR{y}.dbc downloaded {dt.date.today()} from ftp.datasus.gov.br\n')
log(f'BR {y}: {len(recs):,} records')
def compute_br(y, grid=288):
raw = pd.read_parquet(BR_DIR / f'sinasc_{y}.parquet')
if 'HORANASC' not in raw:
raise SystemExit(f'BR {y}: no HORANASC in this file (public SINASC files carry birth time from 2006)')
audit = {'year': y, 'n_records': len(raw)}
h = raw.HORANASC.fillna('').str.strip()
ok = h.str.fullmatch(r'([01]\d|2[0-3])[0-5]\d')
audit['time_blank'] = int((h == '').sum()); audit['time_valid'] = int(ok.sum())
mins = h[ok].str[2:].astype(int)
audit['minute_00_pct'] = float((mins == 0).mean() * 100); audit['minute_x5_pct'] = float((mins % 5 == 0).mean() * 100)
df = raw[ok].copy()
mun = pd.read_csv(BR_DIR / 'municipios_tz.csv', dtype={'cod6': str})
df = df.merge(mun[['cod6', 'latitude', 'longitude', 'tz']], left_on='CODMUNNASC', right_on='cod6', how='left')
um = df.latitude.isna(); audit['unmatched_municipality'] = int(um.sum()); df = df[~um]
df['date'] = pd.to_datetime(df.DTNASC, format='%d%m%Y', errors='coerce')
audit['date_bad_or_other_year'] = int((df.date.isna() | (df.date.dt.year != y)).sum())
df = df[df.date.notna() & (df.date.dt.year == y)].reset_index(drop=True)
df['minute'] = df.HORANASC.str[:2].astype(int) * 60 + df.HORANASC.str[2:].astype(int)
for c in ('PARTO', 'STTRABPART', 'STCESPARTO'):
df[c] = df[c].fillna('').astype(str) if c in df else ''
audit['n_used'] = len(df)
jd = np.empty(len(df)); gap = rep = 0
for tz, idx in df.groupby('tz').groups.items():
r = df.index.get_indexer(idx)
j, a, b = local_to_jd(df.date.values[r], df.minute.values[r], tz)
jd[r] = j; gap += a; rep += b
audit['nonexistent_local_times'] = gap; audit['repeated_local_times'] = rep
F = features(jd, df.latitude.values, df.longitude.values)
w = np.ones(len(df))
out = {'audit': audit, 'rungs': {'B4_real': accumulate({}, F, w)}, 'groups': {}}
groups = {'vaginal': df.PARTO.values == '1', 'caesarean': df.PARTO.values == '2',
'caesarean_before_labour': df.STCESPARTO.values == '1',
'spontaneous_vaginal': (df.PARTO.values == '1') & (df.STTRABPART.values == '2'),
'induced': df.STTRABPART.values == '1'}
for g, m in groups.items():
if m.sum():
out['groups'][g] = accumulate({}, {k: v[m] for k, v in F.items()}, w[m])
audit['shares'] = {g: float(m.mean()) for g, m in groups.items()}
audit['sttrabpart_filled'] = float(np.isin(df.STTRABPART.values, ['1', '2']).mean())
uf = df.CODMUNNASC.str[:2].values
st = pd.DataFrame({'uf': uf, 'day': F['sun_alt'] > 0, 'ces': groups['caesarean']}).groupby('uf').agg(
n=('day', 'size'), day=('day', 'mean'), ces=('ces', 'mean'))
out['by_state'] = [{'uf': k, 'n': int(r.n), 'day': float(r.day), 'ces': float(r.ces)} for k, r in st.iterrows()]
hr = df.minute.values // 60
out['hour_counts'] = {g: np.bincount(hr[m], minlength=24) for g, m in list(groups.items()) + [('all', np.ones(len(df), bool))]}
ces = groups['caesarean']
out['caesarean_daytime_share'] = float((F['sun_alt'][ces] > 0).mean()) if ces.sum() else None
spont = groups['spontaneous_vaginal'] if groups['spontaneous_vaginal'].sum() > 10000 else groups['vaginal']
out['spont_definition'] = 'PARTO=1 & STTRABPART=2' if spont is groups['spontaneous_vaginal'] else 'PARTO=1 (STTRABPART missing)'
prof = np.bincount(hr[spont], minlength=24).astype(float); prof /= prof.mean()
df['cl'] = df.latitude.round(); df['co'] = df.longitude.round()
cells = df.groupby(['tz', 'cl', 'co']).agg(lat=('latitude', 'mean'), lon=('longitude', 'mean'), n=('date', 'size')).reset_index()
cells['cid'] = np.arange(len(cells))
df = df.merge(cells[['tz', 'cl', 'co', 'cid']], on=['tz', 'cl', 'co'])
daycount = df.groupby(['cid', 'date']).size()
dates = pd.date_range(f'{y}-01-01', f'{y}-12-31')
steps = (np.arange(grid) + 0.5) / grid
acc = {r: {} for r in RUNGS[:3]}
N = len(df); ci = cells.set_index('cid')
for month in range(1, 13):
md = dates[dates.month == month]
JD, LAT, LON, W1, W2, W3 = [], [], [], [], [], []
for tz, g in cells.groupby('tz'):
dd = np.repeat(md.values, len(g)); cid = np.tile(g.cid.values, len(md))
a, b = day_bounds_jd(dd, tz)
j = a[:, None] + (b - a)[:, None] * steps[None, :]
ut = pd.to_datetime((j.ravel() - 2440587.5) * 86400, unit='s')
local_hour = pd.DatetimeIndex(ut).tz_localize('UTC').tz_convert(tz).hour.values.reshape(j.shape)
cnt = daycount.reindex(pd.MultiIndex.from_arrays([cid, pd.DatetimeIndex(dd)])).fillna(0).values
pw = prof[local_hour]; pw = pw / pw.sum(1, keepdims=True)
JD.append(j.ravel()); LAT.append(np.repeat(ci.lat.reindex(cid).values, grid)); LON.append(np.repeat(ci.lon.reindex(cid).values, grid))
W1.append(np.repeat(ci.n.reindex(cid).values / len(dates) / grid, grid))
W2.append(np.repeat(cnt / grid, grid)); W3.append((cnt[:, None] * pw).ravel())
FG = features(np.concatenate(JD), np.concatenate(LAT), np.concatenate(LON))
for r, W in zip(RUNGS[:3], (W1, W2, W3)):
accumulate(acc[r], FG, np.concatenate(W))
out['rungs'].update(acc)
save_counts(f'BR_{y}', out)
R = out['rungs']
log(f"BR {y}: n={N:,} day charts " + ' '.join(f"{r[:2]} {R[r]['sect_day'] / R[r]['total'] * 100:.2f}" for r in RUNGS))
# ------------------------------------------------------------------ Japan
JP_DIR = DATA / 'jp'
ESTAT_SID = '0003411915'
# Prefectural offices (GSI, "都道府県の庁舎の経緯度", WGS84), prefecture codes 1–47.
JP_PREF = [(43.063968, 141.347899), (40.824623, 140.740593), (39.703531, 141.152667), (38.268839, 140.872103),
(39.7186, 140.102334), (38.240437, 140.363634), (37.750299, 140.467521), (36.341813, 140.446793),
(36.565725, 139.883565), (36.391208, 139.060156), (35.857428, 139.648933), (35.605058, 140.123308),
(35.689521, 139.691704), (35.447753, 139.642514), (37.902418, 139.023221), (36.69529, 137.211338),
(36.594682, 136.625573), (36.065219, 136.221642), (35.664158, 138.568449), (36.651289, 138.181224),
(35.391227, 136.722291), (34.976978, 138.383054), (35.180188, 136.906565), (34.730283, 136.508591),
(35.004531, 135.86859), (35.021004, 135.755608), (34.686297, 135.519661), (34.691279, 135.183025),
(34.685333, 135.832744), (34.226034, 135.167506), (35.503869, 134.237672), (35.472297, 133.050499),
(34.661772, 133.934675), (34.39656, 132.459622), (34.186121, 131.4705), (34.065761, 134.559303),
(34.340149, 134.043444), (33.841624, 132.765681), (33.559706, 133.53108), (33.606785, 130.418314),
(33.249367, 130.298822), (32.744839, 129.873756), (32.789828, 130.741667), (33.238194, 131.612591),
(31.911096, 131.423855), (31.560148, 130.557981), (26.212401, 127.680932)]
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_rows(op, year, month, place):
"""One view of the e-Stat table: rows = day (total + 1..31), cols = hour (total + 0..23).
The dbview page posts its layout as gzip+base64 JSON; this replicates that 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(place)]),
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('00100' if month == 0 else 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'))
rows = []
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:
label = cells[-26]; vals = [int(v.replace(',', '')) if v not in ('-', '…', '') else 0 for v in cells[-25:]]
rows.append((0 if '総数' in label else int(label.replace('日', '')), vals))
return rows
def fetch_jp():
JP_DIR.mkdir(parents=True, exist_ok=True)
main = JP_DIR / 'jp_births_month_day_hour.csv'
if not main.exists():
op = _estat_opener(); out = []
for y in JP_YEARS:
for m in range(1, 13):
for d, vals in _estat_rows(op, y, m, '000000'):
if d and not any(vals):
continue
out.append([y, m, d] + vals)
time.sleep(0.7)
log(f'JP {y} fetched')
pd.DataFrame(out, columns=['year', 'month', 'day', 'total'] + [f'h{h:02d}' for h in range(24)]).to_csv(main, index=False)
place = JP_DIR / 'jp_hour_by_place.csv'
if not place.exists():
op = _estat_opener(); out = []
for y in JP_YEARS:
for p in ('001100', '001200', '001400', '002100', '002200'):
rows = [v for d, v in _estat_rows(op, y, 0, p) if d == 0]
if rows:
out.append([y, p] + rows[0])
time.sleep(0.5)
pd.DataFrame(out, columns=['year', 'place', 'total'] + [f'h{h:02d}' for h in range(24)]).to_csv(place, index=False)
pref = JP_DIR / 'pref_month_2023.csv'
if not pref.exists():
curl_resume('https://www.e-stat.go.jp/stat-search/file-download?statInfId=000040207154&fileKind=1', pref)
def compute_jp(sub=12):
d = pd.read_csv(JP_DIR / 'jp_births_month_day_hour.csv'); d = d[d.day > 0]
place = pd.read_csv(JP_DIR / 'jp_hour_by_place.csv', dtype={'place': str})
raw = pd.read_csv(JP_DIR / 'pref_month_2023.csv', encoding='shift_jis', header=None, skiprows=7)
w = raw[raw[0].astype(str).str.match(r'^\d\d ')][[0, 1]].copy()
w['id'] = w[0].str[:2].astype(int); w['n'] = w[1].astype(float)
w = w[w.id <= 47].sort_values('id') # codes 50+ repeat designated cities
assert list(w.id) == list(range(1, 48))
share = (w.n / w.n.sum()).values
plat = np.array([p[0] for p in JP_PREF]); plon = np.array([p[1] for p in JP_PREF])
hc = [f'h{h:02d}' for h in range(24)]
nat = place[place.place.isin(['001400', '002100'])][hc].sum().values.astype(float); natp = nat / nat.sum()
steps = (np.arange(sub) + 0.5) / sub
swe = swe_mod()
for y in JP_YEARS:
yy = d[d.year == y].sort_values(['month', 'day'])
counts = yy[hc].values.astype(float)
jd_day = np.array([swe.julday(int(a), int(b), int(c), 0.0) for a, b, c in zip(yy.year, yy.month, yy.day)])
nd = len(jd_day)
local_h = (np.arange(24)[:, None] + steps[None, :]).ravel()
jd = (jd_day[:, None, None] + local_h[None, :, None] / 24 - 9 / 24) + np.zeros((1, 1, 47))
F = features(jd.ravel(), np.broadcast_to(plat, jd.shape).ravel(), np.broadcast_to(plon, jd.shape).ravel())
tot = counts.sum(); day_tot = counts.sum(1); hi = np.repeat(np.arange(24), sub)
W = {'B1_period_astronomy': np.full((nd, 24 * sub), tot / nd / (24 * sub)),
'B2_real_dates': np.repeat(day_tot[:, None] / (24 * sub), 24 * sub, 1),
'B3_spontaneous_profile': day_tot[:, None] * natp[hi][None, :] / sub,
'B4_real': counts[:, hi] / sub}
yp = place[place.year == y]
out = {'audit': {'year': y, 'n_hour_known': float(tot), 'n_table_total': float(yy.total.sum()),
'natural_births_pooled': float(nat.sum())},
'rungs': {k: accumulate({}, F, (v[:, :, None] * share[None, None, :]).ravel()) for k, v in W.items()},
'hour_counts': {'all': counts.sum(0), 'hospital': yp[yp.place == '001100'][hc].values.sum(0),
'clinic': yp[yp.place == '001200'][hc].values.sum(0),
'midwife_home_and_home': yp[yp.place.isin(['001400', '002100'])][hc].values.sum(0)}}
save_counts(f'JP_{y}', out)
R = out['rungs']
log(f"JP {y}: " + ' '.join(f"{r[:2]} {R[r]['sect_day'] / R[r]['total'] * 100:.2f}" for r in RUNGS))
def jp_weights_test(y=2024, sub=12):
"""How much the Japanese results depend on the prefecture weights: the real births of one year at
their recorded hours, with the 2023 prefecture shares replaced by equal weights and by every
single prefecture in turn (all births placed at one prefectural office)."""
d = pd.read_csv(JP_DIR / 'jp_births_month_day_hour.csv'); d = d[d.day > 0]
raw = pd.read_csv(JP_DIR / 'pref_month_2023.csv', encoding='shift_jis', header=None, skiprows=7)
w = raw[raw[0].astype(str).str.match(r'^\d\d ')][[0, 1]].copy()
w['id'] = w[0].str[:2].astype(int); w['n'] = w[1].astype(float)
w = w[w.id <= 47].sort_values('id'); share = (w.n / w.n.sum()).values
plat = np.array([p[0] for p in JP_PREF]); plon = np.array([p[1] for p in JP_PREF])
hc = [f'h{h:02d}' for h in range(24)]
yy = d[d.year == y].sort_values(['month', 'day']); counts = yy[hc].values.astype(float)
swe = swe_mod()
jd_day = np.array([swe.julday(int(a), int(b), int(c), 0.0) for a, b, c in zip(yy.year, yy.month, yy.day)])
local_h = (np.arange(24)[:, None] + ((np.arange(sub) + 0.5) / sub)[None, :]).ravel()
jd = (jd_day[:, None, None] + local_h[None, :, None] / 24 - 9 / 24) + np.zeros((1, 1, 47))
F = features(jd.ravel(), np.broadcast_to(plat, jd.shape).ravel(), np.broadcast_to(plon, jd.shape).ravel())
real = counts[:, np.repeat(np.arange(24), sub)] / sub
def run(sh):
R = accumulate({}, F, (real[:, :, None] * sh[None, None, :]).ravel())
return R['sect_day'] / R['total'] * 100, np.asarray(R['sun_house'], float) / R['total'] * 100
day0, house0 = run(share)
def diff(sh):
day, house = run(sh)
return {'day_pp': float(day - day0), 'sun_house_pp': float(np.abs(house - house0).max())}
single = [diff(np.eye(47)[i]) for i in range(47)]
out = {'year': y, 'day_published': float(day0), 'equal_weights': diff(np.full(47, 1 / 47)),
'tokyo_only': single[12],
'single_prefecture_max': {'day_pp': max(abs(s['day_pp']) for s in single),
'sun_house_pp': max(s['sun_house_pp'] for s in single)}}
save_counts('JP_WEIGHTS_TEST', out)
log(f"JP weights test {y}: equal {out['equal_weights']}, single prefecture max {out['single_prefecture_max']}")
# ------------------------------------------------------------------ USA
US_DIR = DATA / 'us'
US_FILES = {2016: 'Nat2016us.zip', 2017: 'Nat2017US.zip', 2018: 'Nat2018us.zip', 2019: 'Nat2019us.zip',
2020: 'Nat2020us.zip', 2021: 'nat2021us.zip', 2022: 'nat2022us.zip', 2023: 'Nat2023us.zip',
2024: 'Nat2024us.zip'}
def fetch_us(years):
US_DIR.mkdir(parents=True, exist_ok=True)
for y in years:
pq = US_DIR / f'us_{y}_counts.parquet'
if pq.exists():
continue
z = US_DIR / f'Nat{y}us.zip'
curl_ranged(f'https://ftp.cdc.gov/pub/Health_Statistics/NCHS/Datasets/DVS/natality/{US_FILES[y]}', z)
# Positions (1-based, 2016–2024 layout): DOB_YY 9-12, DOB_MM 13-14, DOB_TT 19-22, DOB_WK 23,
# BFACIL 32, LD_INDL 383, DMETH_REC 408. The zips use Deflate64: read with unzip.
cnt = {}
p = subprocess.Popen(['unzip', '-p', str(z)], stdout=subprocess.PIPE)
for line in io.TextIOWrapper(p.stdout, encoding='latin-1'):
k = (line[8:12], line[12:14], line[22:23], line[18:22], line[31:32], line[407:408], line[382:383])
cnt[k] = cnt.get(k, 0) + 1
pd.DataFrame([k + (v,) for k, v in cnt.items()], columns=['year', 'month', 'weekday', 'hhmm', 'bfacil', 'dmeth', 'induced', 'n']).to_parquet(pq)
z.unlink(); log(f'US {y}: {sum(cnt.values()):,} records')
co = US_DIR / 'us_counties_births_tz.csv'
if not co.exists():
est = US_DIR / 'co-est2024-alldata.csv'; gz = US_DIR / 'gaz_counties.zip'
curl_resume('https://www2.census.gov/programs-surveys/popest/datasets/2020-2024/counties/totals/co-est2024-alldata.csv', est)
curl_resume('https://www2.census.gov/geo/docs/maps-data/data/gazetteer/2024_Gazetteer/2024_Gaz_counties_national.zip', gz)
subprocess.run(['unzip', '-o', '-q', str(gz), '-d', str(US_DIR)], check=True)
from timezonefinder import TimezoneFinder
c = pd.read_csv(est, encoding='latin-1', dtype={'STATE': str, 'COUNTY': str}); c = c[c.SUMLEV == 50].copy()
c['GEOID'] = c.STATE + c.COUNTY
g = pd.read_csv(US_DIR / '2024_Gaz_counties_national.txt', sep='\t', dtype={'GEOID': str}); g.columns = [x.strip() for x in g.columns]
m = c.merge(g[['GEOID', 'INTPTLAT', 'INTPTLONG']], on='GEOID')
tf = TimezoneFinder(); m['tz'] = [tf.timezone_at(lng=lo, lat=la) for la, lo in zip(m.INTPTLAT, m.INTPTLONG)]
m[['GEOID', 'STNAME', 'CTYNAME', 'INTPTLAT', 'INTPTLONG', 'tz'] + [f'BIRTHS{k}' for k in range(2020, 2025)]].to_csv(co, index=False)
def dates_by_month_weekday(year):
out = {}
for m in range(1, 13):
for d in range(1, calendar.monthrange(year, m)[1] + 1):
out.setdefault((m, dt.date(year, m, d).isoweekday() % 7 + 1), []).append(f'{year}-{m:02d}-{d:02d}')
return out
def impute(counts, geo, year, bin_minutes, sub=1):
"""National counts by (month, weekday 1=Sunday, clock-time bin) spread over the matching dates and
the places of `geo` (tz, lat, lon, share). Used for the USA and for the degradation test."""
acc = {}; days = dates_by_month_weekday(year)
offs = (np.arange(sub) + 0.5) / sub * bin_minutes
for m in range(1, 13):
JD, LAT, LON, W = [], [], [], []
cm = counts[counts.month == m]
for (mm, wd), dd in days.items():
if mm != m:
continue
c = cm[cm.weekday == wd]
if c.empty:
continue
mins = (c.bin.values[:, None] * bin_minutes + offs[None, :]).ravel()
n = np.repeat(c.n.values / len(dd) / sub, sub)
for tz, g in geo.groupby('tz'):
for day in dd:
jd, _, _ = local_to_jd(np.repeat(day, len(mins)), mins, tz)
JD.append(np.repeat(jd, len(g))); LAT.append(np.tile(g.lat.values, len(jd)))
LON.append(np.tile(g.lon.values, len(jd))); W.append((n[:, None] * g.share.values[None, :]).ravel())
if JD:
accumulate(acc, features(np.concatenate(JD), np.concatenate(LAT), np.concatenate(LON)), np.concatenate(W))
return acc
def compute_us(y, bin_minutes=5, only_groups=False):
"""All four rungs for one year plus the out-of-hospital group at its own recorded times.
``only_groups`` recomputes just that group and merges it into the stored counts of the year."""
nb = 1440 // bin_minutes
c = pd.read_parquet(US_DIR / f'us_{y}_counts.parquet')
audit = {'year': y, 'n_records': int(c.n.sum()), 'time_not_stated': int(c[c.hhmm == '9999'].n.sum())}
c = c[c.hhmm != '9999'].copy()
c['minute'] = c.hhmm.str[:2].astype(int) * 60 + c.hhmm.str[2:].astype(int)
bad = c.minute >= 1440; audit['time_invalid'] = int(c[bad].n.sum()); c = c[~bad]
c['month'] = c.month.astype(int); c['weekday'] = c.weekday.astype(int); c['bin'] = c.minute // bin_minutes
mn = c.minute % 60
audit['minute_00_pct'] = float(c[mn == 0].n.sum() / c.n.sum() * 100)
audit['minute_x5_pct'] = float(c[mn % 5 == 0].n.sum() / c.n.sum() * 100)
audit['n_used'] = int(c.n.sum())
spont = (c.dmeth == '1') & (c.induced == 'N')
# BFACIL: 1 hospital, 2 freestanding birth center, 3 home (intended), 4 home (not intended),
# 5 home (unknown if intended), 6 clinic / doctor's office, 7 other, 9 unknown.
ooh = c.bfacil.isin(['2', '3'])
audit['out_of_hospital'] = {'birth_center': int(c[c.bfacil == '2'].n.sum()), 'home_intended': int(c[c.bfacil == '3'].n.sum())}
audit['shares'] = {'caesarean': float(c[c.dmeth == '2'].n.sum() / c.n.sum()), 'induced': float(c[c.induced == 'Y'].n.sum() / c.n.sum()),
'spontaneous': float(c[spont].n.sum() / c.n.sum()), 'hospital': float(c[c.bfacil == '1'].n.sum() / c.n.sum())}
real = c.groupby(['month', 'weekday', 'bin']).n.sum().reset_index()
mw = c.groupby(['month', 'weekday']).n.sum().reset_index()
prof = c[spont].groupby('bin').n.sum().reindex(range(nb), fill_value=0).values.astype(float); prof /= prof.sum()
b2 = mw.merge(pd.DataFrame({'bin': range(nb)}), how='cross'); b2['n'] = b2.n / nb
b3 = mw.merge(pd.DataFrame({'bin': range(nb), 'p': prof}), how='cross'); b3['n'] = b3.n * b3.p
k = {}
for (m, wd), dd in dates_by_month_weekday(y).items():
k[(m, wd)] = len(dd)
nd = sum(k.values()); tot = c.n.sum()
b1 = pd.DataFrame([(m, wd, tot * v / nd) for (m, wd), v in k.items()], columns=['month', 'weekday', 'n'])
b1 = b1.merge(pd.DataFrame({'bin': range(nb)}), how='cross'); b1['n'] = b1.n / nb
co = pd.read_csv(US_DIR / 'us_counties_births_tz.csv', dtype={'GEOID': str})
col = f'BIRTHS{min(max(y, 2021), 2024)}'
co['w'] = co[col].clip(lower=0); co = co[co.w > 0]
co['cl'] = co.INTPTLAT.round(); co['cn'] = co.INTPTLONG.round()
geo = co.groupby(['tz', 'cl', 'cn']).apply(lambda d: pd.Series({'lat': np.average(d.INTPTLAT, weights=d.w), 'lon': np.average(d.INTPTLONG, weights=d.w), 'n': d.w.sum()}), include_groups=False).reset_index()
geo['share'] = geo.n / geo.n.sum(); audit['geo_weights'] = f'Census county {col}'; audit['geo_cells'] = len(geo)
hour = lambda mask: c[mask].groupby(c[mask].minute // 60).n.sum().reindex(range(24), fill_value=0).values
# Births planned at home or in a freestanding birth center, at their own recorded times. The file
# gives them no place either: they get the same county weights as all births (a stated limit).
group = {'out_of_hospital': impute(c[ooh].groupby(['month', 'weekday', 'bin']).n.sum().reset_index(), geo, y, bin_minutes)}
if only_groups:
out = load_counts(f'US_{y}')
out['audit']['out_of_hospital'] = audit['out_of_hospital']
out['hour_counts']['out_of_hospital'] = hour(ooh.values)
out['groups'] = group
save_counts(f'US_{y}', out)
G = group['out_of_hospital']
log(f"US {y} out of hospital: n {G['total']:.0f} day {G['sect_day'] / G['total'] * 100:.2f}")
return
out = {'audit': audit, 'rungs': {}, 'groups': group,
'hour_counts': {'all': hour(np.ones(len(c), bool)), 'spontaneous_vaginal': hour(spont.values),
'caesarean': hour((c.dmeth == '2').values), 'induced': hour((c.induced == 'Y').values),
'out_of_hospital': hour(ooh.values)}}
for name, tab in (('B4_real', real), ('B1_period_astronomy', b1), ('B2_real_dates', b2), ('B3_spontaneous_profile', b3)):
out['rungs'][name] = impute(tab[['month', 'weekday', 'bin', 'n']], geo, y, bin_minutes)
save_counts(f'US_{y}', out)
R = out['rungs']
log(f"US {y}: " + ' '.join(f"{r[:2]} {R[r]['sect_day'] / R[r]['total'] * 100:.2f}" for r in RUNGS))
def degrade(y, bin_minutes=10):
"""Remove from Brazil exactly what the US file lacks (day of month, place, exact minute), impute
US-style, and store it next to the full per-birth answer."""
df = pd.read_parquet(BR_DIR / f'sinasc_{y}.parquet', columns=['DTNASC', 'HORANASC', 'CODMUNNASC'])
h = df.HORANASC.fillna('').str.strip(); df = df[h.str.fullmatch(r'([01]\d|2[0-3])[0-5]\d')]
mun = pd.read_csv(BR_DIR / 'municipios_tz.csv', dtype={'cod6': str})
df = df.merge(mun[['cod6', 'latitude', 'longitude', 'tz']], left_on='CODMUNNASC', right_on='cod6')
df['date'] = pd.to_datetime(df.DTNASC, format='%d%m%Y', errors='coerce'); df = df[df.date.dt.year == y]
df['month'] = df.date.dt.month; df['weekday'] = (df.date.dt.dayofweek + 1) % 7 + 1
df['bin'] = (df.HORANASC.str[:2].astype(int) * 60 + df.HORANASC.str[2:].astype(int)) // bin_minutes
counts = df.groupby(['month', 'weekday', 'bin']).size().rename('n').reset_index()
df['cl'] = df.latitude.round(); df['cn'] = df.longitude.round()
geo = df.groupby(['tz', 'cl', 'cn']).agg(lat=('latitude', 'mean'), lon=('longitude', 'mean'), n=('date', 'size')).reset_index()
geo['share'] = geo.n / geo.n.sum()
imputed = impute(counts, geo, y, bin_minutes, sub=2)
save_counts(f'DEGRADE_BR_{y}', {'imputed': imputed, 'full': load_counts(f'BR_{y}')['rungs']['B4_real']})
log(f'degradation test BR {y} stored')
# ------------------------------------------------------------------ Mars mechanism
def mars_model():
"""Planet diurnal sector ≈ Sun's sector shifted by (RA_sun − RA_planet)/10°. Convolving the Sun's
real and date-only sector distributions with the long-run offset distribution (1900–2099) predicts
the pooled key-sector excess; the per-year version is checked against the full computation."""
swe = swe_mod()
def ra(j, b):
return swe.calc_ut(j, b, swe.FLG_SWIEPH | swe.FLG_EQUATORIAL)[0][0]
def offsets(y0, y1, b, step):
js = np.arange(swe.julday(y0, 1, 1, 12), swe.julday(y1 + 1, 1, 1, 12), step)
return np.array([((ra(j, swe.SUN) - ra(j, b) + 180) % 360 - 180) / 10.0 for j in js])
def kernel(off):
K = np.zeros(36); lo = np.floor(off).astype(int); fr = off - lo
np.add.at(K, lo % 36, 1 - fr); np.add.at(K, (lo + 1) % 36, fr); return K / K.sum()
def conv(s, K):
return sum(K[d] * np.roll(s, d) for d in range(36))
key = lambda v: float(v[KEY_SECTORS].sum())
long_mars = offsets(1900, 2099, swe.MARS, 2.0)
out = {'mars_within_60deg_of_sun_pct': float(np.mean(np.abs(long_mars) < 6) * 100),
'uniform_within_60deg_pct': 100 / 3,
'jupiter_within_60deg_pct': float(np.mean(np.abs(offsets(1900, 2099, swe.JUPITER, 2.0)) < 6) * 100),
'saturn_within_60deg_pct': float(np.mean(np.abs(offsets(1900, 2099, swe.SATURN, 2.0)) < 6) * 100),
'countries': {}}
Kl = kernel(long_mars)
for c, years in (('BR', BR_YEARS), ('JP', JP_YEARS), ('US', US_YEARS)):
s2 = pooled_pct(c, years, 'sun_gq', 'B2_real_dates'); s4 = pooled_pct(c, years, 'sun_gq', 'B4_real')
per_year = []
for y in years:
R = load_counts(f'{c}_{y}')['rungs']; Ky = kernel(offsets(y, y, swe.MARS, 1.0))
p = lambda r, k: np.asarray(R[r][k]) / R[r]['total'] * 100
per_year.append((key(conv(p('B4_real', 'sun_gq'), Ky)) - key(conv(p('B2_real_dates', 'sun_gq'), Ky)),
key(p('B4_real', 'mars_gq')) - key(p('B2_real_dates', 'mars_gq'))))
per_year = np.array(per_year)
out['countries'][c] = {'predicted_long_run_excess_pp': key(conv(s4, Kl)) - key(conv(s2, Kl)),
'per_year_corr_model_vs_computed': float(np.corrcoef(per_year[:, 0], per_year[:, 1])[0, 1])}
(COUNTS / 'MARS_MODEL.json').write_text(json.dumps(out))
log(json.dumps(out, indent=1))
# ------------------------------------------------------------------ export
def pooled_pct(c, years, feature, rung):
acc = None; tot = 0.0
for y in years:
R = load_counts(f'{c}_{y}')['rungs'][rung]
v = np.asarray(R[feature], float); acc = v if acc is None else acc + v; tot += R['total']
return acc / tot * 100
def pct(R, k):
return np.asarray(R[k], float) / R['total'] * 100
def r2(x, n=3):
# Three decimals: shares are shown with one, and two-decimal storage double-rounds
# (63.646 -> 63.65 -> '63.7' in Intl.NumberFormat).
if isinstance(x, (list, np.ndarray)):
return [round(float(v), n) for v in x]
return round(float(x), n)
def export():
years = {'BR': BR_YEARS, 'JP': JP_YEARS, 'US': US_YEARS}
counts = {(c, y): load_counts(f'{c}_{y}') for c, ys in years.items() for y in ys}
rows = []
feats = list(CAT) + ['sun_asc']
def emit(country, scope, year, rung, R):
tot = R['total']
for k in SCALARS:
rows.append([country, scope, year, rung, k, 'yes', r2(R[k] / tot * 100, 4), r2(R[k], 1)])
for k in feats:
v = np.asarray(R[k], float)
for i, x in enumerate(v):
if k == 'sun_asc':
cat = f'{SIGNS[i // 12]}|{SIGNS[i % 12]}'
elif k.endswith(('_sign', '_sign_sid')):
cat = SIGNS[i]
else:
cat = str(i + 1)
rows.append([country, scope, year, rung, k, cat, r2(x / tot * 100, 4), r2(x, 1)])
for (c, y), obj in sorted(counts.items()):
for r in RUNGS:
emit(c, 'national', y, r, obj['rungs'][r])
for g, v in obj['hour_counts'].items():
v = np.asarray(v, float)
if v.sum() > 0:
for h, x in enumerate(v):
rows.append([c, 'national', y, 'B4_real', f'birth_hour_{g}', str(h), r2(x / v.sum() * 100, 4), r2(x, 1)])
if c == 'US':
# Births planned at home or in a freestanding birth center, at their recorded times.
emit(c, 'out_of_hospital', y, 'B4_real', obj['groups']['out_of_hospital'])
if c == 'BR':
for s in obj['by_state']:
rows.append([c, f"UF{s['uf']}", y, 'B4_real', 'sect_day', 'yes', r2(s['day'] * 100, 4), s['n']])
rows.append([c, f"UF{s['uf']}", y, 'B4_real', 'caesarean_share', 'yes', r2(s['ces'] * 100, 4), s['n']])
CSV_OUT.parent.mkdir(parents=True, exist_ok=True)
with open(CSV_OUT, 'w', newline='') as fh:
w = csv.writer(fh)
w.writerow(['country', 'scope', 'year', 'rung', 'feature', 'category', 'share_pct', 'weighted_births'])
w.writerows(rows)
# ---- page data
series = []
for (c, y), obj in sorted(counts.items()):
R = obj['rungs']; a = obj['audit']
top = int(np.argmax(pct(R['B4_real'], 'sun_house')))
row = {'country': c, 'year': y, 'births': round(R['B4_real']['total']),
'day': {r[:2]: r2(pct(R[r], 'sect_day')) for r in RUNGS},
'day_whole_B4': r2(pct(R['B4_real'], 'sect_day_whole')),
'smv_upper': {'B2': r2(pct(R['B2_real_dates'], 'smv_all_upper')), 'B4': r2(pct(R['B4_real'], 'smv_all_upper'))},
'sun_house_B4': r2(pct(R['B4_real'], 'sun_house')), 'sun_house_B2': r2(pct(R['B2_real_dates'], 'sun_house')),
'sun_top_house': top + 1,
'sun_top_ratio': r2(pct(R['B4_real'], 'sun_house')[top] / pct(R['B2_real_dates'], 'sun_house')[top]),
'mars_key': {r[:2]: r2(pct(R[r], 'mars_gq')[KEY_SECTORS].sum()) for r in RUNGS},
'asc_max_diff_pp': r2(np.abs(pct(R['B4_real'], 'asc_sign') - pct(R['B2_real_dates'], 'asc_sign')).max(), 3),
'moon_max_diff_pp': r2(np.abs(pct(R['B4_real'], 'moon_sign') - pct(R['B2_real_dates'], 'moon_sign')).max(), 3)}
if c == 'BR':
G = obj['groups']
row.update(day_vaginal=r2(pct(G['vaginal'], 'sect_day')), day_caesarean=r2(pct(G['caesarean'], 'sect_day')))
row.update(caesarean=r2(a['shares']['caesarean'] * 100), time_valid=r2(a['time_valid'] / a['n_records'] * 100),
minute_00=r2(a['minute_00_pct']), caesarean_daytime=r2(obj['caesarean_daytime_share'] * 100),
spont_defined=bool(a['sttrabpart_filled'] > 0.9), gap=a['nonexistent_local_times'], repeated=a['repeated_local_times'])
if c == 'US':
hc = obj['hour_counts']
row['share_8_to_18'] = {g: r2(np.asarray(hc[g], float)[8:18].sum() / np.asarray(hc[g], float).sum() * 100)
for g in ('all', 'spontaneous_vaginal', 'caesarean', 'induced')}
row.update(caesarean=r2(a['shares']['caesarean'] * 100), induced=r2(a['shares']['induced'] * 100),
time_not_stated=a['time_not_stated'], minute_00=r2(a['minute_00_pct']))
series.append(row)
states = [{'year': y, 'uf': s['uf'], 'n': s['n'], 'day': r2(s['day'] * 100), 'ces': r2(s['ces'] * 100)}
for (c, y), o in sorted(counts.items()) if c == 'BR' for s in o['by_state']]
st = pd.DataFrame(states)
pooled = {}
for c, ys in years.items():
P = {r: {k: pooled_pct(c, ys, k, r) for k in ('sun_house', 'mercury_house', 'venus_house', 'mars_house', 'jupiter_house',
'saturn_house', 'moon_house', 'asc_sign', 'moon_sign', 'sun_sign', 'sun_asc', 'mars_gq', 'sun_gq')}
for r in ('B1_period_astronomy', 'B2_real_dates', 'B4_real')}
per10k = P['B4_real']['sun_asc'] * 100
pooled[c] = {
'years': [ys[0], ys[-1]], 'births': round(sum(counts[(c, y)]['rungs']['B4_real']['total'] for y in ys)),
'house_ratio': {b: r2(P['B4_real'][f'{b}_house'] / P['B2_real_dates'][f'{b}_house']) for b in ('sun', 'mercury', 'venus', 'moon', 'mars', 'jupiter', 'saturn')},
'sun_house_B4': r2(P['B4_real']['sun_house']),
'asc_sign': {'B4': r2(P['B4_real']['asc_sign']), 'B2': r2(P['B2_real_dates']['asc_sign']), 'B1': r2(P['B1_period_astronomy']['asc_sign'])},
'moon_sign_B4': r2(P['B4_real']['moon_sign'], 3), 'sun_sign': {'B4': r2(P['B4_real']['sun_sign']), 'B1': r2(P['B1_period_astronomy']['sun_sign'])},
'sun_asc_per10k': r2(per10k, 1), 'sun_asc_B2_per10k': r2(P['B2_real_dates']['sun_asc'] * 100, 1), 'sun_asc_ratio': r2(P['B4_real']['sun_asc'] / P['B2_real_dates']['sun_asc']),
'mars_key': {'B4': r2(P['B4_real']['mars_gq'][KEY_SECTORS].sum()), 'B2': r2(P['B2_real_dates']['mars_gq'][KEY_SECTORS].sum())},
}
hours = {}
for c, ys in years.items():
H = {}
for y in ys:
for g, v in counts[(c, y)]['hour_counts'].items():
H[g] = H.get(g, 0) + np.asarray(v, float)
hours[c] = {g: r2(v / v.sum() * 100) for g, v in H.items() if v.sum() > 0}
# USA: births planned at home or in a freestanding birth center, pooled over all years.
G = [counts[('US', y)]['groups']['out_of_hospital'] for y in US_YEARS]
A = [counts[('US', y)]['audit'] for y in US_YEARS]
g_tot = sum(g['total'] for g in G)
oh = hours['US']['out_of_hospital']; hosp = hours['US']['all']
top = sorted(np.argsort(oh)[-4:].tolist())
us_out_of_hospital = {
'years': [US_YEARS[0], US_YEARS[-1]], 'births': round(g_tot),
'home_intended': sum(a['out_of_hospital']['home_intended'] for a in A),
'birth_center': sum(a['out_of_hospital']['birth_center'] for a in A),
'share_of_births_pct': [r2((a['out_of_hospital']['home_intended'] + a['out_of_hospital']['birth_center']) / a['n_used'] * 100) for a in A],
'day': r2(sum(g['sect_day'] for g in G) / g_tot * 100),
'day_by_year': [r2(pct(g, 'sect_day')) for g in G],
'sun_house': r2(sum(np.asarray(g['sun_house'], float) for g in G) / g_tot * 100),
'share_8_to_18': r2(sum(oh[8:18])), 'share_8_to_18_all': r2(sum(hosp[8:18])),
'peak_hours': [top[0], top[-1] + 1],
}
deg = {}
for y in (2018, 2024):
try:
D = load_counts(f'DEGRADE_BR_{y}')
except FileNotFoundError:
continue
f, i = D['full'], D['imputed']
def err(k):
a, b = pct(f, k), pct(i, k)
return r2(np.abs(a - b).max(), 3)
def rel(k):
a, b = pct(f, k), pct(i, k)
return r2(np.nanmax(np.abs(b / np.where(a > 0, a, np.nan) - 1)) * 100, 1)
deg[str(y)] = {'sect_pp': r2(abs(pct(f, 'sect_day') - pct(i, 'sect_day')), 3), 'sun_house_pp': err('sun_house'),
'mars_house_pp': err('mars_house'), 'moon_sign_pp': err('moon_sign'), 'asc_sign_pp': err('asc_sign'),
'sun_asc_rel_pct': rel('sun_asc'), 'smv_upper_pp': r2(abs(pct(f, 'smv_all_upper') - pct(i, 'smv_all_upper')), 3)}
# Brazil first vs last year: how much of the rise in day charts is the larger caesarean share
# (composition) and how much is each delivery type moving into the day (shift-share, midpoint weights).
b0, b1 = (next(r for r in series if r['country'] == 'BR' and r['year'] == y) for y in (BR_YEARS[0], BR_YEARS[-1]))
c0, c1 = b0['caesarean'] / 100, b1['caesarean'] / 100
comp = (c1 - c0) * ((b0['day_caesarean'] - b0['day_vaginal']) + (b1['day_caesarean'] - b1['day_vaginal'])) / 2
within = ((c0 + c1) / 2) * (b1['day_caesarean'] - b0['day_caesarean']) + (1 - (c0 + c1) / 2) * (b1['day_vaginal'] - b0['day_vaginal'])
cm = (c0 + c1) / 2
within_c = cm * (b1['day_caesarean'] - b0['day_caesarean'])
within_v = (1 - cm) * (b1['day_vaginal'] - b0['day_vaginal'])
total = b1['day']['B4'] - b0['day']['B4']
# The share depends on the weights: holding first-year or last-year day shares fixed instead.
comp_first = (c1 - c0) * (b0['day_caesarean'] - b0['day_vaginal'])
comp_last = (c1 - c0) * (b1['day_caesarean'] - b1['day_vaginal'])
br_decomposition = {'from': BR_YEARS[0], 'to': BR_YEARS[-1], 'day_change_pp': r2(total),
'composition_pp': r2(comp), 'within_types_pp': r2(within),
'within_caesarean_pp': r2(within_c), 'within_vaginal_pp': r2(within_v),
'composition_share_pct': r2(comp / (comp + within) * 100, 1),
'composition_share_first_year_weights_pct': r2(comp_first / total * 100, 1),
'composition_share_last_year_weights_pct': r2(comp_last / total * 100, 1)}
mars = json.loads((COUNTS / 'MARS_MODEL.json').read_text()) if (COUNTS / 'MARS_MODEL.json').exists() else None
jpw = load_counts('JP_WEIGHTS_TEST') if (COUNTS / 'JP_WEIGHTS_TEST.json').exists() else None
if jpw is not None:
jpw = {k: ({a: r2(b, 3) for a, b in v.items()} if isinstance(v, dict) else v) for k, v in jpw.items()}
corr = float(st[['day', 'ces']].corr().iloc[0, 1])
data = {
'generated_by': 'scripts/research/birth_sky.py', 'signs': SIGNS, 'rungs': RUNGS,
'selftest': selftest(2000),
'totals': {c: {'years': [ys[0], ys[-1]], 'births': pooled[c]['births']} for c, ys in years.items()},
'total_births': sum(pooled[c]['births'] for c in years),
'series': series, 'br_decomposition': br_decomposition, 'br_states': {'points': len(st), 'corr_day_caesarean': r2(corr, 3), 'rows': states},
'pooled': pooled, 'hour_profiles_pct': hours, 'us_out_of_hospital': us_out_of_hospital,
'degradation_test': deg, 'jp_weights_test': jpw, 'mars_model': mars,
'csv_rows': len(rows),
}
# Pages «planet in house» (natal SEO leaves) read their OWN small file: kept out of the
# study JSON so that neither the articles' lastmod nor their bundle moves with it.
house_frequency = {
c: {'years': [ys[0], ys[-1]],
'bodies': {b: {'real': r2(pooled_pct(c, ys, f'{b}_house', 'B4_real')),
'even': r2(pooled_pct(c, ys, f'{b}_house', 'B2_real_dates'))}
for b in ('sun', 'moon', 'mercury', 'venus', 'mars', 'jupiter', 'saturn')}}
for c, ys in years.items()}
JSON_OUT.parent.mkdir(parents=True, exist_ok=True)
if HOUSE_JSON_OUT is not None:
HOUSE_JSON_OUT.write_text(json.dumps(house_frequency, ensure_ascii=False, separators=(',', ':')) + '\n')
JSON_OUT.write_text(json.dumps(data, ensure_ascii=False, indent=1) + '\n')
if FACTS_OUT is not None:
write_backend_facts(counts, years, pooled)
if SCRIPT_COPY is not None:
SCRIPT_COPY.write_bytes(Path(__file__).read_bytes())
log(f'wrote {JSON_OUT} and {CSV_OUT} ({len(rows):,} rows)')
def write_backend_facts(counts, years, pooled):
"""Compact facts for backend/app/services/birth_timing.py (AI astrologer, natal hint).
Per country-year: day-chart share (real, even times) and the Sun's house shares in both
house systems the product offers (Placidus, whole sign); pooled Sun–Ascendant pairs per
10,000; tropical rising-sign shares by integer latitude from the rising-sign study
(astronomy: over a full year the hour of birth barely moves them — shown in this study).
"""
per_year = {}
for (c, y), obj in sorted(counts.items()):
R = obj['rungs']
per_year.setdefault(c, {})[str(y)] = {
'day': r2(pct(R['B4_real'], 'sect_day')), 'day_even': r2(pct(R['B2_real_dates'], 'sect_day')),
'sun_house': r2(pct(R['B4_real'], 'sun_house')), 'sun_house_even': r2(pct(R['B2_real_dates'], 'sun_house')),
'sun_whole': r2(pct(R['B4_real'], 'sun_whole')), 'sun_whole_even': r2(pct(R['B2_real_dates'], 'sun_whole')),
}
rising = json.loads(RISING_JSON.read_text())
facts = {
'generated_by': 'scripts/research/birth_sky.py export',
'research_path': '/research/' + SLUG,
'coverage': {c: [ys[0], ys[-1]] for c, ys in years.items()},
'per_year': per_year,
'sun_asc_per10k': {c: {'real': pooled[c]['sun_asc_per10k'], 'even': pooled[c]['sun_asc_B2_per10k']} for c in years},
'rising_by_latitude': {'latitudes': [row['latitude'] for row in rising['latitudes']],
'tropical': [row['tropical']['shares'] for row in rising['latitudes']]},
}
FACTS_OUT.parent.mkdir(parents=True, exist_ok=True)
FACTS_OUT.write_text(json.dumps(facts, ensure_ascii=False, separators=(',', ':')) + '\n')
log(f'wrote {FACTS_OUT} ({FACTS_OUT.stat().st_size:,} bytes)')
def main():
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
ap.add_argument('step', choices=['fetch', 'fetch-br', 'fetch-jp', 'fetch-us', 'compute', 'compute-br', 'compute-jp',
'compute-us', 'compute-us-groups', 'jp-weights', 'degrade', 'mars-model', 'export', 'selftest'])
ap.add_argument('years', nargs='*', type=int)
a = ap.parse_args()
ys = lambda default: a.years or default
if a.step in ('fetch', 'fetch-br'): fetch_br(ys(BR_YEARS))
if a.step in ('fetch', 'fetch-jp'): fetch_jp()
if a.step in ('fetch', 'fetch-us'): fetch_us(ys(US_YEARS))
if a.step in ('compute', 'compute-br'):
for y in ys(BR_YEARS): compute_br(y)
if a.step in ('compute', 'compute-jp'): compute_jp()
if a.step in ('compute', 'jp-weights'): jp_weights_test()
if a.step in ('compute', 'compute-us'):
for y in ys(US_YEARS): compute_us(y)
if a.step == 'compute-us-groups':
for y in ys(US_YEARS): compute_us(y, only_groups=True)
if a.step in ('compute', 'degrade'):
for y in ys([2018, 2024]): degrade(y)
if a.step in ('compute', 'mars-model'): mars_model()
if a.step == 'export': export()
if a.step == 'selftest': log(selftest())
return 0
if __name__ == '__main__':
sys.exit(main())