File size: 29,785 Bytes
ebaf8c2
c140718
 
 
 
 
 
 
 
 
ebaf8c2
 
 
 
 
 
 
c140718
 
6055ba8
c140718
 
 
 
 
 
 
 
 
ebaf8c2
c140718
 
 
 
 
 
 
 
 
 
 
 
ebaf8c2
c140718
 
 
 
 
4ccac56
 
 
 
 
 
 
6055ba8
 
 
 
 
 
 
 
 
 
 
 
 
 
4ccac56
d624b50
ebaf8c2
 
 
 
 
 
 
 
c140718
 
ebaf8c2
c140718
 
ebaf8c2
 
 
 
c140718
ebaf8c2
 
c140718
 
 
 
 
 
 
 
 
 
ebaf8c2
 
 
 
 
c140718
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ebaf8c2
 
c140718
 
 
 
 
 
ebaf8c2
c140718
 
 
ebaf8c2
4ccac56
6055ba8
 
ebaf8c2
c140718
ebaf8c2
c140718
 
 
ebaf8c2
 
c140718
ebaf8c2
 
c140718
ebaf8c2
c140718
 
ebaf8c2
c140718
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d624b50
 
c140718
 
 
4ccac56
6055ba8
 
 
 
 
 
 
 
 
 
 
 
 
 
4ccac56
6055ba8
 
 
4ccac56
 
 
 
 
 
 
 
 
 
 
 
 
 
6055ba8
 
 
 
 
 
 
 
 
 
 
 
 
 
ebaf8c2
c140718
 
 
 
ebaf8c2
c140718
 
 
 
 
d624b50
 
 
 
 
 
4ccac56
 
 
 
ebaf8c2
4ccac56
 
 
 
 
 
 
 
 
 
 
 
c140718
 
 
 
 
4ccac56
6055ba8
 
 
 
ebaf8c2
 
 
 
 
 
 
 
 
 
 
c140718
ebaf8c2
 
 
 
 
c140718
ebaf8c2
 
c140718
ebaf8c2
 
 
 
 
 
c140718
 
 
 
 
 
 
ebaf8c2
c140718
 
 
 
 
 
 
 
d624b50
 
4ccac56
6055ba8
ebaf8c2
 
c140718
ebaf8c2
c140718
ebaf8c2
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
#!/usr/bin/env python3
"""Reproducible public-data snapshot for ASCEND (all World Bank economies); no synthetic fallback.

Usage:
  python3 refresh_data.py            # rebuild data.json from live public APIs
  python3 refresh_data.py --validate # re-validate the committed data.json

Optional: if geo/access_summary.json exists (produced by access_pipeline.py), its
modelled geographic-access indicators are merged as a separate, clearly labelled group.
"""
from pathlib import Path
import argparse, concurrent.futures, datetime, gzip, html, json, math, re, time
import urllib.request, urllib.parse

BASE = Path(__file__).resolve().parent
DEST = BASE / 'data.json'
RAW = BASE / 'data_raw'
ACCESS = BASE / 'geo' / 'access_summary.json'
YEARS = (2000, 2024)
VERSION = '2.3.0'

WB_NOTE = 'World Bank open data (CC BY 4.0); indicator attribution retained. Check dataset terms before redistribution.'
WHO_NOTE = 'WHO Global Health Observatory open data (CC BY-NC-SA 3.0 IGO); attribution retained.'
EPMC_NOTE = 'Europe PMC bibliographic service. Article and abstract rights remain with their respective holders; linked records retain identifiers and attribution.'
CT_NOTE = 'ClinicalTrials.gov public registry data; sponsor submissions are not independent verification of efficacy. See ClinicalTrials.gov terms and conditions.'
GOV_NOTE = 'Official Brazilian Ministry of Health public API. Connector proof only; schema field names retained, no individual patient records distributed.'

