-
Notifications
You must be signed in to change notification settings - Fork 0
Code walkthrough lambda_sweep_simplex
In one sentence: lambda_sweep_simplex.jl turns one area's OD exports into a frontier, a baseline and the two pinned scenarios S1 and S2 — this page walks through it in execution order and says why each step is the way it is.
Where this sits: GeoDMS exports the OD matrix and the client/candidate tables (GeoDMS OD matrix and choice set) → this script loads them, prices today's network, builds the LP once (Code walkthrough lp_run), sweeps λ (The lambda sweep and the Pareto frontier), rounds (From LP fractions to real pharmacies) and pins S1/S2 (Scenarios S1 S2 S3) → its log and Arrow files feed the deck (Deck and charts pipeline, Log lines and output files). Constants come from Code walkthrough settings. As of September 2026 (commit e25416b).
For a rising price per facility the script asks the LP "how many pharmacies would you keep open, and where?" and records every answer; the set of answers is the Pareto frontier (Glossary). Today's network is priced on the same footing (the baseline ★), and the two points where the frontier crosses it — same count (S1) and same travel (S2) — are pinned by bisection.
One solver concept explains most of the code: HiGHS's simplex keeps a basis, and re-solving from the previous one (a warm start) is fast when the new optimum is nearby — a near step — and possibly hours when it is far away or the basis was left by an aborted solve (The lambda sweep and the Pareto frontier, Glossary). Three design facts follow:
- One JuMP model, solved many times in sequence. A grid step costs 22–75 s on the Netherlands up to w = 0.2 against roughly 700–3 950 s per fresh IPM solve in the superseded parallel variant (§12, Alternatives not pursued §3). "No parallelism, no MAX_PARALLEL" (header comment, around line 5).
- A far basis is the enemy. Nearly every September change (issue #52) keeps every solve a near step: bisect the moment a bracket closes, restore the basis afterwards, stop the tail at the first time-out.
-
One grid for every area. The aggregate frontier (Metrics aggregation and ranking) is summed at common λ, so any area-dependent stop truncates it: a fixed common grid since
206923c(6 July 2026), identical for every area sincef93aea1(9 July).
flowchart TD
A[for country in COUNTRIES] --> B[load_from existing side<br/>apply_factor=false]
B --> C[load_from new side<br/>subsampling + region exclusion]
C --> D[baseline_metrics → ★<br/>target_cells = n_used]
D --> E[write_baseline_arrows]
E --> F[build_lp_warmstart once]
F --> G[grid: SWEEP_WMAX, SWEEP_MULTS]
G --> H{for w in ws_common}
H --> I[run_lp w, light?]
I -->|nothing| J{both pinned and<br/>tail-fail stop?}
J -->|yes| M
J -->|no| H
I -->|result| K[print_sweep_row<br/>refine_if_closed! prev,r]
K --> L{refine-only and<br/>both pinned?}
L -->|yes| M
L -->|no| H
M[sort results; find_bracket S1, S2] --> N{either bracketed?}
N -->|no| O[Combined table only, return]
N -->|yes| P[fine sweep of unsolved mults<br/>fallback bisection]
P --> Q[Combined sweep table]
Q --> R[closest S1, S2 → summary<br/>write S1/S2 arrows]
B to R is one call of analyze_country(country) (around line 245). The top-level loop (around line 551) wraps it in try … catch, prints the error with a backtrace and moves on, so one bad export never kills a batch.
The file starts with include("lp_run.jl"), which includes settings.jl, so every constant of Code walkthrough settings is in scope. The driver adds three of its own (around lines 9–11):
| constant | value | why |
|---|---|---|
RAW_PHARMACY_COUNTS |
Dict("Netherlands" => 1992) |
The model counts cells with a pharmacy, not pharmacies (several can share a 1 km² cell). Where a raw count is known the log adds an informational n-raw column and one summary line; nothing in the optimisation uses it. Only the Netherlands is listed because the Dict generalised the prototype's Netherlands-only TARGET_N = 1992 (lambda_sweep.jl, commit 2d1d32b, 24 May 2026) and no other raw count was ever added. |
EXISTING_PATH |
<LOCAL_DATA_PROJ_DIR>\ExistingPharmacies |
today's pharmacies and their OD |
NEW_PATH |
<LOCAL_DATA_PROJ_DIR>\NewPharmacies |
the candidate set and its much larger OD |
LOCAL_DATA_PROJ_DIR is C:\LocalData\networkmodel_eu (settings.jl). ANALYSIS/ANALYSIS_DIR are not used: the sweep always needs both sides, hence its own loader. pln (line 3) is println plus flush(stdout), so a redirected log is readable while a multi-hour run is still going.
load_from(dir, country; apply_factor=true) (around line 71) reads the three Arrow files GeoDMS exported for one side — <country>_od.arrow (one row per client–candidate pair, t_ij in seconds), <country>_i.arrow (clients) and <country>_j.arrow (facilities); columns in Log lines and output files — and returns a CountryData (settings.jl, around line 208). It is called twice:
existing = load_from(EXISTING_PATH, country; apply_factor=false) # today's pharmacies
new_data = load_from(NEW_PATH, country) # the candidate setThe existing side serves only the baseline; the new side is what the LP is built on. The existing side is never subsampled (apply_factor=false forces factor = 1): the baseline must see every pharmacy that exists. Inside, in order:
-
Region exclusion (issue #49; rule and numbers in Scope rules and data gaps):
excluded_nuts_regions(country)decides — on the existing OD, so both sides agree — which NUTS codes to drop; their candidates are removed and their clients get weight 0 (around line 124), so no index shifts. -
Baseline protection (commit
f6edc57, 21 July), only whenLOCATION_SELECTION_FACTOR > 1,PROTECT_BASELINEis"1"(its default; any other value disables it),ExistingPharmacies\<country>_j.arrowexists and the candidate table hasx/ycolumns (around line 92): candidates whose rounded(x, y)(EPSG:3035 metres) coincides with an existing pharmacy are always kept; only the rest is subsampled by the stride (rest[1:factor:end]). Why: the blind stride on FRI at factor 3 kept only 35 % of today's pharmacy cells, so S2 ended with more locations than the baseline (Scope rules and data gaps). It is the only cross-side facility match, by coordinates, not by id — the_jid space is local to each file; clients are matched by id. -
OD filtering: whenever the facility set was reduced (
factor != 1or region exclusion), keep only OD rows whosefacility_relsurvives.d834a7cfixed the shortcut that filtered only forfactor != 1— excluded candidates slipped through andbuild_lp_warmstartcrashed withKeyError(0). -
Units and indices:
t_ijis divided by 60 → minutes;client_rel/facility_relare 0-based GeoDMS ids, hencepopulation[clients_col[k]+1](Log lines and output files).locations[i]lists the OD rows of clienti,facility_rows[j]those of facilityj;nearest_facilityis filled but unused by the sweep.
baseline_metrics(existing, new_data) (around line 167) prices today's network exactly as the LP prices a solution, so ★ and the frontier are comparable. For each existing-side client it takes the OD row with the smallest raw travel time (argmin over t_ij, not c(t) — harmless while c is monotone) and accumulates:
| printed line | field | how it is computed |
|---|---|---|
facilities used (cells): N (of M) |
n_used |
distinct existing facilities that are somebody's nearest. A pharmacy cell that is nobody's nearest ("empty") does not count. M is existing.M, the _j rows after region exclusion. n_used is target_cells, the S1 target.
|
raw pharmacy count |
target_raw |
from RAW_PHARMACY_COUNTS; NL only |
total travel cost(c) |
cost_c |
Σ c(t_nearest)·pop plus BIG·pop per unreachable client. Person-minutes for LINEAR, dimensionless for LOGISTIC. The S2 target.
|
facility cost @ €100000 ea |
— |
target_cells · FACILITY_MIN_COSTS, a placeholder € figure |
mean travel time (min) |
mean_t |
time_total / total_pop; unreachable clients enter at MAX_TRAVELTIME_MIN (120 min) |
total client population |
total_pop |
covered + stranded population (zero-weight clients drop out) |
unreachable clients: n cells / p residents priced at BIG=… |
n_stranded, stranded_pop, big
|
see below |
The coverage-consistent part (commit 206923c, doc/todo.md item 3) is the loop over new_data.locations: a client present in the candidate OD but absent from the existing OD can reach no existing pharmacy within the choice set, so it is charged BIG·pop, the price the LP pays for stranding a client under soft coverage (Glossary); before this fix such clients were silently dropped and ★ sat below the frontier (Scenarios S1 S2 S3). Zero-demand cells are skipped (p == 0 && continue, d834a7c): out of scope, not stranded. BIG = big_cost() is c(120) = 120 for LINEAR and 1.0 for LOGISTIC (settings.jl around line 97; Travel cost functions).
A sweep row's mean_t lets stranded clients contribute 0 minutes where the baseline charges 120, so compare cost_c, not mean_t, across ★ and the frontier (Log lines and output files). After the block, write_baseline_arrows(country, existing) writes the baseline's two Arrow files (§10).
state = build_lp_warmstart(new_data) (lp_run.jl around line 397) builds the soft-coverage LP on the New side once — HiGHS, presolve on, output_flag = true so HiGHS's iteration log is interleaved in the sweep log and a stalled worker can be diagnosed from it (commit 011cafb). Only the y-coefficients (λ) are rewritten per solve; construction and variables in Code walkthrough lp_run.
The closure run_lp(w; light=false) (around line 281) is the only way the driver solves: solve_at_w!(state, w; rounding=!light). On any exception it prints LP at w=… FAILED: … and returns nothing (a time-out is raised as an error inside solve_at_w!, so this is where a timed-out point becomes a skipped point); on success it prints solved w=<full float> in <s> s (plus (LP only, no rounding) if light), writes the per-point Arrow files unless light, and returns the result tuple.
That tuple (lp_run.jl around lines 553–559) carries w, λ, sum_y (the LP's fractional facility count), n_frac (printed as frac_y), travel_relax (the LP lower bound, stranding priced at BIG), the three roundings' travel and stranded counts, and for the rounding selected by ROUNDING (default multistart) n_open, cost_c, mean_t, open_set, assigned_k. A light result (around lines 516–522) keeps sum_y, n_frac, travel_relax and n_open = clamp(round(sum_y), 1, M) but has cost_c = Inf, mean_t = NaN, rounding = "none" and no open set; the Inf means it can neither close an S2 bracket (§7) nor be chosen as a scenario (§8).
w_max = SWEEP_WMAX (env) else (LOGISTIC ? 0.5 : 5.0)
mults = SWEEP_MULTS (env, sorted) else [1.0, 2.0, 5.0]
d_hi = max(0, ceil(Int, log10(w_max)))
ws_common = sort(unique(m·10^d for d in −4:d_hi, m in mults if m·10^d ≤ w_max·(1+1e-9)))(around lines 304–312). w is the dimensionless price knob; λ = w · FACILITY_MIN_COSTS = w · €100 000 (Glossary). The default LINEAR grid is 1e-4, 2e-4, 5e-4, …, 1, 2, 5 (15 points); LOGISTIC stops at 0.5 (12 points) because its c(t) ∈ [0, 1] makes the travel term ~100× smaller, so facilities dominate at far lower λ (Travel cost functions). SWEEP_MULTS="1,1.5,2,3,5,7" (commit 2b60d00) is the densification behind the Netherlands rows in deck_data.json. Neither the 1-2-5 spacing nor the 1e-4 lower end is recorded.
The grid ends at w_max. The per-area ×2.5 upward extension of 206923c (6 July) was removed in f93aea1 (9 July) because the hard-coverage high-λ regime was intractable (NL LINEAR w = 5.0: 16.7 h; Austria LOGISTIC timed out from w = 0.02), which also cut w_max to 1.0/0.02 per travel function; soft coverage (4ec47a9, 16 July) then removed the coverage floor that had kept S1 unbracketable in Portugal, ITG, PL8 and Norway, so w_max went back to 5.0/0.5 and no extension is needed — the history is in The lambda sweep and the Pareto frontier. Under soft coverage sum_y keeps falling towards 0 as λ rises, so a high enough w_max brackets both scenarios everywhere: a comment around line 300 records the check (ITG sum_y 178 at w = 5, cost 1.75e8 > baseline 1.4e8), and high-λ soft solves are cheap (few open locations ⇒ small basis). If S1 is still unbracketed at w_max the driver prints a hint (around line 420): check for timed-out points or raise SWEEP_WMAX. The extension still described in Lambda sweep method §2, README.md and the header of run_resweep_batch.ps1 is stale text.
refined is a Dict keyed "S1"/"S2" holding each scenario's pinned result once its bisection has run (around line 338); "both pinned" below means both keys are present.
prev = nothing
for w in ws_common
light = stop_after_refine && !haskey(refined, "S1") &&
(prev === nothing || prev.sum_y > 1.5 * target_cells)
r = run_lp(w; light)
r === nothing && (both pinned && SWEEP_STOP_AFTER_TAIL_FAIL == "1" ? break : continue)
push!(results, r); print_sweep_row(r, target_cells, target_raw)
refine_if_closed!(prev, r)
prev = r
stop_after_refine && both pinned && break
end(around lines 387–414, condensed). Per grid point the log shows HiGHS's iteration output, the solved w=… line, one sweep row (§9) and, when a bracket closes, a bisection block (§7). Per rounded point two Arrow files land in a w=<w> folder (§10).
The LP-only "light" walk (commit e25416b, 10 September). In refine-only mode (SWEEP_STOP_AFTER_REFINE=1, Scenarios S1 S2 S3) the frontier is already known; the walk up from 1e-4 exists only to reach the S1 bracket with a warm basis. While the previous point's LP count is still more than ×1.5 above today's, this point can close neither bracket (S2 lies at a higher price than S1 because ★ is above the frontier, figure in §7), so its three roundings — 50–100 s per point on a mid-size area against a 7–10 s LP, twelve such points on Denmark — were pure waste. The point is solved LP-only, printed with - in the rounding columns and a trailing (LP only), and writes no arrows. Why 1.5: the 1-2-5 grid moves the LP count by roughly 30 % per step (comment around line 321), so a predecessor still more than ×1.5 above today's count cannot fall through it in one step.
The tail stop (around line 400). A timed-out solve leaves HiGHS on an aborted basis, and every later grid point then warm-starts from far away and times out too (FRC LINEAR: w = 1, 2 and 5 each burned the full hour). Once both scenarios are pinned the remaining points only extend the frontier (S3 material), so the first failure ends the sweep unless SWEEP_STOP_AFTER_TAIL_FAIL is anything other than 1. Before both are pinned a failed point is skipped with prev unchanged, so the next bracket test spans it.

S1 is the frontier point straight below ★ and S2 the one straight to its left (Scenarios S1 S2 S3); the sweep sees only w, so it detects each as the grid step where sum_y (S1) or the rounded travel (S2) passes today's value — target_cells and base.cost_c.
crosses(a, b, target, descending) (around line 341) is the bracket test between two consecutive successful grid points: S1 closes when r.sum_y ≤ target ≤ prev.sum_y, S2 when prev.cost_c ≤ target ≤ r.cost_c. S1 is tested on sum_y, not n_open, because the LP count can only fall as the price rises (The lambda sweep and the Pareto frontier), whereas the rounded count once jumped from 2 296 to 3 894 between w = 0.01 and 0.1 on the Netherlands (commit 9ee7ac6, 25 May; Alternatives not pursued §3); rounded travel is monotone only "up to rounding noise" (comment around line 320).
refine_if_closed!(prev, r) (around line 364) runs after each grid point: if S1's bracket has just closed and S1 is not yet in refined, it calls bisect! on sum_y; likewise S2 on cost_c. The S1 tolerance is 0.2 % of today's cell count but at least one whole facility (max(1.0, REFINE_TOL · target_cells)); the S2 tolerance is 0.2 % of today's travel (REFINE_TOL · base.cost_c) — for the Netherlands LINEAR (1 615 cells, 6.07e7 person-minutes) 3.2 facilities and 1.2e5 person-minutes. After any bisection it calls resolve_for_basis!(state, r.w) (lp_run.jl around line 446) — a re-solve of the bracket's upper endpoint, no rounding, no metrics, so the basis is where it would have been without the detour and the next grid point is again a near step — and re-sorts results by w.
bisect!(label, lo_r, hi_r, getter, target, tol, descending) (around line 343):
best = the endpoint closer to target (ties → lower endpoint)
up to REFINE_ITERS times:
wmid = sqrt(lo·hi) # geometric midpoint = bisection in log w
r = run_lp(wmid) # always with rounding; arrows written
r === nothing && break # a failed midpoint ends the bisection
push!(results, r); print row
closer than best ? best = r
within tol ? break
"w still too low" ? lo = wmid : hi = wmid
print " <label> pinned at w=…: sum_y|cost_c=… (target …)"
return best
"w still too low" means the quantity has not yet crossed the target: for S1 (descending) sum_y > target — the count is still above today's, so raise the lower end; for S2 (ascending) cost_c < target. The midpoint is geometric because the grid is logarithmic: each iteration halves the bracket in log w, and eight halvings of a ×2.5 bracket leave a factor ≈ 1.004 — a final bracket only 0.4 % wide in w. REFINE_TOL defaults to 0.002 and REFINE_ITERS to 8, tightened in 29fcf7f from the 0.5 % / 6-solve S2 bisection of b76f643 (7 June); why 0.2 % and 8 is not recorded.
"Pinned" means the closest point seen, possibly an endpoint — when the integer frontier jumps over the target, or when the midpoints time out. Always read the (target …) residual; the toy sweep and the Denmark LOGISTIC case that show this are on Scenarios S1 S2 S3.
Why in-loop rather than after the sweep. The S2 bisection of b76f643 ran after the sweep had climbed to w_max, from the basis left by the tail's time-outs: its first solve timed out in 14 of 42 LINEAR areas, and snapped S1 was more than 10 % off in 22 of 74 sweeps (issue #52). Since 29fcf7f (10 September) each bisection starts the moment its bracket closes, so every midpoint is a near step (Scenarios S1 S2 S3, Decision log).
Refine-only mode (SWEEP_STOP_AFTER_REFINE=1, set by run_resweep_batch.ps1 -RefineOnly) is the same loop with the light walk on and a break once both are pinned; the tail is never entered. doc/build_deck_data.py (around lines 169–183) merges the S1/S2 summary of a newer logs\refine_<area>_<FUNC>.log into the frontier; if sweep and refine used different candidate sets the merged rows come from two LPs and the frontier kinks (issue #54, The lambda sweep and the Pareto frontier).
The post-hoc fallback (around lines 478–490) remains for a bracket that closed across a failed grid point or was first seen in the fine sweep: find_bracket on the sorted results, then bisect! — with the far-basis risk. One corner (inference, unverified): a light row has cost_c = Inf, so a (light, rounded) pair never satisfies prev.cost_c ≤ target; if S2 lies below the first rounded grid point, only the S1 bisection midpoints can give find_bracket an S2 bracket.
What is exported is the snapped row, not the pinned one. The S1/S2 folders and the per-area slide markers use the closest() row of the summary block (§8); bisect!'s return value is only printed as "pinned at" and kept in refined, which nothing downstream reads. The deck's S1/S2 tables and the improvement rectangle are chord-interpolated (doc/frontier_metrics.py) and never use snapped rows; Scenarios S1 S2 S3 compares the three readings. The later refinements of issue #52's second comment (doc/check_s1s2.py; commits 6aaab27 … 216ac77) are not on GitHub: origin/ServiceAccess ends at e25416b (Branches data and environment).
-
sort!(results, by=w), thenfind_bracket(results, target, getter, descending)(around line 201) scans consecutive rows and returns the pair whose values enclose the target inclusively — the post-hoc form of the same crossing test. - If neither scenario is bracketed (
do_fine = false) the driver prints the "structural coverage-floor region" message (the hard-coverage case of §5), still prints the Combined table (an earlyreturnused to leave the area's slide empty) and returns without summary or S1/S2 arrows. Under soft coverage this should not occur. -
Fine sweep (around lines 436–472): all
mults × 10^dstrictly inside the union of the two brackets, minus the w's already solved (isapprox, rtol 1e-9). With the default multipliers the set is empty unless a coarse point inside the brackets failed — then it is retried here. Before29fcf7fit re-solved every such point from a far basis; the duplicatedw = 0.3row in the Netherlands LINEAR data ofdeck_data.jsonis a relic. - The fallback bisection (§7).
-
Combined sweep, sorted by w:— every row inresults(grid, bisection, fine and light); the tablebuild_deck_data.pyparses. -
closest(results, target, key)(around line 504) picks, among rounded rows only, the row minimising|field − target|: S1 = closestsum_ytotarget_cells, S2 = closestcost_ctobase.cost_c; ties go to the lowest w. It does not consultrefined: usually the bisection's best point wins, but on a staircase frontier (consecutive rows sharing one count and one travel value) an earlier row can tie. The two summary blocks (around lines 514–542) print the chosen row in long form plus the % travel change versus ★; the S2 block addsfewer than cells (by sum_y)and, for NL,fewer than raw;mean t (min)closes each block for the parser. -
write_scenario_arrows(country, new_data, s1, "S1")and"S2"copy the chosen points into the stableS1/S2folders (df4febf: the.dmsreferences labels because the winning w differs per area and function).
print_sweep_header(target_raw) (around line 212) and print_sweep_row(r, target_cells, target_raw) (around line 221) print fixed-width, space-separated columns: w, λ (€), sum_y, travel_relax, the three roundings' travel, n_open, fac_€, mean_t, frac_y, n-cells and — Netherlands only, never on a light row — n-raw; a light row shows - in the rounding columns and ends in (LP only). The column order is a contract with build_deck_data.py (around lines 85–104): it needs at least 12 tokens and reads positions 0, 2, 3, 4, 5, 6, 7, 9 and 10 as numbers, so a light row's - raises ValueError and is skipped, as intended. fac_€ comes from the fractional sum_y, so it disagrees with n_open by up to €50 000. Every column with units and a worked Netherlands row: Log lines and output files.
sweep_dir(base_dir, country, w_label) (around line 23) builds and creates
<base_dir>\<country>\lambda_sweep\<travel_func_name>\<w_label>\assignment.arrow (id, open)
<base_dir>\<country>\lambda_sweep\<travel_func_name>\<w_label>\traveltime.arrow (id, t_ij)
with <base_dir> = ExistingPharmacies and w_label = baseline for the baseline; NewPharmacies with w_label = "w=<full float>" for every rounded sweep or bisection point, or S1/S2 for the chosen scenarios. The path carries facility type, area, travel function and w so scenarios never overwrite each other and GeoDMS can address any of them (commit fe8256a).
-
write_assignment_arrow— one row per facility of that side's_jfile:id,open∈ {0, 1}. GeoDMS'sReadSweepResults_T(cfg/main/Analyses.dms, around line 219) reads it as anOpenEnumwhose third value 2 (Underfilled) this driver never writes. -
write_traveltime_arrow—id(client, sorted) andt_ijin minutes to the assigned open facility. A stranded client is absent from the file; GeoDMSrjoins on id, so it gets a null. -
write_baseline_arrowsmirrorsbaseline_metrics(nearest by raw time) and marks theusedset as open.
The w=… folders accumulate across runs and nothing cleans them; the S1/S2 folders are overwritten by every run, refine-only included.
Three layers, inside out:
-
solve_at_w!sets HiGHS'stime_limittoLP_TIME_LIMIT(env, default 3600 s; lp_run.jl around line 439) and raises unless the status isOPTIMAL,LOCALLY_SOLVEDorALMOST_OPTIMAL. A time-out surfaces asTIME_LIMIT; IPM on soft LOGISTIC asOTHER_ERROR(Code walkthrough lp_run). -
run_lpcatches, printsLP at w=… FAILED: …and returnsnothing: the grid loop skips (or stops, once both are pinned);bisect!ends with the best point so far; the fine sweep skips. - The top-level loop catches anything else (a missing Arrow file, an out-of-memory during build), prints
<country> — failed: …with a backtrace and continues.
A time-out is not free: HiGHS keeps the aborted basis, so the next solve starts far from optimal — why Poland country-level LINEAR never pinned (3–15 h per solve near S1, issue #52), why the tail stop exists, and why Test-SweepComplete in run_resweep_batch.ps1 (Running a sweep end to end) only trusts a log whose last Country: block ends in scenario summary.
lambda_sweep.jl (302 lines) is the May 2026 variant — a fresh JuMP model and an IPM solve per w, MAX_PARALLEL of them in threads, no region exclusion, a baseline that silently dropped unreachable clients — kept but not maintained; MAX_PARALLEL survives only as the --threads count the batch scripts pass to Julia. Why warm-started simplex (011cafb, 27 May) replaced it, with the timing comparison and July's solver flip-flops, is in Alternatives not pursued §3.
Read analyze_country top to bottom; everything above it is a helper it calls.
| function | around line | permalink |
|---|---|---|
constants, sweep_dir, the write_*_arrow(s) helpers |
9–69 | L9 |
load_from |
71 | L71 |
baseline_metrics |
167 | L167 |
find_bracket |
201 | L201 |
print_sweep_header, print_sweep_row
|
212, 221 | L212 |
analyze_country — baseline block, run_lp, grid |
245, 281, 304 | L245 |
crosses, bisect!, refine_if_closed!
|
341, 343, 364 | L343 |
| the grid loop, tail stop | 387, 400 | L387 |
| brackets, fine sweep, fallback | 417–490 | L417 |
Combined table, closest, summary, S1/S2 arrows |
494–548 | L494 |
| top-level loop | 551 | L551 |
lp_run.jl → build_lp_warmstart, LP_TIME_LIMIT, resolve_for_basis!, solve_at_w!
|
397, 439, 446, 460 | L460 |
settings.jl → MAX_TRAVELTIME_MIN, big_cost, CountryData
|
96, 97, 208 | L96 |
doc/build_deck_data.py → row parser, refine-log merge |
85–104, 169–183 | L169 |
All are environment variables; there are no command-line flags. Those owned by settings.jl (COUNTRIES, TRAVEL_FUNC, ROUNDING, LOCATION_SELECTION_FACTOR, FACILITY_MIN_COSTS, NUTS_EXCLUSION, BIG_TRAVELTIME_MIN, …) are in Code walkthrough settings; LP_TIME_LIMIT (default 3600 s) and SOLVER (ipm escape hatch; fails on soft LOGISTIC) in Code walkthrough lp_run. COUNTRIES defaults to France Italy Netherlands Sweden and TRAVEL_FUNC to QUADRATIC — the batch scripts always set both.
| name | default | what it changes | why you would change it | interactions |
|---|---|---|---|---|
SWEEP_WMAX |
LOGISTIC 0.5, else 5.0 | top of the grid; decade range follows | raise if S1 is unbracketed (the driver says so); lower to skip an expensive tail | every area must share the range for the aggregate |
SWEEP_MULTS |
1,2,5 |
per-decade multipliers | densify where crossings cluster (1,1.5,2,3,5,7) |
the fine sweep uses the same list, so it adds nothing new |
REFINE_TOL |
0.002 |
bisection tolerance: S1 max(1, tol·target_cells) facilities, S2 tol·base.cost_c
|
tighter pin | more solves, capped by REFINE_ITERS
|
REFINE_ITERS |
8 |
max solves per bisection | — | 8 halvings of a ×2.5 bracket ≈ ×1.004 in w |
SWEEP_STOP_AFTER_REFINE |
0 |
1 = refine-only: light walk, stop once both pinned |
re-pin S1/S2 of an area whose frontier is already swept | set by run_resweep_batch.ps1 -RefineOnly; the log must be named refine_… for the deck merge |
SWEEP_STOP_AFTER_TAIL_FAIL |
1 |
end the sweep at the first failed point after both are pinned | anything other than 1 keeps trying the tail |
each attempt may burn LP_TIME_LIMIT
|
PROTECT_BASELINE |
1 |
keep baseline-coincident candidates when subsampling | anything other than 1 applies the stride to every candidate, today's pharmacy cells included (S2 can then exceed the baseline count) |
only with LOCATION_SELECTION_FACTOR > 1, an existing _j.arrow and x/y columns |
-
"Pinned" is not "within tolerance".
bisect!returns the closest point after at mostREFINE_ITERSsolves — an endpoint when the frontier jumps over the target or the midpoints time out — and prints "pinned" regardless (§7). Read the(target …)value. -
closest()ignoresrefined(§8); on a staircase frontier S1 and S2 can still land on one row, and it is the snapped row that is exported. The deck tables are chord-interpolated and immune. -
mean_tof a sweep row is not coverage-consistent (§3): comparecost_cacross ★ and the frontier. -
Arrow folder names use the full float (
w=0.3872983346207417); the log row shows0.3873. -
S1/S2folders are overwritten by every run, refine-only included;w=…folders are never cleaned. -
Stale text and unpushed code: the ×2.5 extension in Lambda sweep method,
README.mdandrun_resweep_batch.ps1; the snapped Netherlands LINEAR S1 indeck_data.jsonat e25416b (w = 0.3, 1 334 cells, 17 % under today's 1 615; its S2 at w = 0.34087 is from the old post-hoc bisection); the post-#52 commits anddoc/check_s1s2.py(Branches data and environment). -
Unrecorded rationale: the 1-2-5 spacing and the 1e-4 lower end,
REFINE_TOL= 0.2 %,REFINE_ITERS= 8. -
Open: is the ×1.5 light threshold safe with
SWEEP_MULTSsteps below ×1.5 (the dense grid has ×1.4 steps)? Should anything clean thew=…folders? A barejulia lambda_sweep_simplex.jlsweeps QUADRATIC for four countries that nothing downstream reads, andSweepResults/baselineinAnalyses.dms(around line 381) still reads thatQUADRATICfolder — stale June wiring or intentional? (GeoDMS OD matrix and choice set)
- Code walkthrough lp_run · Code walkthrough settings · Running a sweep end to end · Log lines and output files
- The lambda sweep and the Pareto frontier · Scenarios S1 S2 S3 · From LP fractions to real pharmacies · Scope rules and data gaps · The facility location model explained
- Lambda sweep method (Maarten's formal statement) · Decision log · Alternatives not pursued · Glossary
Start here
Understanding the method
- The facility location model explained
- Travel cost functions
- The lambda sweep and the Pareto frontier
- From LP fractions to real pharmacies
- Scenarios S1 S2 S3
- Scope rules and data gaps
- Metrics aggregation and ranking
- Background theory
Working with the code
- GeoDMS OD matrix and choice set
- Code walkthrough settings
- Code walkthrough lp_run
- Code walkthrough lambda_sweep_simplex
- Running a sweep end to end
- Log lines and output files
- Deck and charts pipeline
- Installation of Julia
Reference
- Lambda sweep method
- Pharmacy service access
- Pharmacy results September 2026
- Decision log
- Alternatives not pursued
- FAQ
- Glossary
- Service allocation procedure
External