|
| 1 | +"""Build resources/istat_lookup.duckdb (table territorial_subdivisions) from CL_ITTER107 and ISTAT data. |
| 2 | +
|
| 3 | +Sources: |
| 4 | +- CL_ITTER107: fetched via mcp__istat__get_codelist_description (cached in cache/cache.db) |
| 5 | +- ISTAT municipalities CSV: https://www.istat.it/storage/codici-unita-amministrative/Elenco-comuni-italiani.csv |
| 6 | +- ISTAT capoluogo JSON: https://situas-servizi.istat.it/publish/reportspooljson?pfun=61&pdata=01/01/2048 |
| 7 | +
|
| 8 | +Output columns (table: territorial_subdivisions): |
| 9 | +- code: ISTAT territorial code (CL_ITTER107 for all levels; 6-digit numeric for comuni) |
| 10 | +- name_it: Italian name |
| 11 | +- level: italia | ripartizione | regione | provincia | comune |
| 12 | +- nuts_level: 0 | 1 | 2 | 3 | 4 |
| 13 | +- parent_code: code of the parent territorial unit (NULL for Italia) |
| 14 | +- capoluogo_provincia: True if the comune is a provincial/UTS capital (NULL for non-comuni) |
| 15 | +- capoluogo_regione: True if the comune is a regional capital (NULL for non-comuni) |
| 16 | +
|
| 17 | +Usage: |
| 18 | + python3 resources/build_territorial_subdivisions.py <itter107_json> [comuni_csv] |
| 19 | +""" |
| 20 | + |
| 21 | +import csv |
| 22 | +import json |
| 23 | +import re |
| 24 | +import sys |
| 25 | +import tempfile |
| 26 | +import urllib.request |
| 27 | +from pathlib import Path |
| 28 | + |
| 29 | +import duckdb |
| 30 | + |
| 31 | +# NUTS 2021 codes that DON'T follow simple ITH->ITD / ITI->ITE substitution |
| 32 | +# Maps NUTS2021 NUTS3 code -> CL_ITTER107 code |
| 33 | +NUTS2021_TO_ITTER_OVERRIDE = { |
| 34 | + 'ITC4C': 'ITC45', # Metro Milano -> Milano |
| 35 | + 'ITC4D': 'IT108', # Monza e della Brianza |
| 36 | + 'ITI35': 'IT109', # Fermo (must be before ITI->ITE rule) |
| 37 | + 'ITF46': 'ITF41', # Foggia |
| 38 | + 'ITF47': 'ITF42', # Bari / Citta Metropolitana |
| 39 | + 'ITF48': 'IT110', # Barletta-Andria-Trani |
| 40 | + 'ITG2D': 'ITG25', # Sassari |
| 41 | + 'ITG2E': 'ITG26', # Nuoro |
| 42 | + 'ITG2F': 'ITG27', # Cagliari |
| 43 | + 'ITG2G': 'ITG28', # Oristano |
| 44 | + 'ITG2H': 'IT111', # Sud Sardegna |
| 45 | +} |
| 46 | + |
| 47 | +# Special IT1XX province codes -> parent NUTS2 region code |
| 48 | +IT1XX_PARENTS = { |
| 49 | + 'IT108': 'ITC4', # Monza e della Brianza -> Lombardia |
| 50 | + 'IT109': 'ITE3', # Fermo -> Marche |
| 51 | + 'IT110': 'ITF4', # Barletta-Andria-Trani -> Puglia |
| 52 | + 'IT111': 'ITG2', # Sud Sardegna -> Sardegna |
| 53 | + 'IT113': 'ITG2', # Gallura Nord-Est Sardegna -> Sardegna |
| 54 | + 'IT119': 'ITG2', # Sulcis Iglesiente -> Sardegna |
| 55 | +} |
| 56 | + |
| 57 | +COMUNI_CSV_URL = 'https://www.istat.it/storage/codici-unita-amministrative/Elenco-comuni-italiani.csv' |
| 58 | +CAPOLUOGO_JSON_URL = 'https://situas-servizi.istat.it/publish/reportspooljson?pfun=61&pdata=01/01/2048' |
| 59 | + |
| 60 | +OUTPUT_PATH = Path(__file__).parent / 'istat_lookup.duckdb' |
| 61 | + |
| 62 | + |
| 63 | +def nuts2021_to_itter(code: str) -> str: |
| 64 | + """Convert NUTS 2021 NUTS3 code to CL_ITTER107 equivalent.""" |
| 65 | + if code in NUTS2021_TO_ITTER_OVERRIDE: |
| 66 | + return NUTS2021_TO_ITTER_OVERRIDE[code] |
| 67 | + if code.startswith('ITH'): |
| 68 | + return 'ITD' + code[3:] |
| 69 | + if code.startswith('ITI'): |
| 70 | + return 'ITE' + code[3:] |
| 71 | + return code |
| 72 | + |
| 73 | + |
| 74 | +def load_itter107(path: str) -> dict[str, str]: |
| 75 | + """Load CL_ITTER107 codelist from a JSON file (MCP tool result format).""" |
| 76 | + with open(path, encoding='utf-8') as f: |
| 77 | + data = json.load(f) |
| 78 | + text = data[0]['text'] |
| 79 | + entries = re.findall(r'\{[^{}]+\}', text) |
| 80 | + codes = {} |
| 81 | + for e in entries: |
| 82 | + cm = re.search(r'"code":\s*"([^"]+)"', e) |
| 83 | + di = re.search(r'"description_it":\s*"([^"]+)"', e) |
| 84 | + if cm and di: |
| 85 | + codes[cm.group(1)] = di.group(1) |
| 86 | + return codes |
| 87 | + |
| 88 | + |
| 89 | +def download_comuni_csv() -> str: |
| 90 | + """Download ISTAT municipalities CSV to a temp file, return path.""" |
| 91 | + tmp = tempfile.NamedTemporaryFile(suffix='_comuni_istat.csv', delete=False) |
| 92 | + print(f'Downloading {COMUNI_CSV_URL}...') |
| 93 | + urllib.request.urlretrieve(COMUNI_CSV_URL, tmp.name) |
| 94 | + utf8_path = tmp.name + '_utf8.csv' |
| 95 | + with open(tmp.name, encoding='latin1') as f: |
| 96 | + content = f.read() |
| 97 | + with open(utf8_path, 'w', encoding='utf-8') as f: |
| 98 | + f.write(content) |
| 99 | + return utf8_path |
| 100 | + |
| 101 | + |
| 102 | +def download_capoluogo_json() -> dict[str, tuple[bool, bool]]: |
| 103 | + """Download capoluogo flags from ISTAT JSON endpoint. |
| 104 | +
|
| 105 | + Returns: |
| 106 | + Dict mapping 6-digit comune code -> (capoluogo_provincia, capoluogo_regione) |
| 107 | + """ |
| 108 | + print(f'Downloading {CAPOLUOGO_JSON_URL}...') |
| 109 | + with urllib.request.urlopen(CAPOLUOGO_JSON_URL) as resp: |
| 110 | + data = json.loads(resp.read().decode('utf-8')) |
| 111 | + result = {} |
| 112 | + for rec in data.get('resultset', []): |
| 113 | + code = str(rec.get('PRO_COM_T', '')).strip().zfill(6) |
| 114 | + if code: |
| 115 | + result[code] = (bool(rec.get('CC_UTS', 0)), bool(rec.get('CC_REG', 0))) |
| 116 | + print(f' Loaded capoluogo flags for {len(result)} comuni') |
| 117 | + return result |
| 118 | + |
| 119 | + |
| 120 | +def build_comuni_mappings(csv_path: str) -> tuple[dict, dict]: |
| 121 | + """Build comune_code->ITTER_nuts3 and storico->ITTER_nuts3 mappings.""" |
| 122 | + comune_to_nuts3 = {} |
| 123 | + storico_to_nuts3 = {} |
| 124 | + with open(csv_path, encoding='utf-8') as f: |
| 125 | + reader = csv.reader(f, delimiter=';') |
| 126 | + next(reader) |
| 127 | + for row in reader: |
| 128 | + cod6 = row[15].strip().zfill(6) |
| 129 | + storico = row[2].strip().zfill(3) |
| 130 | + nuts3_2021 = row[22].strip() |
| 131 | + if nuts3_2021: |
| 132 | + itter = nuts2021_to_itter(nuts3_2021) |
| 133 | + if cod6: |
| 134 | + comune_to_nuts3[cod6] = itter |
| 135 | + if storico: |
| 136 | + storico_to_nuts3[storico] = itter |
| 137 | + return comune_to_nuts3, storico_to_nuts3 |
| 138 | + |
| 139 | + |
| 140 | +def build_duckdb(codes: dict, comune_to_nuts3: dict, storico_to_nuts3: dict, capoluogo: dict[str, tuple[bool, bool]] | None = None) -> None: |
| 141 | + """Build and write the DuckDB database.""" |
| 142 | + # Province: standard NUTS3 + letter-suffix + IT1XX |
| 143 | + nuts3_codes = {k: v for k, v in codes.items() if re.match(r'^IT[A-Z][0-9]{2}$', k)} |
| 144 | + letter_suffix = {k: v for k, v in codes.items() if re.match(r'^IT[A-Z][0-9][A-Z]$', k)} |
| 145 | + it1xx = {k: v for k, v in codes.items() if re.match(r'^IT1[0-9]{2}$', k)} |
| 146 | + all_province_codes = {**nuts3_codes, **letter_suffix, **it1xx} |
| 147 | + |
| 148 | + rows = [] |
| 149 | + |
| 150 | + rows.append(('IT', 'Italia', 'italia', 0, None, None, None)) |
| 151 | + |
| 152 | + for k, v in {'ITC': 'Nord-ovest', 'ITD': 'Nord-est', 'ITE': 'Centro', 'ITF': 'Sud', 'ITG': 'Isole'}.items(): |
| 153 | + rows.append((k, v, 'ripartizione', 1, 'IT', None, None)) |
| 154 | + |
| 155 | + for k, v in codes.items(): |
| 156 | + if re.match(r'^IT[A-Z][0-9]$', k): |
| 157 | + rows.append((k, v, 'regione', 2, k[:3], None, None)) |
| 158 | + |
| 159 | + for k, v in all_province_codes.items(): |
| 160 | + parent = IT1XX_PARENTS.get(k, k[:4]) |
| 161 | + rows.append((k, v, 'provincia', 3, parent, None, None)) |
| 162 | + |
| 163 | + no_parent = 0 |
| 164 | + for k, v in codes.items(): |
| 165 | + if re.match(r'^[0-9]{6}$', k): |
| 166 | + parent = comune_to_nuts3.get(k) or storico_to_nuts3.get(k[:3]) |
| 167 | + if not parent: |
| 168 | + no_parent += 1 |
| 169 | + cap_prov, cap_reg = capoluogo.get(k, (False, False)) if capoluogo else (False, False) |
| 170 | + rows.append((k, v, 'comune', 4, parent, cap_prov, cap_reg)) |
| 171 | + |
| 172 | + # Remove existing db if present |
| 173 | + if OUTPUT_PATH.exists(): |
| 174 | + OUTPUT_PATH.unlink() |
| 175 | + |
| 176 | + conn = duckdb.connect(str(OUTPUT_PATH)) |
| 177 | + conn.execute(''' |
| 178 | + CREATE TABLE territorial_subdivisions ( |
| 179 | + code VARCHAR NOT NULL, |
| 180 | + name_it VARCHAR NOT NULL, |
| 181 | + level VARCHAR NOT NULL, |
| 182 | + nuts_level TINYINT, |
| 183 | + parent_code VARCHAR, |
| 184 | + capoluogo_provincia BOOLEAN, |
| 185 | + capoluogo_regione BOOLEAN |
| 186 | + ) |
| 187 | + ''') |
| 188 | + conn.executemany( |
| 189 | + 'INSERT INTO territorial_subdivisions VALUES (?, ?, ?, ?, ?, ?, ?)', |
| 190 | + rows, |
| 191 | + ) |
| 192 | + conn.execute('CREATE INDEX idx_level ON territorial_subdivisions(level)') |
| 193 | + conn.execute('CREATE INDEX idx_parent ON territorial_subdivisions(parent_code)') |
| 194 | + conn.execute('CREATE INDEX idx_code ON territorial_subdivisions(code)') |
| 195 | + conn.close() |
| 196 | + |
| 197 | + size = OUTPUT_PATH.stat().st_size |
| 198 | + print(f'Written: {OUTPUT_PATH}') |
| 199 | + print(f'Rows: {len(rows)} | Size: {size:,} bytes ({size // 1024} KB)') |
| 200 | + print(f'Province: {len(all_province_codes)} | Comuni senza parent: {no_parent}') |
| 201 | + |
| 202 | + |
| 203 | +if __name__ == '__main__': |
| 204 | + itter107_path = sys.argv[1] if len(sys.argv) > 1 else None |
| 205 | + comuni_csv_path = sys.argv[2] if len(sys.argv) > 2 else None |
| 206 | + |
| 207 | + if not itter107_path: |
| 208 | + print('Usage: python3 build_territorial_subdivisions.py <itter107_json> [comuni_csv]') |
| 209 | + print(' itter107_json: MCP tool result JSON for CL_ITTER107 codelist') |
| 210 | + print(' comuni_csv: ISTAT municipalities CSV (latin1); downloaded if omitted') |
| 211 | + sys.exit(1) |
| 212 | + |
| 213 | + print('Loading CL_ITTER107...') |
| 214 | + codes = load_itter107(itter107_path) |
| 215 | + print(f' Loaded {len(codes)} codes') |
| 216 | + |
| 217 | + if comuni_csv_path: |
| 218 | + utf8_path = comuni_csv_path + '_utf8.csv' |
| 219 | + with open(comuni_csv_path, encoding='latin1') as f: |
| 220 | + content = f.read() |
| 221 | + with open(utf8_path, 'w', encoding='utf-8') as f: |
| 222 | + f.write(content) |
| 223 | + comuni_csv_path = utf8_path |
| 224 | + else: |
| 225 | + comuni_csv_path = download_comuni_csv() |
| 226 | + |
| 227 | + print('Building comuni mappings...') |
| 228 | + comune_to_nuts3, storico_to_nuts3 = build_comuni_mappings(comuni_csv_path) |
| 229 | + print(f' comuni: {len(comune_to_nuts3)} | storico: {len(storico_to_nuts3)}') |
| 230 | + |
| 231 | + print('Downloading capoluogo flags...') |
| 232 | + capoluogo = download_capoluogo_json() |
| 233 | + |
| 234 | + print('Building DuckDB...') |
| 235 | + build_duckdb(codes, comune_to_nuts3, storico_to_nuts3, capoluogo) |
0 commit comments