# key -> definition. `source` is 'worldbank' or 'who'. `scale` guides chart axes. `target` is the
# Lancet Commission on Global Surgery 2030 target where one exists (Meara et al., Lancet 2015).
INDICATORS = {
 'spending':     {'id':'SH.XPD.CHEX.PC.CD','source':'worldbank','label':'Current health expenditure per capita','short':'Health spending / person','unit':'current US$','decimals':0,'scale':'log','better':'higher'},
 'spending_ppp': {'id':'SH.XPD.CHEX.PP.CD','source':'worldbank','label':'Current health expenditure per capita, PPP','short':'Health spending / person (PPP)','unit':'international $','decimals':0,'scale':'log','better':'higher'},
 'physicians':   {'id':'SH.MED.PHYS.ZS','source':'worldbank','label':'Physicians','short':'Physicians','unit':'per 1,000 people','decimals':2,'scale':'linear','better':'higher'},
 'life':         {'id':'SP.DYN.LE00.IN','source':'worldbank','label':'Life expectancy at birth','short':'Life expectancy','unit':'years','decimals':1,'scale':'linear','better':'higher'},
 'oop':          {'id':'SH.XPD.OOPC.CH.ZS','source':'worldbank','label':'Out-of-pocket expenditure','short':'Out-of-pocket share','unit':'% of current health expenditure','decimals':1,'scale':'linear','better':'lower'},
 'population':   {'id':'SP.POP.TOTL','source':'worldbank','label':'Population, total','short':'Population','unit':'people','decimals':0,'scale':'log','better':None},
 'sao':          {'id':'SH.MED.SAOP.P5','source':'worldbank','label':'Specialist surgical workforce','short':'Surgical workforce (SAO)','unit':'per 100,000 population','decimals':1,'scale':'linear','better':'higher','target':20,'targetLabel':'LCoGS 2030 target: at least 20 per 100,000'},
 'volume':       {'id':'SH.SGR.PROC.P5','source':'worldbank','label':'Number of surgical procedures','short':'Surgical volume','unit':'per 100,000 population','decimals':0,'scale':'linear','better':'higher','target':5000,'targetLabel':'LCoGS 2030 target: at least 5,000 per 100,000'},
 'catastrophic': {'id':'SH.SGR.CRSK.ZS','source':'worldbank','label':'Risk of catastrophic expenditure for surgical care','short':'Catastrophic cost risk (surgery)','unit':'% of people at risk','decimals':1,'scale':'linear','better':'lower','target':0,'targetLabel':'LCoGS 2030 target: 100% protection (0% at risk)'},
 'impoverishing':{'id':'SH.SGR.IRSK.ZS','source':'worldbank','label':'Risk of impoverishing expenditure for surgical care','short':'Impoverishing cost risk (surgery)','unit':'% of people at risk','decimals':1,'scale':'linear','better':'lower','target':0,'targetLabel':'LCoGS 2030 target: 100% protection (0% at risk)'},
 'uhc':          {'id':'UHC_INDEX_REPORTED','source':'who','label':'UHC service coverage index (SDG 3.8.1)','short':'UHC service coverage index','unit':'index 0–100','decimals':0,'scale':'linear','better':'higher'},
 'cata10':       {'id':'FINPROTECTION_CATA_TOT_10_POP','source':'who','dim1':'RESIDENCEAREATYPE_TOTL','label':'Population with household health spending above 10% of total consumption or income (SDG 3.8.2)','short':'Catastrophic health spending (>10%)','unit':'% of population','decimals':1,'scale':'linear','better':'lower'},
}
GROUPS = [
 {'id':'context','label':'System context','indicators':['spending','spending_ppp','physicians','life','oop','population'],'note':'National averages; contextual observations, not measures of surgical access.'},
 {'id':'surgical','label':'Surgical system (LCoGS indicators)','indicators':['sao','volume','catastrophic','impoverishing'],'note':'Four of the six Lancet Commission on Global Surgery indicators as compiled in World Development Indicators. Sparse and irregular reporting years; the latest value can be a decade old.'},
 {'id':'coverage','label':'Coverage & financial protection','indicators':['uhc','cata10'],'note':'SDG 3.8 monitoring indicators from WHO and World Bank.'},
]
# UN System Data Commons (UNSD) counterparts used for source triangulation and live refresh. factor converts to ASCEND units.
UNDC_REST = 'https://unsd-datacommons.gcp.un-icc.cloud/core/api/v2'
UNDC_NOTE = 'UN System Data Commons (United Nations Statistics Division) knowledge graph; the underlying series retain their publishers\' terms (WHO, UN SDG Global Database). Retrieved via the public V2 observation endpoint.'
UNDC = {
 'uhc':        {'variable':'undata/sdg/SH_ACS_UNHC_25','factor':1,'unitNote':'index 0–100, SDG Global Database (2025 methodology)'},
 'cata10':     {'variable':'undata/who/FINP_CHE_SH.HE_THRESHOLD--T_10','factor':1,'unitNote':'% of population, WHO financial-protection series'},
 'physicians': {'variable':'undata/sdg/SH_MED_DEN.OCCUPATION--ISCO08_221','factor':0.1,'unitNote':'SDG 3.c.1 medical doctors per 10,000, converted to per 1,000'},
 'spending':   {'variable':'undata/who/GHED_PER_CAP.HE_CATEGORY--CHE','factor':1,'unitNote':'WHO Global Health Expenditure Database, current health expenditure per capita, current US$'},
 'oop':        {'variable':'undata/who/GHED_SH.HE_CATEGORY--OOP_HE__REF_EXP_CAT--CHE','factor':1,'unitNote':'WHO GHED, out-of-pocket expenditure as % of current health expenditure'},
 'life':       {'variable':'undata/unicef/DM_LIFE_EXP','factor':1,'unitNote':'UNICEF data warehouse (UN WPP basis), both sexes; the series extends to the current WPP estimate year'},
 'population': {'variable':'undata/unicef/DM_POP','factor':1,'unitNote':'UNICEF data warehouse (UN WPP basis), total population'},
 # Derived cross-check only (no single Commons variable): WHO NHWA head-counts of surgeons, anaesthesiologists and
 # obstetricians divided by World Bank population of the same year, per 100,000. One observation per economy, 2009–2015.
 'sao':        {'derived':True,'variables':['undata/who/HWF_CNT.HWF_TYPE--SURGEON','undata/who/HWF_CNT.HWF_TYPE--ANAESTH','undata/who/HWF_CNT.HWF_TYPE--OBSTETRICIAN'],'factor':1,'unitNote':'sum of WHO head-counts / World Bank population × 100,000; counts are single 2009–2015 observations'},
}
# Indicators searched in the Commons (MCP search_indicators anchored on Brazil and Kenya, 20 Oct 2026) with no counterpart.
UNDC_NONE = {
 'spending_ppp':'GHED per-capita slice is published in current US$ only; no PPP series in this deployment.',
 'volume':'No surgical-volume series; nearest hits are trachoma surgeries and caesarean-section shares, which are not comparable.',
 'catastrophic':'No surgery-specific catastrophic-expenditure series; the Commons holds general catastrophic (FINP_CHE_SH) and impoverishing (FINP_IMP_SH) health-spending shares.',
 'impoverishing':'No surgery-specific impoverishing-expenditure series; general FINP_IMP_SH poverty-line series exist but measure all health spending.',
}
ACCESS_GROUP = {'id':'access','label':'Geographic access (modelled)','indicators':['access2h','access1h','traveltime'],'note':'Population-weighted travel time to the nearest healthcare facility, computed by ASCEND at 1 km from the Malaria Atlas Project 2019 travel-time surfaces (motorised and walking-only) and WorldPop 2020 counts. Any facility, not Bellwether surgical capacity: a modelled upper bound on geographic surgical access.'}


