File size: 21,307 Bytes
ebaf8c2
c140718
 
 
 
 
 
 
 
 
ebaf8c2
 
 
 
 
 
 
c140718
 
 
 
 
 
 
 
 
 
 
 
ebaf8c2
c140718
 
 
 
 
 
 
 
 
 
 
 
ebaf8c2
c140718
 
 
 
 
 
ebaf8c2
 
 
 
 
 
 
 
c140718
 
ebaf8c2
c140718
 
ebaf8c2
 
 
 
c140718
ebaf8c2
 
c140718
 
 
 
 
 
 
 
 
 
ebaf8c2
 
 
 
 
c140718
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ebaf8c2
 
c140718
 
 
 
 
 
ebaf8c2
c140718
 
 
ebaf8c2
 
c140718
ebaf8c2
c140718
 
 
ebaf8c2
 
c140718
ebaf8c2
 
c140718
ebaf8c2
c140718
 
ebaf8c2
c140718
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ebaf8c2
c140718
 
 
 
ebaf8c2
c140718
 
 
 
 
 
 
ebaf8c2
c140718
 
 
 
 
ebaf8c2
 
 
 
 
 
 
 
 
 
 
c140718
ebaf8c2
 
 
 
 
c140718
ebaf8c2
 
c140718
ebaf8c2
 
 
 
 
 
c140718
 
 
 
 
 
 
ebaf8c2
c140718
 
 
 
 
 
 
 
 
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
#!/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.0.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.'},
]
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 from the Malaria Atlas Project 2019 travel-time surface and WorldPop 2020 counts. Any facility, not Bellwether surgical capacity; a modelled upper bound on 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,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'}
        GROUPS.append(ACCESS_GROUP)
        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'})
    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:
                v=access['countries'].get(iso,{}).get(key)
                c['series'][key]=[[access['referenceYear'],round(float(v),4)]] if v is not None and math.isfinite(float(v)) else []
        countries.append(c)
    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])}
    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.'] if access else [])}
    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()