Skip to content

Scope rules and data gaps

Jip Claassens edited this page Sep 15, 2026 · 1 revision

Scope rules and data gaps

In one sentence: every resident the model cannot route to a pharmacy is priced at BIG, so missing roads or missing pharmacy records looked like policy headroom in the 31 August 2026 (#48) rankings; this page explains the three scope rules (Glossary: Scope rules (Rule 0/1/2)) that keep such residents out of the model, and the one limit — the choice-set radius — that is not a data gap but still distorts the frontier.

Where this sits: these rules decide who is in the model before anything is optimised: Rule 0, country (ModelClient, Analyses.dms); Rule 1, landbody (road network kept per landbody, Templates.dms); Rule 2, NUTS region (Julia's loader, settings.jl). The choice-set radius is a limit of the OD matrix, not a rule. Upstream: GeoDMS OD matrix and choice set. Downstream: the baseline ★ (Glossary), the frontier (The lambda sweep and the Pareto frontier), Scenarios S1 S2 S3, Metrics aggregation and ranking. Formal statement: Lambda sweep method §5–6.

Why a data gap must not look like policy headroom

The LP uses soft coverage (The facility location model explained, Glossary): a client cell need not be served, but every unserved resident costs BIG — 120 person-minutes under LINEAR, 1.0 under LOGISTIC (Travel cost functions). ★ is priced the same way (baseline_metrics, lambda_sweep_simplex.jl): a client with candidate-side OD rows but no existing-side row adds BIG × residents to today's cost. Right for a genuinely remote hamlet; wrong when the row is missing because the data is missing — a pruned road network, a region with no pharmacy records — because the model then sees a huge cost today, "removes" it by opening a facility nearby, and reports the difference as headroom.

A cell in neither OD — no candidate within 120 min either — is silently absent from ★ and frontier alike (comment above baseline_metrics, around line 165: "Clients absent from BOTH ODs stay invisible"): a third, implicit scope boundary. So "unreachable" means two things here: for Rule 2, any inhabited cell of _i.arrow with no existing-side row; in the log's baseline block (unreachable clients: X cells / Y residents, Log lines and output files), a cell that has candidate-side rows but no existing-side row. A cell with no rows on either side is counted nowhere.

Under soft coverage a served resident costs c(t); an unreachable one costs the flat BIG. Thousands of residents at BIG dominate a baseline.

How fast the penalty dominates: cells A (1 000 residents, 5 min) and B (500 residents, 10 min) are served; C (200 residents) is an island whose pharmacies are not in the source file. LINEAR baseline = 1 000·5 + 500·10 + 200·120 = 34 000 person-minutes, 71 % of it penalty; the mean reads 20 min while the 1 500 served residents average 6.7 min; and a pharmacy on C "saves" up to 24 000 — more than everything else combined.

That happened at scale in the 31 August 2026 (#48) rankings (#49):

  • ITG (Sicilia + Sardegna). Before the landbody fix: LINEAR baseline 1.394e8 with 983 169 unreachable residents (15.7 %); penalty 983 169 × 119.9638 = 117 944 689, 84.6 % of the "travel cost". (That log's BIG=119.9638 was ITG's own maximum OD time, the rule before 4ec47a9 — Travel cost functions — so the log #49 quotes was apparently not re-run after 16 Jul; unverified.) The 5.27 M residents actually served averaged 4.08 min — normal, next to ITI (4.18) and Czechia (4.23). ITG topped the LINEAR rect_rel ranking (Metrics aggregation and ranking) at 0.384 (next Portugal 0.320) for that reason alone (doc/issue_itg_body.md).
  • Portugal. 467 704 unreachable residents × BIG ≈ 5.6e7 of an 8.91e7 baseline, ~63 %. A probe of the exports put all 1 893 pharmacy cells at x ≥ 2 640 km (EPSG:3035) and every unreachable client at x ≤ 1 884 km: the Azores and Madeira, in the population grid, absent from the pharmacy file. No road-network fix could help (doc/issue49_comment_portugal.md).

The ranked lists were "ranking coverage artefacts alongside real policy headroom" (#49); hence the deck's scope slide: a data gap must not rank as policy headroom. Three rules now separate "remote" from "unknown":

rule granularity in words where effect
0 country ModelClient = inhabited cells in a country with ≥ 1 pharmacy record in the area Analyses.dms covered_country, ModelClient (around line 17) whole countries only in the EU run; in a single-country or NUTS-1 run only coastal/border cells whose centre lies in no country polygon
1 landbody keep the largest strongly connected road network per landbody Templates.dms Roads_isConnected (around line 93) adds back island networks the old rule pruned
2 NUTS region drop a region when > 50 % of its inhabited cells reach no existing pharmacy settings.jl excluded_nuts_regions (around line 129) removes the region's residents and candidates, both sides

Rule 0 is explained on GeoDMS OD matrix and choice set; here only this: it states data availability, not accessibility, and both sides must use the same client unit, or S1 and S2 (Scenarios S1 S2 S3) compare different populations (doc/issue49_comment_client.md).

Rule 1 — the road network per landbody

A landbody is one part of a country's multipolygon — mainland, Sicilia, Sardegna, Gotland, each Wadden island — produced by Country_Split := geos_split_union_polygon(Country/geometry, id(Country)) (RegionalUnits.dms around line 117; a seldomain in the template's vocabulary, Glossary).

The old rule kept the single largest strongly connected component (SCC, Glossary) of the whole road extract. An island whose roads did not join the mainland through a modelled link lost its entire network (a TomTom ferry is a link, so the Wadden islands were fine) and every resident there was priced at BIG; for ITG, two landbodies, at most one could survive. Issue #13 (Gran Canaria; Chris: "All centres are 120m centres", i.e. everyone at the cap) was the first sighting; Jip's note was to port the world model's solution. Chris fixed it on main at once — aec3cca (20 Feb 2026) introduced Check_Connectiveness_T_seldomain — but ServiceAccess kept the single-SCC rule until Chris's 79cb58b (31 Aug 2026) brought the same change over; every sweep before then, including the 31 Aug (#48) rankings, ran on pruned island networks.

What changed. 79cb58b switched Roads_isConnected/Nodes_isConnected to the per-landbody criterion (Templates.dms around lines 93 and 705–808): every road node is assigned to a landbody, and per landbody the SCC with the most nodes among those touching it is kept — by node count overall, so a ferry-joined island keeps the mainland component (a landbody touched by a small ferry-joined mainland fragment and a larger local component would lose the local network — inference; no such case observed). No source data changed — the island roads had been in the extract all along. The configuration is read step by step on GeoDMS OD matrix and choice set.

What it moved. Rebuilding all 42 areas (recipe and timings: Running a sweep end to end) changed three, measured as candidate-OD row counts before/after in scratch/affected_areas.csv: ITG (+60 % rows; unreachable 983 169 → 591, baseline mean 22.30 → 4.878 min), PL8 (+27.9 %; 153 669 → 0), Portugal (+10.7 %, but see Rule 2). The Netherlands moved 0.0 %. ITG's new mean sits just above the 4.08 min of the previously served — reconnected islanders travel further, the expected direction. Before/after unreachable counts are not like-for-like: a larger network gives more cells a candidate-side row, so more cells can be counted as unreachable at all (ITG: +25 895 residents entered the count this way).

Issue #50, a side effect. Norway, SE2 and SE3 then failed at alloc (the OD-export step) with FinalLinkSet/F2: Link_Node2_rel out of range or undefined. Duplicate junction ids were ruled out by construction (NodeSet_src := unique(…JnctIds)), id drift by measurement; the cause was a junction-free section (Glossary: a degree-2 chain that CreateMoreEfficientNetwork collapses into one link) with no connector link (the synthetic 30 km/h link that attaches a client or facility cell to the nearest road): a closed island ring road touching nothing, possible only once the kept network is no longer one component, collapsed into a link with an undefined endpoint. Fix 7086928 (2 Sep): count connector links (NrConnectorLinks, around line 522) and drop such sections in ToBeOmitted (around line 608); a no-op elsewhere. The landbody rule plus this fix changed 0.00 / 1.41 / 0.39 % of Norway's, SE2's and SE3's candidate OD rows; none triggers Rule 2. Diagnosis trail: Decision log.

Rule 2 — the NUTS region exclusion (#49, #51)

The rule in words. Per NUTS region, count the inhabited cells that appear in no row of the existing-side OD — with or without a road network they reach no pharmacy within 120 min. If that share is more than 50 %, the whole region leaves the model: residents and candidate cells, baseline and sweep alike. Candidates go too so that an uncoverable region supplies neither demand nor supply (load_from comment, around line 83) — a facility opened there would serve zero-weight clients and pad the count. Maarten first proposed judging per landbody (Country_Split); Chris confirmed the Azores/Madeira gap was unexpected but "a recurring theme" and endorsed dropping them; 872b388 (2 Sep) implemented it "as agreed" on NUTS regions. Why NUTS rather than landbody is not recorded; the likely motivation (inference) is that a NUTS code is a named, reportable unit both exports can carry as one string per cell, whereas a landbody is an anonymous polygon part.

Level choice, NUTS3 → 2 → 1. GeoDMS exports one NUTS3 code per client and per candidate (column NUTS, Analyses.dms around lines 431 and 444; empty where a cell centre is outside every NUTS3 polygon); the first 4 characters are the NUTS2 code, the first 3 the NUTS1 code. excluded_nuts_regions judges at the finest level that has any group and falls to the next coarser level only when a level has none — "NUTS3 if populated" means "if the column carries 5-character codes" (the loop: Code walkthrough settings). Why finest first is not recorded; the evident motivation (inference) is that it keeps the verdict local — Azores out, mainland in — which a verdict on PT as a whole could not. A NUTS2 region whose three NUTS3 parts have 4, 4 and 8 of 10 cells absent loses only the third part, although its aggregate 16/30 = 0.53 would have excluded all three at NUTS2. No such case exists today; the fallback has never fired; cells with an empty code are in no group and can neither be excluded nor counted.

Judged on the existing side, and why. The helper reads only ExistingPharmacies\<area>_i.arrow and _od.arrow: the verdict must be identical for baseline and sweep, so it cannot depend on which candidate set — full or subsampled — a run uses (settings.jl comment, doc/region_exclusion_for_deck.md); both load_from calls invoke it.

What it excludes today. Exactly one area triggers: Portugal at NUTS3, PT200 Açores (919 cells, 212 855 residents, 100 % absent) and PT300 Madeira (419 cells, 244 867, 100 %). The other 27 Portuguese NUTS3 regions are far below 50 %; no other area comes near (#51, scratch/excluded_regions_Portugal.csv). Portugal (#51): client population 10 308 403 → 9 850 636 (doc/region_exclusion_for_deck.md says 9 850 681; #51's final table is used here), candidates 19 382 → 18 487, baseline cost 8.91e7 → 3.42e7 (−62 %), mean 8.64 → 3.47 min, unreachable 467 704 → 9 982 residents. The −62 % matches the ~63 % penalty share: the inflation was the data gap almost entirely. Portugal's figures now describe the mainland only; pre- and post-exclusion numbers must not be mixed.

How the loader applies it. In load_from (around lines 85–125) excluded candidates leave the facility list and excluded clients keep their OD rows at weight 0, contributing nothing to cost, penalty or population; d834a7c fixed a KeyError(0) on rows pointing at an excluded candidate and stopped zero-weight cells being counted as unreachable (p == 0 && continue). Mechanics: Code walkthrough lambda_sweep_simplex, Code walkthrough settings.

A worked verdict. Clients coded PT111 ×4, PT200 ×3, 100 residents each; the existing OD has rows for three of the PT111 cells only. PT111: 4 cells, 1 absent, share 0.25 → stays (that cell remains, priced at BIG). PT200: 3 of 3 absent, share 1.0 → excluded. Return value (Set(["PT200"]), 3, [("PT200", 1.0, 300.0, 3)]), CSV line Area,3,PT200,1.0,300,3, log line Area: excluding 1 NUTS3 region(s) with >50.0% of inhabitant locations absent from the OD: PT200 (100.0%, 300 residents).

The choice-set radius — a limit, not a data gap

On the candidate side each client's OD row holds every candidate within the road time to its 5th-nearest existing pharmacy cell, capped at 120 min — a radius fixed by today's five, ~70–90 candidates on average. How the limit(...) directive builds it (with a worked client), why a fixed count rather than a fixed radius (issue #22: a search outward from clients has a stop criterion) and the history of the 120-min cap (0737dfe, 535f796; the value's reason is not recorded) are on GeoDMS OD matrix and choice set. Why five is not recorded (inference: a school-era default chosen for compute, not policy); doc/todo.md B6 asks "whether 5 is enough" and the question is open.

Where it binds. When the optimiser closes pharmacies the radius does not grow: a client whose five nearest existing pharmacies all close has no candidate left unless another open one lies inside the same radius, and is priced at BIG although a sixth pharmacy may stand just outside it. At S1 this rarely matters; at S2-level counts it does. Measured for the 10 Sep deck (p9) and Lambda sweep method §6: SE2 at the exported S2 point (309 of 452 open) had 1 730 cells / 449 261 residents — 10.2 % — with exactly one open facility left in their radius; closing that last one would strand them at BIG, so the sweep cannot go there. ITF (519 of 1 253 open) had 9.0 % already stranded and 23.9 % more one closure away, and even at S1 stranded 1.84 %.

Two caveats as of e25416b. The SE2 figure was read at the snapped grid point (309 open) that stood in for both S1 and S2; #52's bisection puts S1 at 451 of 452 and S2 at 278 (snapping versus pinning: Scenarios S1 S2 S3). The ITF figure used Italy's ESPON list (1 951 pharmacies in ITF); Italy has since moved to the OECD ministry list (4 955, Branches data and environment). With the OECD list and pinned S1/S2, the 12 Sep results (not yet on GitHub) give Norway 18.6 % of residents one closure from stranding at S2 and ITF 1.8 % (#53). The direction stands: wherever the radius rather than the geography decides who can be served, the frontier is under-estimated — the true S2 needs fewer facilities than the sweep can find. Widening the candidate-side radius (5 → 10, keeping the existing side at 5 so ★ stays comparable) is the first method item on the roadmap (README's list puts "communicate λ intuitively" ahead of it) and a precondition for RSSV, a spatial-voting way of choosing a candidate subset (Decision log).

Candidate subsampling and PROTECT_BASELINE

LOCATION_SELECTION_FACTOR = K keeps every K-th candidate (and only the OD rows referencing kept candidates) so a huge region fits a solve; added 27 May 2026 "for scale-up testing" (afef439). The existing side is never subsampled (apply_factor=false): ★ must be priced on every pharmacy that exists today, or the baseline itself would be an artefact of the stride (inference; no commit states it). The first production use exposed a defect: the blind stride facilities_all[1:K:end] also dropped most of today's pharmacy cells — FRI at K = 3 kept 555 of 1 590 — so the frontier could not reproduce today's network and FRI's S2 landed at 1 772 facilities, more than the 1 584 baseline (f167da2, 21 Jul). The fix, PROTECT_BASELINE=1 (default), keeps every candidate whose rounded (x, y) equals an existing pharmacy cell in _j.arrow (facility ids are per-file local; coordinates are the only cross-side key) and strides the rest: FRI 17 141 → 6 765 candidates with 1 577 of 1 590 baseline cells kept.

Even protected, a subsample's frontier is an upper bound of the full one, and mixing its points with full-set points breaks the convexity of the plotted "LP bound" — issue #54, FRI's July frontier (6 765 candidates, interior point) merged with September refine points on the full 17 133 candidates; FRI is being fully re-swept (mechanism: The lambda sweep and the Pareto frontier). Whether PL8's frontier is also subsampled is open (doc/issue48_README.md says so, #54 says FRI only). Per-area frontier provenance: Branches data and environment.

Residual unreachable population

After both scope rules, residents still unreachable within 120 min are 0.012 % of the total over the 41 areas of the 10 Sep results; 34 of 41 areas have none; the largest remainder is SE2 (165 cells, 14 210 residents, 0.32 %); ITG keeps 591 residents in 12 cells inside the x-range of its pharmacies, Portugal 9 982 on the mainland (README, the deck's model slide, #49). These are genuinely remote and stay in, priced at BIG on both sides — which keeps ★ and the frontier comparable (cells reachable from no candidate at all are absent from both).

How to check an area for these problems

Sources: logs/sweep_<area>_<FUNC>.log (git-ignored, on Maarten's machine; line names in Log lines and output files) and doc/pharmacy_descriptives.csv.

  1. Penalty share. Take Y from the baseline line unreachable clients: X cells / Y residents priced at BIG=… and the total travel cost line of the same block. Penalty share = Y × 120 ÷ total cost; served mean = (cost − Y × 120) ÷ (population − Y) (LINEAR). More than a few per cent means a scope problem, not policy headroom (before the fixes: ITG 84.6 %, Portugal 62.6 %, PL8 44.4 %, SE2 10.9 %).
  2. Connectivity or data? Edit the area list at the top of scratch/probe_portugal.jl (hard-coded to Portugal and ITG and to C:/LocalData/networkmodel_eu) and run it; it prints per area the x/y range (km) of unreachable clients and of all pharmacy cells. Disjoint ranges (Portugal: unreachable x ≤ 1 884 km, pharmacies x ≥ 2 640 km) = no pharmacy records there, Rule 2; overlapping ranges with many unreachable cells = a pruned network (compare candidate-OD row counts before/after a rebuild) or genuinely remote cells.
  3. Did Rule 2 fire, could it? Look for <area>: excluding N NUTS3 region(s) …, or the warning client table has no NUTS column; region exclusion skipped (old export — re-run alloc on both sides). scratch/excluded_regions_<area>.csv is written only on a trigger and never deleted, so a stale file can outlive a clean verdict.
  4. Descriptives versus log. n_residents sums every inhabited cell and equals the pre-exclusion client population; the sweep's total client population counts ModelClient cells with non-zero weight — Portugal 10 308 403 vs 9 850 636, the difference being the excluded islands. A low n_pharmacies for the country size means a thin source list (Italy on ESPON 12 991 vs 20 635 on the OECD list, #53).
  5. Reduced candidate set, binding radius? load_from(<area>): protected N baseline candidates; kept K of M (factor=…) in the header means a subsample. In the S1 —/S2 — blocks read stranded : topp … greedy … multi … (the three rounding seeds, From LP fractions to real pharmacies); non-zero at S1 means relocation moved facilities out of clients' fixed radii. The one-closure-from-stranding share is not in the log (deck p9 used a script not in the checkout; unverified).

In the code

file → function open it for
settings.jl → excluded_nuts_regions, in_excluded_region, nuts_prefix (permalink) the rule, level loop, CSV and log line
settings.jl → big_cost, MAX_TRAVELTIME_MIN (around line 91) the price of an unreachable resident, and why it is fixed at 120
lambda_sweep_simplex.jl → load_from (permalink), baseline_metrics (around line 167) exclusion and protection applied; BIG-priced baseline, zero-weight and both-absent cells skipped
cfg/main/Templates.dms → Roads_isConnected (permalink), Check_Connectiveness_T_seldomain (around line 705), NrConnectorLinks/ToBeOmitted (around lines 522, 608) the landbody rule and the #50 fix
cfg/main/SourceData/RegionalUnits.dms → Country_Split (around line 117) what a landbody is
cfg/main/Analyses.dms → covered_country/ModelClient (around line 17); ClientExport/FacilityExport nuts (around lines 431, 444); Allocation (around line 277) Rule 0, NUTS export, the radius
cfg/main/ModelParameters.dms → max_nr_facilities_per_client, max_traveltime_to_facility (around lines 14–16) 5 and 120 min
scratch/probe_portugal.jl, exclusion_verdicts.jl, affected_areas.csv the evidence behind #49–#51

Four near-identical loaders exist and only lambda_sweep_simplex.jl load_from, the production one, applies protection and exclusion — the "wrong loader" trap: PROTECT_BASELINE was first patched into load_country, which the sweep never calls, and worked only once ported to load_from (f6edc57). The four-loaders table is on Code walkthrough settings.

Knobs

name default changes why you would change it interactions
NUTS_EXCLUSION 1 rule on/off 0 to see pre-exclusion Portugal off: Azores/Madeira priced at BIG again
NUTS_EXCLUDE_SHARE 0.5 threshold, strict >; read into the constant EXCLUDE_SHARE in settings.jl sensitivity; no borderline case exists origin of 50 % not recorded beyond "as agreed"
LOCATION_SELECTION_FACTOR 1 keep every K-th non-baseline candidate, new side only a region too large to solve frontier becomes an upper bound; mixing with full-set points breaks convexity (#54)
PROTECT_BASELINE 1 with K > 1 keep every candidate coinciding with an existing pharmacy cell 0 only to reproduce the pre-July blind stride needs the existing _j.arrow and x,y
BIG_TRAVELTIME_MIN 120 BIG = c(120) for linear-type functions; minutes a stranded baseline client enters mean_t at experiments only must equal GeoDMS max_traveltime_to_facility; LOGISTIC ignores it for BIG (fixed 1.0) but stranded baseline clients still enter mean_t at these minutes under every function
max_nr_facilities_per_client (GeoDMS) 5 the radius (mass limit) the 5 → 10 experiment, candidate side only OD grows; keep 5 on the existing side
max_traveltime_to_facility (GeoDMS) 120 min absolute cut of every OD tree — change together with BIG_TRAVELTIME_MIN

Pitfalls and open questions

  • Both sides must carry the NUTS column; without it the rule warns and does nothing. On disk the column is NUTS (the DMS token registry is case-insensitive and had seen NUTS first); Julia reads :NUTS.
  • Level loop and empty codes: see Rule 2. Loaders that skip the exclusion: see In the code.
  • scratch/exclusion_verdicts.csv holds only Portugal's two rows; "39 areas, only Portugal" is the commit message of db7084d.
  • The choice-set numbers on deck p9 are hard-coded prose in doc/build_deck.mjs, pre-#52/#53; the wiki §6 repeats them.
  • Open: widen the radius to 10 (roadmap); whether 5 is enough at all (doc/todo.md B6); whether PL8's frontier was subsampled; whether Rule 2 should be per landbody after all if a gap ever cuts across NUTS borders; the origin of the 50 % threshold.

See also

Clone this wiki locally