def utcnow():
    return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds')

def fetch(job):
    name,url,note = job
    record = {'id':name,'name':name.replace('_',' ').title(),'url':url,'accessedAt':utcnow(),'licenseNote':note}
    attempts=6 if 'ghoapi' in url else 4
    for attempt in range(attempts):
        try:
            req = urllib.request.Request(url,headers={'User-Agent':'ASCEND-research-prototype/2.0','Accept':'application/json','Accept-Encoding':'gzip'})
            with urllib.request.urlopen(req,timeout=90) as response:
                body=response.read()
                if body[:2] == b'\x1f\x8b': body=gzip.decompress(body)
                payload=json.loads(body.decode('utf-8'))
                record.update(status='success',httpStatus=response.status,bytes=len(body),attempts=attempt+1)
                (RAW/f'{name}.json').write_text(json.dumps(payload,ensure_ascii=False))
                return name,payload,record
        except Exception as exc:
            record.update(status='failed',error=str(exc)[:300],attempts=attempt+1)
            time.sleep(2+3*attempt)
    cached=RAW/f'{name}.json'
    if cached.exists():
        try:
            payload=json.loads(cached.read_text())
            record.update(status='cached',note='Live retrieval failed; the previously retrieved raw response in data_raw/ was reused unchanged. Timestamp is the file modification time.',
                          accessedAt=datetime.datetime.fromtimestamp(cached.stat().st_mtime,datetime.timezone.utc).isoformat(timespec='seconds'))
            return name,payload,record
        except Exception: pass
    return name,None,record

def clean_text(text):
    return html.unescape(re.sub('<[^>]+>', ' ', text or '')).strip()

def wb_rows(payload):
    return payload[1] if isinstance(payload,list) and len(payload)>1 and isinstance(payload[1],list) else []

def to_series(values):
    """{year:value} -> sorted [[year,value],...] with nulls dropped and finite check."""
    out=[]
    for year in range(YEARS[0],YEARS[1]+1):
        v=values.get(year)
        if v is None: continue
        v=float(v)
        if not math.isfinite(v): continue
        out.append([year,round(v,4)])
    return out

def validate(data):
    assert data['kind'] == 'observed/retrieved'
    assert data['version'] == VERSION
    ids=[c['id'] for c in data['countries']]
    assert len(ids)==len(set(ids)) and len(ids) >= 190, len(ids)
    keys=set(data['indicators'])
    for g in data['indicatorGroups']:
        assert set(g['indicators']) <= keys
    observations=0; nonempty=0
    for c in data['countries']:
        assert re.fullmatch(r'[A-Z]{3}',c['id']) and c['name'] and c['region'] and c['income']
        assert c['lat'] is None or -90 <= c['lat'] <= 90
        assert c['lon'] is None or -180 <= c['lon'] <= 180
        assert set(c['series']) == keys
        for key,series in c['series'].items():
            years=[y for y,_ in series]
            assert years==sorted(years) and len(set(years))==len(years)
            assert all(YEARS[0] <= y <= YEARS[1] for y in years)
            assert all(isinstance(v,(int,float)) and math.isfinite(v) for _,v in series)
            observations+=len(series); nonempty+=bool(series)
    for source in data['sources']:
        assert source['url'].startswith('https://') and source['accessedAt'] and source['licenseNote']
    assert data['evidence']['classification'] == 'related literature; not model effect estimates'
    for p in data['evidence']['publications']:
        assert p['title'] and p['url'].startswith('https://')
    assert data['trials']['classification'] == 'registered studies; not evidence of efficacy or surgery relevance'
    for p in data['trials']['studies']:
        assert p['nctId'].startswith('NCT') and p['url'].startswith('https://clinicaltrials.gov/study/')
    for c in data['connectorProofs']:
        assert 'schemaFields' in c and 'patientData' not in c
    coverage={k:sum(1 for c in data['countries'] if c['series'][k]) for k in keys}
    return {'countries':len(ids),'indicators':len(keys),'series':len(ids)*len(keys),'nonEmptySeries':nonempty,'observations':observations,
            'countriesWithAnyValue':coverage,'publications':len(data['evidence']['publications']),'trials':len(data['trials']['studies'])}


def build():
    RAW.mkdir(parents=True,exist_ok=True)
    jobs=[('worldbank_countries','https://api.worldbank.org/v2/country?format=json&per_page=400',WB_NOTE)]
    for key,meta in INDICATORS.items():
        if meta['source']=='worldbank':
            jobs.append((f'worldbank_{key}',f"https://api.worldbank.org/v2/country/all/indicator/{meta['id']}?format=json&date={YEARS[0]}:{YEARS[1]}&per_page=20000",WB_NOTE))
        else:
            jobs.append((f'who_{key}',f"https://ghoapi.azureedge.net/api/{meta['id']}",WHO_NOTE))
    queries={
      'europe_pmc_access':'(TITLE_ABS:"surgical access" OR TITLE_ABS:"access to surgery" OR TITLE_ABS:"access to surgical care") AND (TITLE_ABS:"global surgery" OR TITLE_ABS:"health system" OR TITLE_ABS:"low- and middle-income")',
      'europe_pmc_capacity':'(TITLE_ABS:"surgical capacity" OR TITLE_ABS:"surgical workforce") AND (TITLE_ABS:financing OR TITLE_ABS:access OR TITLE_ABS:"health system")',
      'europe_pmc_indicators':'TITLE_ABS:"Lancet Commission on Global Surgery" AND TITLE_ABS:indicators'
    }
    for key,m in UNDC.items():
        for i,var in enumerate(m.get('variables') or [m['variable']]):
            jobs.append((f'undc_{key}' if not m.get('derived') else f'undc_{key}_{i}',UNDC_REST+'/observation?'+urllib.parse.urlencode([('variable.dcids',var),('entity.expression','Earth<-containedInPlace+{typeOf:Country}'),('date',''),('select','entity'),('select','variable'),('select','value'),('select','date')]),UNDC_NOTE))
    for key,q in queries.items():
        jobs.append((key,'https://www.ebi.ac.uk/europepmc/webservices/rest/search?'+urllib.parse.urlencode({'query':q,'format':'json','pageSize':6,'resultType':'core','sort':'CITED desc'}),EPMC_NOTE))
    jobs.extend([
      ('demas_cnes','https://apidadosabertos.saude.gov.br/cnes/estabelecimentos?codigo_uf=35&limit=1&offset=0',GOV_NOTE),
      ('demas_sih','https://apidadosabertos.saude.gov.br/assistencia-a-saude/sih-procedimentos-hospitalares?nu_ano=2023&limit=1&offset=0',GOV_NOTE),
      ('clinical_trials','https://clinicaltrials.gov/api/v2/studies?'+urllib.parse.urlencode({'query.term':'AREA[BriefTitle]("surgical access" OR "access to surgery" OR "access to surgical care" OR "global surgery" OR "surgical capacity" OR "surgical workforce" OR "surgical care delivery" OR "surgical outreach") AND AREA[StudyType]INTERVENTIONAL','pageSize':6,'format':'json'}),CT_NOTE)
    ])
    responses={}; sources=[]
    with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
        for name,payload,record in pool.map(fetch,jobs):
            responses[name]=payload;sources.append(record)
            print(name,record['status'],record.get('bytes',''),flush=True)
    (RAW/'request_manifest.json').write_text(json.dumps(sources,indent=2))
    metadata=wb_rows(responses['worldbank_countries'])
    if not metadata:
        raise RuntimeError('Country metadata unavailable; no substitute snapshot was fabricated. Inspect raw request manifest.')
    economies=[m for m in metadata if m.get('region',{}).get('value') and m['region']['value']!='Aggregates']
    # indicator tables keyed by iso3
    tables={}
    for key,meta in INDICATORS.items():
        if meta['source']=='worldbank':
            rows=wb_rows(responses.get(f'worldbank_{key}'))
            table={}
            for row in rows:
                iso=row.get('countryiso3code')
                if not iso or row.get('value') is None: continue
                table.setdefault(iso,{})[int(row['date'])]=row['value']
            tables[key]=(table,'success' if rows else 'unavailable',f'worldbank_{key}')
        else:
            payload=responses.get(f'who_{key}') or {}
            table={}
            for row in payload.get('value',[]):
                if row.get('SpatialDimType')!='COUNTRY' or row.get('NumericValue') is None: continue
                if (row.get('Dim1') or None)!=meta.get('dim1'): continue
                table.setdefault(row['SpatialDim'],{})[int(row['TimeDim'])]=row['NumericValue']
            tables[key]=(table,'success' if table else 'unavailable',f'who_{key}')
    access=None
    if ACCESS.exists():
        access=json.loads(ACCESS.read_text())
        for k,meta in access['indicators'].items():
            INDICATORS[k]={**meta,'source':'ascend-geo'}
        order=['access2h','access1h','traveltime','access2h_walk','access1h_walk','traveltime_walk']
        GROUPS.append({**ACCESS_GROUP,'indicators':[k for k in order if k in access['indicators']]})
        sources.append({'id':'ascend_geo_access','name':'ASCEND geographic-access pipeline','url':access['provenance']['methodUrl'],'accessedAt':access['generatedAt'],'licenseNote':access['provenance']['licenseNote'],'status':'computed','note':access['provenance']['summary']})
        for s in access['provenance']['inputs']:
            sources.append({'id':s['id'],'name':s['name'],'url':s['url'],'accessedAt':access['generatedAt'],'licenseNote':s['licenseNote'],'status':'success'})
    undc_tables={}
    def undc_table(payload,var,factor):
        table={}
        for entity,rec in (payload.get('byVariable',{}).get(var,{}).get('byEntity',{}) or {}).items():
            mt=re.fullmatch(r'country/([A-Z]{3})',entity)
            if not mt: continue
            facets=rec.get('orderedFacets') or []
            if not facets: continue
            vals={}
            for o in facets[0].get('observations',[]):
                try: y=int(str(o['date'])[:4]); v=float(o['value'])*factor
                except Exception: continue
                if math.isfinite(v): vals[y]=v
            if vals: table[mt.group(1)]=sorted((y,round(v,4)) for y,v in vals.items())
        return table
    for key,m in UNDC.items():
        if m.get('derived'):
            parts=[undc_table(responses.get(f'undc_{key}_{i}') or {},var,1) for i,var in enumerate(m['variables'])]
            undc_tables[key]=('derived',parts); continue
        payload=responses.get(f'undc_{key}') or {}
        table={}
        for entity,rec in (payload.get('byVariable',{}).get(m['variable'],{}).get('byEntity',{}) or {}).items():
            mt=re.fullmatch(r'country/([A-Z]{3})',entity)
            if not mt: continue
            facets=rec.get('orderedFacets') or []
            if not facets: continue
            vals={}
            for o in facets[0].get('observations',[]):
                try: y=int(str(o['date'])[:4]); v=float(o['value'])*m['factor']
                except Exception: continue
                if math.isfinite(v): vals[y]=v
            if vals: table[mt.group(1)]=sorted((y,round(v,4)) for y,v in vals.items())
        undc_tables[key]=table
    for key,val in list(undc_tables.items()):
        if isinstance(val,tuple) and val[0]=='derived':
            parts=val[1]; table={}
            poptab=tables['population'][0]
            for iso in set.intersection(*[set(p) for p in parts]) if parts else set():
                years=set.intersection(*[{y for y,_ in p[iso]} for p in parts])
                out=[]
                for y in sorted(years):
                    pop=poptab.get(iso,{}).get(y)
                    if not pop: continue
                    total=sum(dict(p[iso])[y] for p in parts)
                    out.append((y,round(total/float(pop)*1e5,4)))
                if out: table[iso]=out
            undc_tables[key]=table
    countries=[]
    for meta in sorted(economies,key=lambda m:m['name']):
        iso=meta['id']
        c={'id':iso,'iso2':meta.get('iso2Code'),'name':meta['name'],'region':meta['region']['value'].strip(),'income':meta['incomeLevel']['value'].strip(),
           'capital':meta.get('capitalCity') or None,
           'lat':float(meta['latitude']) if meta.get('latitude') else None,'lon':float(meta['longitude']) if meta.get('longitude') else None,
           'series':{}}
        for key in INDICATORS:
            if key in tables:
                c['series'][key]=to_series(tables[key][0].get(iso,{}))
            elif access:
                rec=access['countries'].get(iso,{}); v=rec.get(key)
                # Small-island artefact guard: a microstate whose nearest mapped facility is across open water
                # (mean travel time > 4 h with < 100,000 people) reflects a gap in the facility database, not access.
                suffix='_walk' if key.endswith('_walk') else ''
                artefact=rec.get('popTotal2020',0)<100000 and (rec.get('traveltime'+suffix) or 0)>240
                c['series'][key]=[[access['referenceYear'],round(float(v),4)]] if v is not None and math.isfinite(float(v)) and not artefact else []
        c['crosscheck']={}
        for key,table in undc_tables.items():
            ser=table.get(iso)
            if ser: c['crosscheck'][key]=[list(ser[-1]),len(ser)]  # latest [year,value] and number of observations held by UN Data Commons
        countries.append(c)
    # agreement summary at the latest common year, per indicator
    agreement={}
    for key,table in undc_tables.items():
        n=agree=0;diffs=[]
        for c in countries:
            ser=table.get(c['id']);snap=dict(c['series'].get(key,[]))
            if not ser or not snap: continue
            common=[p for p in ser if p[0] in snap]
            if not common: continue
            y,v=common[-1];a=snap[y];n+=1;rel=abs(a-v)/max(abs(a),abs(v),1e-9);diffs.append(rel)
            if rel<=0.05: agree+=1
        agreement[key]={'economiesCompared':n,'withinFivePercent':agree,'medianRelativeDifference':round(sorted(diffs)[len(diffs)//2],4) if diffs else None,'undcEconomies':len(table)}
    indicator_defs={}
    for key,meta in INDICATORS.items():
        status=tables[key][1] if key in tables else ('computed' if access else 'unavailable')
        indicator_defs[key]={**meta,'sourceId':tables[key][2] if key in tables else 'ascend_geo_access','retrievalStatus':status,
                             'countriesWithData':sum(1 for c in countries if c['series'][key])}
        if key in UNDC:
            m=UNDC[key]; first=m.get('variable') or m['variables'][0]
            indicator_defs[key]['undc']={**m,'rest':UNDC_REST+'/node?'+urllib.parse.urlencode({'nodes':first,'property':'->name'}),'status':'success' if undc_tables.get(key) else 'unavailable','agreement':agreement.get(key)}
        elif key in UNDC_NONE:
            indicator_defs[key]['undc']={'status':'no-counterpart','note':UNDC_NONE[key]}
    publications=[]; seen=set()
    for key,q in queries.items():
        response=responses[key] or {}
        for p in response.get('resultList',{}).get('result',[]):
            ident=p.get('doi') or p.get('pmid') or p['id']
            if ident in seen: continue
            seen.add(ident)
            source=p.get('source','MED');id_=p['id']
            publications.append({'id':id_,'title':clean_text(p.get('title')),'authors':p.get('authorString',''),'year':int(p['pubYear']) if p.get('pubYear') else None,
              'doi':p.get('doi'),'pmid':p.get('pmid'),'url':f'https://europepmc.org/article/{source}/{id_}',
              'abstract':clean_text(p.get('abstractText')),'journal':p.get('journalInfo',{}).get('journal',{}).get('title',''),
              'citedBy':p.get('citedByCount'),
              'classification':'related literature; not model effect estimates','query':q,'sourceId':key})
    studies=[]
    for row in (responses['clinical_trials'] or {}).get('studies',[]):
        p=row['protocolSection']; ident=p['identificationModule']; design=p.get('designModule',{}); status=p.get('statusModule',{})
        locations=p.get('contactsLocationsModule',{}).get('locations',[])
        countries_in=sorted({loc.get('country') for loc in locations if loc.get('country')})
        studies.append({'nctId':ident['nctId'],'title':ident.get('briefTitle',''),'status':status.get('overallStatus',''),
          'phase':design.get('phases',[]),'studyType':design.get('studyType',''),'briefSummary':p.get('descriptionModule',{}).get('briefSummary',''),
          'url':'https://clinicaltrials.gov/study/'+ident['nctId'],'countries':countries_in,'locations':[{k:loc[k] for k in ['facility','city','country'] if k in loc} for loc in locations[:6]],
          'sourceId':'clinical_trials','lastUpdate':status.get('lastUpdatePostDateStruct',{}).get('date')})
    proofs=[]
    for name,key in [('demas_cnes','estabelecimentos'),('demas_sih','sih_procedimentos_hospitalares')]:
        response=responses[name] or {}; rows=response.get(key,[])
        proofs.append({'id':name,'label':'CNES establishment schema' if name=='demas_cnes' else 'SIH hospital-procedure schema',
          'status':'success' if rows else 'unavailable','schemaFields':sorted(rows[0].keys()) if rows else [],'sampleRowsRetrieved':len(rows),
          'sourceId':name,'note':'Connectivity and schema proof only (example national registry). One response row does not establish data completeness, historical coverage, surgical relevance, or analytic validity.'})
    data={'version':VERSION,'kind':'observed/retrieved','accessedAt':utcnow(),'yearRange':list(YEARS),
      'description':'All World Bank economies: contextual health-system indicators, Lancet Commission on Global Surgery indicators, SDG 3.8 coverage and protection indicators'+(', and ASCEND-modelled geographic access' if access else '')+'. Observational comparisons do not establish intervention effects.',
      'indicatorGroups':GROUPS,'indicators':indicator_defs,'countries':countries,
      'sources':sources,'apiRegistry':[{k:v for k,v in r.items() if k not in ['bytes']} for r in sources],
      'evidence':{'classification':'related literature; not model effect estimates','selection':'Three explicit Europe PMC queries on surgical access, capacity and LCoGS indicators, six most-cited results per query, deduplicated. Convenience discovery set, not a systematic review. No relevance or risk-of-bias adjudication.','status':'success' if publications else 'unavailable','publications':publications},
      'trials':{'classification':'registered studies; not evidence of efficacy or surgery relevance','selection':'First six ClinicalTrials.gov API results for interventional studies with surgical access, capacity, workforce or delivery terms in the title. No country filter; registry coverage of health-system interventions is thin.','status':'success' if studies else 'unavailable','studies':studies},
      'connectorProofs':proofs,
      'limitations':['All World Bank economies are included; small states and territories often lack observations and remain visibly missing.',
        'Latest values can refer to different years; the observation year is always shown next to the value.',
        'Expenditure is shown in current US dollars and, separately, in PPP international dollars; neither is inflation-adjusted across years.',
        'Missing values remain null; no interpolation, imputation or carry-forward in fixed-year mode.',
        'Lancet Commission on Global Surgery indicators are compiled from heterogeneous national reports with irregular years and definitions.',
        'National ecological indicators cannot estimate effects of a surgical intervention.',
        'Bibliographic and registry retrieval is not a systematic evidence synthesis.',
        'No patient-level records are included in this distributable snapshot.']+
        (['Geographic access is modelled from a travel-time surface to any healthcare facility and 2020 population counts; it is not the LCoGS Bellwether 2-hour indicator and overstates surgical access.',
          'Access values are omitted where fewer than half the population lies on valid travel-time pixels, and for microstates (<100,000 people) whose mean travel time exceeds four hours, which indicates missing facility points rather than inaccessibility.'] if access else [])}
    data['undc']={'rest':UNDC_REST,'mcp':'https://unsd-datacommons.gcp.un-icc.cloud/mcp','documentation':'https://projects.officialstatistics.org/undata2/undatacommons-mcp/','portal':'https://data.un.org/undatacommons/','agreement':agreement,
      'note':'Eight ASCEND indicators carry a UN System Data Commons counterpart (seven single variables plus a derived SAO density) (DCID in indicators[*].undc). The snapshot stores each economy\'s latest UN Data Commons value for triangulation; the app can also refresh these series live from the browser because the endpoint allows cross-origin requests.'}
    data['validation']=validate(data)
    DEST.parent.mkdir(parents=True,exist_ok=True)
    DEST.write_text(json.dumps(data,ensure_ascii=False,separators=(',',':')))
    print(json.dumps(data['validation'],indent=2))
    print('bytes',DEST.stat().st_size)

if __name__ == '__main__':
    parser=argparse.ArgumentParser();parser.add_argument('--validate',action='store_true');args=parser.parse_args()
    if args.validate: print(json.dumps(validate(json.loads(DEST.read_text())),indent=2))
    else: build()