v0.9.0
- Add a
captionparameter toplot_accessibility():None(the default) prints a stakeholder-facing “how to read this” explanation of the region shading – and, depending onshow_site_ratiobelow, of the site markers too – below the chart; pass""to suppress it or a custom string to replace it, matching the existingcaptionconvention onplot_pareto_summary()/plot_site_reallocation_matrix()/plot_population_impact_histogram(). Also factors the shared caption-rendering logic those four now use intolokigi.plot_utils._add_plot_caption()(internal, no behaviour change to the existing three)
⚠️ Breaking changes
lokigi is pre-1.0, so breaking changes can land in any minor release. Read this before upgrading – it is detailed in the notes below.
ParetoMetricis renamedMetric. It now describes a metric forsolve(rank_on=...)’s single-objective ranking as well as for Pareto fronts, so the old name would have been actively misleading at the new call site. No alias is kept:from lokigi.multiobjective import ParetoMetricfails with a clearImportErrorrather than changing behaviour silently. Update the import and the constructor calls; every field, default and method is unchanged.SiteSolutionSet.pareto_metricsandcompute_pareto_front(metrics=...)keep their names.- GRASP now applies
unreachable_costto the solutions it returns, not only to the ones it searches over. Its final-accept evaluation omitted the parameter that brute-force and greedy both passed, so withunreachable_costset the returned rows’weighted_average_for_ranking/unweighted_average_for_ranking/max_for_rankingheld reachable-only figures – and sincesolve()’s final sort orders the returned pool on exactly those columns, a multi-solution GRASP pool could come back in the wrong order. Affectssearch_strategy="grasp"withunreachable_costset;grasp_num_solutions=1was unaffected in practice, having no pool to misorder. - Tertile equity metrics now honour
add_equity_data(disadvantaged_end=...).avg_lower_third_bins/avg_upper_third_bins,inter_tertile_ratio/inter_tertile_description, andpopulation_impact_by_equity_group()’s band ordering previously always assumed the lowest raw equity-bin values were the most disadvantaged; that was wrong on any problem registered withdisadvantaged_end="high", whose numbers now change to the correct ordering. Problems that never setdisadvantaged_end, or set it to"low", are unaffected. site_allocation_summary()’stotal_demandcolumn is renamedallocated_demand. Reads correctly sitting next to the newsite_capacity_summary()’scapacitycolumn. Any code or notebook selectingtotal_demandfrom this method’s output (not frompopulation_impact_summary()’s unrelatedtotal_demandmetric, orSiteProblem.total_demand, both unaffected) needs updating toallocated_demand.- The display-side
rank_onparameter is renamedsort_by.solve(rank_on=...)(above) is unrelated and keeps its name – it drives the search, deciding what survives pruning. The renamed one instead re-sorts a finishedSiteSolutionSetand never affects which combinations exist to choose from:show_solutions(),show_solutions_summary(),return_best_combination_details()/_site_indices()/_site_names(),site_allocation_summary(),site_capacity_summary(),two_step_floating_catchment(),plot_best_combination(),plot_n_best_combinations()/_bar(),plot_site_allocation_summary(),plot_site_capacity_summary(),plot_allocated_utilisation(),plot_solution_comparison(),plot_travel_time_distribution(),check_solution_equity(),plot_top_n_solution_equity(),plot_combination_by_equity(), and the'rank_on'key inplot_solution_sets_comparison()/SolutionComparator.plot_comparison()’s config dicts, all takesort_by=now. Having both an argument namedrank_onthat searches and one namedrank_onthat only re-sorts was a standing source of confusion – reaching for the wrong one silently produces a weaker answer rather than an error, since re-sorting can only reorder whatever the search already kept. No alias.
New Features
Add
solve(rank_on=...), which searches on any metricsolve()computes rather than only the oneobjectivesimplies. Every candidate combination already computed the full metric set – the objective merely selected which column ranked and pruned – so metrics likeinter_tertile_ratio,90th_percentile,demand_beyond_threshold_45,proportion_demand_improvedor a<metric>__<label>secondary-matrix column can now drive the optimisation itself- Distinct from re-sorting a finished
SiteSolutionSeton one of those columns, which only reorders candidates that survived a search ranked on something else. Underbrute_force_keep_best_nor GRASP, the genuinely best solution for your metric may never have been retained;rank_ondecides what survives - Accepts a bare column name, whose direction follows the usual convention (coverage and improvement metrics higher-is-better, travel costs lower-is-better), or a
Metricfor explicit control.Metricis the only way to express a metric that is best at neither extreme – e.g.Metric("inter_tertile_ratio", direction="closest_to_target", target=1.0), where 1.0 means the most- and least-disadvantaged thirds travel equally far and both larger and smaller values are a departure from that - Composes with the objective’s own constraints rather than replacing them, since
objectivesgoverns feasibility as well as ranking:objectives="hybrid_p_median", max_value_cutoff=60, rank_on="inter_tertile_ratio"means “cap the worst journey at 60 minutes, then pick the most equitable option that qualifies” - Works with every search strategy, and preserves brute-force’s streaming heap,
brute_force_keep_best_n/_worst_nandn_jobs– the ranking transform is per-combination and needs no view of the batch, unlikeweights={"cost": ...}, which must materialise every combination first - Not a job for
weights, which holds row-level weights over demand regions: those change howweighted_averageis computed, not which metric is ranked, and a per-combination scalar likeinter_tertile_ratiohas nothing to weight - Validated against one representative combination before the search starts, so a column that doesn’t exist, holds one value per equity band (
weighted_by_equity_groupand friends), or is empty because its precondition wasn’t met (inter_tertile_ratiowithoutadd_equity_data(), coverage withoutthreshold_for_coverage, population impact withoutbaseline=) fails immediately with a message naming the fix, rather than after a full solve - Ranking on
weighted_average/unweighted_average/maxwhileunreachable_costis set upgrades to the matching_for_rankingcolumn and warns, since ranking on the reachable-only figure would reward a solution for stranding demand – the exact failureunreachable_costexists to prevent. Relatedly,unreachable_costis now required based on the column actually being ranked rather than on the objective (unchanged for every built-in objective, but correct for a customrank_on: a coverage proportion already counts an unreachable pair as “not covered”, so it needs no substitution) - New
custom_ranking_metricexample works through both halves of this on the Devon MIU data: choosing sites on90th_percentilerather thanweighted_average, and choosing which two of fifteen sites to close on the population-impact metrics – where four rankings land within a quarter of a minute of each other on the headline average while the number of people made worse off varies by a factor of nearly three
- Distinct from re-sorting a finished
Add an
objectives="custom"model, for ranking onrank_onwith no model constraints at all. It requiresrank_onand rejectsmax_value_cutoff(usehybrid_p_medianfor that). Mostly useful so the returnedSiteSolutionSetdoesn’t name a textbook model the run didn’t perform;describe_models()lists it, honestly flagged as not being from the location-allocation literatureAdd
SiteSolutionSet.ranking_metric, theMetrica solve actually ranked and pruned on. Read it rather thanobjectiveswhen reporting what was optimised –plot_sites(),plot_n_best_combinations()and the distribution plots pick which numbers to annotate fromobjectives, which stops describing the ranking underrank_on, so they now name the real metric alongside. Annotations for a solve withoutrank_onare unchangedAdd
Metric.format_value(), the level counterpart to the existingformat_delta()– renders one value with the metric’s own unit, decimals and percentage handlingAdd
SiteSolutionSet.site_capacity_summary(), comparing a chosen solution’s allocated demand (fromsite_allocation_summary()) against capacity registered viaadd_sites(capacity_col=...)– “does the allocation fit?”, wheresite_allocation_summary()answers “who gets allocated where?”. Diagnostic only: it does not feed back intosolve(), which still has no capacitated search strategy (capacitated=TrueraisesNotImplementedError)capacity_colis resolved at call time (falling back to the registered one), mirroringtwo_step_floating_catchment()’s call-timesupply_col, so the same solution can be scored under a different capacity definition without re-registering sitesdemand_to_capacity_rate(default1.0) converts allocated demand into capacity units (e.g. appointments consumed per unit of demand), since the two are usually measured differently. Must be a positive scalar- Always returns
n_regions,allocated_demand,allocated_load,capacity, andallocated_utilisation_ratio(allocated_load / capacity, not clipped above1.0); a zero-allocation site gets an explicit0.0ratio, notNaN– genuinely measured and idle - Additionally returns
current_load/baseline_utilisation_ratio(whichever was registered),headroom,incremental_headroom_ratio, andresidual_headroomwhenevercurrent_load_colorutilisation_colwas registered viaadd_sites() allocated_utilisation_ratioandincremental_headroom_ratiodeliberately encode contradictory assumptions – the former treats allocated demand as replacing today’s activity (the whole-network reallocationsolve()actually models), the latter as landing on top of it (e.g. a new service line from existing sites) – and only one is usually right for a given study; see the method’s Notes before trusting either number- Warns rather than silently reading as “measured and empty” when a selected site has no registered capacity, and warns when the selected solution has unreachable demand (which is excluded from allocation entirely, so every ratio understates true load with no visible tell the way
proportionhas) - Zero capacity with a nonzero allocation gives
inf, not a clipped or coerced value
Add
SiteSolutionSet.plot_site_capacity_summary(), a bar chart ofsite_capacity_summary()’s two ratios with a reference line at “exactly at capacity” and over/under bars in distinct colours (semantic, not per-site, since the finding is whether a site fits, not which site is which). An infinite ratio is drawn at a finite length (past the largest finite bar) so the axis stays usable, labelled “∞” rather than a number- Its static (non-interactive) branch takes an
axparameter, for embedding this chart as one panel of a larger figure. When given, the caller owns the figure’s lifecycle – this method neither callsplt.tight_layout()nor closes it afterwards
- Its static (non-interactive) branch takes an
Add
SiteSolutionSet.plot_allocated_utilisation(), the map counterpart: colours each selected site by itsallocated_utilisation_ratio, mirroringplot_site_utilisation()’s marker conventions (reversed colormap: high ratio is bad). Unselected candidate sites are not drawn, since grey already means “no capacity registered” on this mapAdd
metric="allocated_demand"/metric="n_regions"toplot_site_allocation_summary(), for plotting the raw headcount/region-count allocated to each site instead of only its share ("proportion") or"average_travel_cost"Add a
captionparameter toplot_site_utilisation(), matching the convention onplot_accessibility()/plot_pareto_summary()/plot_site_reallocation_matrix()/plot_population_impact_histogram()two_step_floating_catchment()’sper_capitanow rejects non-positive or non-numeric values, since a value of0would silently zero out every region’s accessibility score regardless of supply or demandAdd
SiteSolutionSet.show_solutions_summary(), a stakeholder-facing view ofsolution_dffor readers who aren’t going to parse ~30-40 jargon-named columns (weighted_average,inter_tertile_ratio, …) with no units and placeholderNone/NaN/“N/A (No equity data)” columns whenever equity data, a coverage threshold, or a baseline weren’t registered- Always shows
Sites in this optionandSites not in this option(site names joined into a readable string, not a list pandas truncates mid-entry – the latter is every registered candidate site absent from that solution, in canonical site-index order),Average travel time (mins), andLongest journey (mins);Ranktoo for a multi-solutionSiteSolutionSet - Adds a coverage section (
People within <threshold> mins,% within <threshold> mins) only ifthreshold_for_coveragewas set, a population-impact-vs-baseline section (People with a longer/shorter journey,% of cohort with a longer/shorter journey,Avg increase/reduction for them (mins)) only if a baseline was supplied, and an equity section (Equity gap (mins, best vs worst group)plus the two plain-English equity verdicts) only if equity data was registered – a section absent from the input is omitted rather than shown full of placeholders - People counts are whole numbers, travel times are rounded to 1 decimal place, coverage is given as both a headcount and a percentage, and any
NaN(e.g. the average reduction when nobody’s journey actually improved) is filled with 0 rather than left blank - Adds
Additional sites chosen(seeadditional_site_namesbelow) when at least one required site is configured - Adds
Sites closed/Sites added(seesites_closed_vs_baseline/sites_added_vs_baselinebelow) when a baseline was supplied - Adds
Sites added (vs <reference>)/Sites removed (vs <reference>)/Sites changed (vs <reference>)(a per-row site-name diff, plus the size of the symmetric difference) whenever there’s more than one solution to tell apart – makes near-identical top-N rows (e.g. a brute-force search that only ever swaps one or two sites out of many) distinguishable without a baseline, unlikeSites closed/Sites addedabove (which requiresolve(baseline=...)). The reference is controlled by the newdiff_againstparameter:"default"(the default) uses the sites flagged viaadd_sites(required_sites_col=...)if any are configured, else falls back to"rank_1";"rank_1"diffs every row against the top-ranked solution;"previous_rank"diffs every row against the row one rank better than it;"required_sites"diffs directly against the required-sites set, raising if none are configured (an explicit request for a reference that doesn’t exist, unlike"default"’s silent fallback) - Add the same
diff_againstoption (Noneby default, so existing behaviour is unchanged) toplot_pareto_summary()andplot_pareto_facets(): each Pareto-optimal option’s legend label/subplot title gains a compact “+added; -removed” site-name diff against the chosen reference, so a reader can see what’s actually different about a trade-off option without cross-referencingshow_solutions_summary()separately. Computed over just the Pareto-optimal front, so"previous_rank"compares each option to the next-best Pareto-optimal one, not a dominated solution sitting between them in rank
- Always shows
Add
unselected_site_names,additional_site_names,sites_closed_vs_baseline, andsites_added_vs_baselinetosolution_df(EvaluatedCombination.return_solution_metrics()), so all four are available to any caller ofshow_solutions(), not onlyshow_solutions_summary()unselected_site_names: every registered candidate site absent fromsite_names, in canonical site-index order – always present, since it doesn’t depend on any optional registered data.show_solutions_summary()’sSites not in this optionnow just formats this column rather than recomputing itadditional_site_names:site_nameswith the sites flagged viaadd_sites(required_sites_col=...)removed – e.g. for a “we have 4 sites and are opening 1 more” problem, this is just the new site, rather thansite_namesrepeating the same 4 required names identically across every solution, making them hard to tell apart at a glance. Only present when at least one required site is configured; absent, not an empty list, otherwisesites_closed_vs_baseline/sites_added_vs_baseline: the baseline’s own site names absent from this solution, and vice versa – the set difference a reader would otherwise have to compute themselves to tell several near-identicalsite_namesrows apart. Only present when a baseline was supplied viasolve(baseline=...)(orbaseline_costs=directly); absent, not empty lists, otherwise
Add
SiteSolutionSet.describe_solution_columns(), a grouped, beginner-facing alternative toshow_solutions_colnames()’s flat column list – prints (or returns, viareturn_dict=True)solution_df’s columns bucketed into “Which sites”, “Travel cost”, “Coverage”, “Equity”, “Change vs a baseline”, “Left behind (beyond a threshold)”, and “Underlying per-region data”, each with a one-line explanation- A group whose columns are genuinely absent (e.g. “Change vs a baseline” without
solve(baseline=...)) is omitted entirely; “Coverage” and “Equity” are likewise omitted whenever their (always-present-in-schema) columns hold nothing but placeholder values, rather than column presence alone deciding whether to show them - Secondary travel-matrix/demand-scenario columns (
<base>__<label>) are grouped by their base name alongside the primary column - Purely additive: doesn’t touch
solution_dforshow_solutions()’s existing column set
- A group whose columns are genuinely absent (e.g. “Change vs a baseline” without
Add population-impact-vs-baseline metrics, answering “how many people’s journey actually changed, and by how much?” rather than only the region-wide
weighted_averageshift, which dilutes a large, genuinely local effect across everyone else who is unaffected by it- Add
SiteProblem.evaluate_baseline(), evaluating the current (“do-nothing”) network as a one-solutionSiteSolutionSetfor use as a baseline. With nosite_names/site_indices, defaults to the sites flagged viaadd_sites(required_sites_col=...) - Add
SolutionComparator.population_impact_summary(), a per-demand-location diff ofset_b(candidate) againstset_a(baseline):demand_improved/demand_worsened/demand_unchanged,regions_improved/regions_worsened/regions_unchanged,mean_reduction_among_improved,mean_increase_among_worsened,max_reduction,max_increase,proportion_demand_improved/proportion_demand_worsened, andtotal_demand. Takesmatrix=/demand=to diff a registered secondary travel matrix or demand scenario instead of the primary, and ameaningful_change_threshold(default0.0) below which a region counts as unchanged rather than improved/worsened. All magnitudes are reported positive, with direction carried by the bucket name rather than by sign- Returns a single-column
pandas.DataFrame(index = metric name) by default, so it displays cleanly in a notebook; passas_dict=Truefor the rawdict(e.g. to pull out one value for further computation). Either way, every value is a native Pythonint/floatrather than a numpy scalar, avoiding numpy >=2.0’snp.float64(...)repr wrapper showing through on a bare dict
- Returns a single-column
- Add
solve(baseline=...), threading the same comparison through every enumerated combination automatically:None(default, off,solution_df’s column set is unchanged),True(build fromrequired_sites_col, inheriting this call’s objective/weights/threshold), or a one-solutionSiteSolutionSet(typically fromevaluate_baseline()). The baseline is evaluated once persolve()call, not once per combination, so the added cost is negligible for every search strategy (brute-force, greedy, GRASP). Addsdemand_improved/demand_worsened/demand_unchanged,proportion_demand_improved/proportion_demand_worsened,regions_improved/regions_worsened/regions_unchanged,mean_reduction_among_improved,mean_increase_among_worsened,max_reduction,max_increasetosolution_df - Secondary travel matrices and secondary demand scenarios get the demand-weighted subset of columns (
demand_improved__<label>, etc.) by default;full_secondary_metrics=Truealso adds the region-count and max-change variants for secondary travel matrices, matching the existing “core metrics by default” convention - Add
SolutionComparator.plot_population_impact_summary(), a two-panel bar chart pairing the region-wideweighted_averageshift againstpopulation_impact_summary()’s per-region view, so the “dilution” is visible at a glance.by="demand"(default) counts people;by="regions"counts LSOAs. Takes anax=pair to embed the two panels in a larger layout - Add
SolutionComparator.population_impact_phrase(), turningpopulation_impact_summary()into a stakeholder-facing sentence, e.g. “46,907 people (9.0% of the cohort) get a shorter journey, averaging 16.1 minutes off. 61 of 729 regions improved; 0 worsened; 668 unchanged.” Only mentions a “longer journey” clause if people are actually worse off, so the common superset comparison (adding a site while keeping every existing one) doesn’t read a redundant “0 people …” sentence - Add
SolutionComparator.population_impact_worst_affected(), a top-ndrill-down table (defaultn=10) naming the specific demand locations hit hardest by a change –Before/After/Change(suffixed with the travel matrix’s registered unit, e.g.Before (minutes), if one was registered),Previous site/New site(that location’s closest site underset_a/set_brespectively, so “closed site X’s demand went to site Y” reads off a single row), andPeople affected, ordered most- to least-affected.direction="worsened"(default) answers “who is worst off?”;direction="improved"answers “who benefits most?”. Built from the same per-region diff aspopulation_impact_summary(return_per_region=True)(which now also exposesprevious_site/new_siteon its ownreturn_per_region=Trueoutput), so a reader isn’t left to infer that an aggregate “9,669 people worsened” figure affects somewhere in particular without naming it - Add
SolutionComparator.decision_summary(), bundling everything a decision-maker needs to weigh a candidate against a baseline into one board-paper-style paragraph – which sites close/open,population_impact_phrase()’s people-affected sentence, theworst_affected_n(default 3) hardest-hit places by name, and the candidate network’s own equity verdicts – rather than assembling those four pieces by hand as the example notebooks previously did. Each clause is omitted (not shown blank) when it doesn’t apply: no site changes, nobody worsened, or no equity data registered - Add
SolutionComparator.plot_population_impact_map(), the map counterpart: colours each region by its change in travel time.direction="all"(default) shows every region on a diverging scale centred on 0 (red=longer, blue=shorter);direction="worsened"/"improved"restrict the coloured regions to that bucket alone on a plain sequential scale, with everything else shown greyed-out for geographic context. Takes the samen=aspopulation_impact_worst_affected()to cap the coloured regions to thenmost affected (only valid alongsidedirection="worsened"/"improved"– raises rather than silently ignoringnunderdirection="all", which already shows every region). Requires aregion_geometry_layer(add_region_geometry_layer())show_sitesoverlays candidate sites as points, mirroringplot_best_combination()’s own site-marker logic:"all"plots every site, with any closed relative to the baseline (inset_a’s solution but notset_b’s) picked out as a distinct marker shape (a black “X” by default – deliberately not colour, to avoid clashing with the red/blue region scales this plot itself uses);"closed"plots only the closed sites.closed_site_color/closed_site_marker/closed_site_markersizerestyle it. Requires point geometry on the candidate sites (raises otherwise, matchingplot_best_combination()’s own requirement)
- Add
SolutionComparator.plot_population_impact_histogram(), an overlaid before/after travel-cost distribution (set_avsset_b), weighted by demand where available so the plotted mass represents people rather than regions, with reference lines and a legend annotating each side’s weighted mean and maximum travel cost. The distributional counterpart topopulation_impact_summary()’s aggregate numbers – shows the whole shape of the shift, including anything the improved/worsened bucketing collapses (e.g. a long unaffected tail).kind="kde"(the default) draws a smoothed kernel density estimate viaseaborn.kdeplot, usually easier to compare by eye than two overlapping sets of bars;kind="hist"draws a traditional binned histogram instead - Fix
plot_population_impact_summary()not respectingmatrix=for its left-hand region-wideweighted_averagepanel – it diffed the requested secondary matrix’s costs correctly, but the panel showing the before/after averages always read from the primary matrix regardless
- Add
Add
SolutionComparator.site_reallocation_matrix(), cross-tabulating each demand location’s closest site underset_aagainst its closest site underset_b– answers “if we close this site, where does its demand actually go?” and “if we open this site, whose demand does it take?” in a single table, rather than only the per-site aggregatescompare_site_allocation()already gives. Rows areset_a’s selected sites, columns areset_b’s, cell values are the demand (or region count, viaby="regions") whose closest site moved from row to column; every selected site gets a full row/column (0.0, notNaN, if it captures no reallocated demand), and the diagonal is the demand that didn’t move at all. Takes the samematrix=/demand=arguments aspopulation_impact_summary()- Each axis is ordered persisting sites first (in canonical site-index order), then that axis’s own closed/newly-opened sites last – otherwise an unrelated unchanged site could sort after the genuinely closed/opened ones purely by candidate index, burying the actual reallocation in the middle of the table instead of grouping it at the bottom/right where it’s easy to scan
- Add
changed_only=False: passTrueto drop every row/column whose site saw no reallocation at all. Rows and columns are dropped independently – a site that kept 100% of its own patients but also gained new ones from elsewhere keeps its column (something arrived) even though its row is dropped (nothing left) - Add
SolutionComparator.plot_site_reallocation_matrix(), a heatmap ofsite_reallocation_matrix()(including its ownchanged_only=False) – a sequential (not diverging) colour scale, since reallocated demand has no natural “centre” and is never negative.caption=None(the default) prints a stakeholder-facing “how to read this” explanation below the chart; pass""to suppress it or a custom string to replace it, matchingplot_pareto_summary()’s existingcaptionconvention
Add absolute coverage headcounts alongside the existing coverage proportions –
demand_within_coverage_threshold/regions_within_coverage_thresholdonsolution_df(and their__<label>secondary-matrix/demand-scenario variants), the literal headcountproportion_within_coverage_threshold/proportion_regions_within_coverage_thresholdalready compute internally but didn’t expose, for reporting to audiences who find an absolute number (“391,823 people”) clearer than a percentage- Add
SiteProblem.total_demand, a stable read-only sum of the registered demand column (Noneif none registered), for sanity-checking the new headcounts independently rather than recomputing the total ad hoc - Add
SolutionComparator.population_impact_summary()’sdemand_newly_covered/demand_newly_uncovered/regions_newly_covered/regions_newly_uncovered– the GROSS number of people/regions crossingthreshold_for_coveragein each direction betweenset_a(baseline) andset_b(candidate), which a net change in the coverage proportion can mask (e.g. equal gains and losses cancelling out). Only present when a coverage threshold was assessed on both sides
- Add
Add
beyond_thresholdstoevaluate_single_solution_single_objective(),evaluate_baseline(), andsolve()– one or more “left behind” travel-cost thresholds, surfacing how many people/regions are beyond a cutoff rather than only the single worst-casemax. Addsdemand_beyond_threshold_<t>/regions_beyond_threshold_<t>tosolution_dfper thresholdt(plus a_by_equity_groupdict variant of each when equity data is registered)- Deliberately a distinct parameter from
threshold_for_coverage, not an overload of it: “covered” (good) and “beyond” (bad) cross the threshold in opposite directions, and unlikethreshold_for_coverage, this accepts more than one value at once - A demand location with no reachable site (
NaNtravel cost) counts as beyond every threshold, matchingwithin_threshold’s “NaNcost -> not covered” convention applied to the opposite direction - Absent, not
NaN, onsolution_dfunless requested – no effect onsolution_df’s schema for existing callers
- Deliberately a distinct parameter from
Add
SolutionComparator.population_impact_by_equity_group(), splittingpopulation_impact_summary()by equity band: per-bandregions_improved/regions_worsened/regions_unchanged,demand_improved/demand_worsened/demand_unchanged,band_total_demand, and the rate-normalisedproportion_of_band_improved/proportion_of_band_worsened– share of THAT band’s own population, the number that actually distinguishes “helps everyone equally” from “helps the most deprived disproportionately more, or less” (a raw headcount alone can’t, since a larger band can look like it benefits most while a smaller share of it actually improved). Raises clearly, namingadd_equity_data(), if no equity data is registeredsolve(baseline=..., ...)on a problem with equity data registered also gainsdemand_improved_by_equity_group/demand_worsened_by_equity_groupdict columns onsolution_df– the same breakdown without needing to build aSolutionComparatorpopulation_impact_phrase()gains two further clauses when equity data is registered: the rate-normalised most- vs least-disadvantaged-tertile comparison, and – unconditionally, whenever non-zero – how many people in the most disadvantaged tertile specifically saw a worse outcome, so a benefits-only summary can never silently omit a concentrated harmpopulation_impact_phrase()’s wording also now statesmeaningful_change_thresholdexplicitly whenever it is above0(“a journey shorter by more than 5.0 minutes” rather than a bare “a shorter journey”), so a reader isn’t left to separately check what threshold produced the headline- Add
SolutionComparator.plot_population_impact_by_equity_group(), a paired improved/worsened bar chart per equity band, ordered most- to least-disadvantaged – deliberately plots both directions rather than only the improved rate, since a benefits-only equity chart can hide that a candidate makes some disadvantaged areas worse off
Add
allow_missing/treat_as_missingtoadd_travel_matrix()/add_secondary_travel_matrix(), so a travel matrix can genuinely have no feasible journey for some origin-destination pairs (e.g. no public transport route within a permissive search radius) instead of forcing a sentinel value like9999– which corrupted every average and map colour scale it touched – just to avoid a crashallow_missing=False(the default, matching existing behaviour): a missing (NaN) travel cost raises immediately, since an unnoticed NaN more often means an ID mismatch or a botched generation run than a genuinely unreachable pair.add_travel_matrix()previously had no such check at all – a NaN silently passed through registration and only surfaced later as a crypticValueError: Encountered all NA values(or worse, silently NaN’d every downstream average) the first time it was evaluated; it now raises clearly, at the point the bad data was actually suppliedallow_missing=Trueopts in:min_cost/selected_site/within_threshold(and their secondary-matrix__<label>equivalents) now handle a demand location with no reachable selected site correctly rather than crashingevaluate_single_solution_single_objective()/solve()withValueError: Encountered all NA values(fromDataFrame.idxminon an all-NaN row)treat_as_missing=<value or callable>converts an existing sentinel (e.g.9999) already baked into the input data to a proper missing value before anything else runs (including unit conversion, so the sentinel is always matched in the caller’s original units)weighted_average/unweighted_average/90th_percentile/max(and their per-equity-group breakdowns) are now computed over reachable regions only, rather than a single unreachable row silently NaN-poisoning the entire solution’s averages via plainnp.average/np.percentile/np.maxpropagation. How much was excluded is reported alongside them, never silently: newregions_unreachable/demand_unreachable/proportion_demand_unreachablemetrics onsolution_df(plusregions_unreachable_by_equity_group/demand_unreachable_by_equity_groupwhen equity data is registered, and__<label>secondary-matrix/demand-scenario variants),0/0.0for every problem that never opts intoallow_missingadd_secondary_travel_matrix()’s existing NaN rejection (raised oncesolve()builds its aligned frame, since the completeness check needs demand/site data that may not exist yet at registration time) now also respectsallow_missing; its error message points atallow_missing=Trueinstead of suggesting a sentinel fillsolve()(search/optimisation) does not support a primary matrix that actually contains a missing value on its own – it raisesNotImplementedErrornaming the reason (ranking on reachable-only averages would silently reward a combination for stranding more demand, since the excluded rows simply vanish from the average rather than counting against it) unlessunreachable_costis also supplied (see below). Secondary travel matrices are unaffected, since they never drive optimisation, only reporting
Add
solve(unreachable_cost=...), the explicit cost policy the previous entry’sNotImplementedErrorasks for: a finite travel cost substituted for every unreachable pair, used ONLY to rank/prune combinations during search – never insolution_df’s reportedweighted_average/unweighted_average/90th_percentile/max, and never in any plot, both of which stay honest, reachable-only figures throughout (see the previous entry)- Required for every objective except
"mclp", whose coverage-proportion ranking already treats an unreachable pair as “not covered” – correctly bad, with no equivalent silent-reward failure mode – so it accepts a matrix with missing values regardless of whetherunreachable_costis set - The substituted view is exposed alongside the honest one as
weighted_average_for_ranking/unweighted_average_for_ranking/max_for_rankingonsolution_df(and onEvaluatedCombination/evaluate_single_solution_single_objective(unreachable_cost=...)directly) – always present, identical to their honest counterparts wheneverunreachable_costisn’t used, so the column exists unconditionally and needs no special-case handling max_value_cutoff(the hybrid objectives’ worst-case-travel guarantee) is enforced againstmax_for_ranking, not the honestmax– a combination whose only problem is an unreachable pair is correctly judged against the assumed cost of that failure, rather than appearing to always satisfy the cutoff because the stranded rows were excluded from its reported worst case- Applies uniformly across every search strategy (brute-force, greedy, GRASP) and their cost-weighting (
weights={"cost": ...}) and diversity/local-search machinery, since all of it already ranks generically off whichever column_get_ranking_by_objective()resolves to
- Required for every objective except
Every plot that shows a region’s travel cost now renders an unreachable region (
add_travel_matrix(allow_missing=True)) as a distinct grey fill rather than silently leaving it unfilled – geopandas draws nothing at all for a missingcolumnvalue unlessmissing_kwdsis passed, an invisible hole in the map indistinguishable from “outside the study area”. Coversplot_best_combination(),plot_n_best_combinations(),plot_solution_comparison(),plot_solution_sets_comparison()(cost-based andplot_site_allocation=Truecolouring both), andplot_combination_by_equity()’s per-band panels- Each of those plots’ default title now also states how many regions were unreachable (e.g. “12 regions unreachable”), omitted entirely for the overwhelming majority of problems that never see one.
plot_travel_time_distribution()’s per-solution label gains the same count (“Unreachable: 12”), since Plotly’s histogram silently excludes a missing value from the bars/density with no indication anything was left out plot_site_allocation_summary()’s title states the excluded share too (e.g. “Share of demand closest to each site (12 regions unreachable, excluded)”), since its bars otherwise silently sum to less than 100% with nothing explaining the shortfall –site_allocation_summary()’s docstring is corrected to match (it previously claimedproportion“sums to 1.0 across the frame” unconditionally, which is only true when nothing is unreachable)- Fix
plot_n_best_combinations()’s shared colour-scale computation using Python’s builtinmin()/max()over a list of per-solution.min()/.max()results, which is NaN-order-dependent (silently unreliable) whenever one of the plotted solutions is entirely unreachable – now computed via a single concatenated, NaN-skipping pandas reduction instead - Extend the multiple travel matrices example with a worked demonstration on real sample data: the bundled public-transport matrix already contains a
9999sentinel for a handful of unrouteable pairs, shown corrupting a single-site solution’s demand-weighted average to over 12 hours, then fixed withallow_missing=True, treat_as_missing=9999
- Each of those plots’ default title now also states how many regions were unreachable (e.g. “12 regions unreachable”), omitted entirely for the overwhelming majority of problems that never see one.
Other
- Fix
describe_models()’s closing instruction pointing at aprob.solve_pmedian(p=3)method that doesn’t exist – it now readsprob.solve(p=3, objectives="p_median"), the actual public API used everywhere else - Fix several colour bars/axes showing a bare, jargon-named column instead of a plain-English label with units:
plot_best_combination()’s default (cost-based) map andplot_n_best_combinations()’s shared colour bar were unlabelled entirely;check_solution_equity()andplot_combination_by_equity()labelled their axes/titles/colour bar with the rawmin_cost/equity-column name (e.g. a bare “IMD15”). All four now read “Travel time to nearest site”/“Average travel time”, with the registered unit appended in parentheses when one was set viaadd_travel_matrix(unit=...); the equity axis/title now uses the human-readableadd_equity_data(label=...)instead of the raw column name - Fix
check_solution_equity()’s bar chart (return_plot=True) not saying which end is “most deprived”, unlikeplot_population_impact_by_equity_group()’s explicit “(most to least disadvantaged)” wording – bars are now reordered most- to least-disadvantaged peradd_equity_data(disadvantaged_end=...)(via the same tertile logic aspopulation_impact_by_equity_group(), now shared through a new_order_bins_most_to_least_disadvantaged()helper) and the equity axis is labelled accordingly.plot_top_n_solution_equity()inherits the fix automatically, since it just callscheck_solution_equity()per subplot. The non-plot DataFrame return (return_plot=False) is left in its original ascending order – only the chart’s bar order/label changes, so this isn’t a breaking change to the returned data - Fix
SiteProblem.plot_region_geometry_layer()’s static plot (plot_demand=True/plot_equity=True) having a completely unlabelled colour bar and no way to add a title at all – a distinct code path from the solution-level plots already fixed above (this one builds its own choropleth directly rather than going through_plot_single_solution_map()). The colour bar now reads “Demand” or the human-readableadd_equity_data(label=...)(falling back to the raw equity column name if none was registered); a newtitleparameter sets the plot’s title viaax.set_title(), ignored wheninteractive=Truesince a Folium map has no equivalent built-in title support - Fix
plot_region_geometry_layer()’s plain branch (neitherplot_demandnorplot_equityset) ignoringplot_region_of_interest_onlyentirely – it always plotted the full, unfilteredregion_geometry_layerregardless of the flag, and passingplot_region_of_interest_only=Trueactually raisedUnboundLocalError(the filtered frame was assigned to a variable it then never used, and the assignment itself referenced that variable before it existed). Both the static and interactive plots now correctly plot the region-of-interest-filtered frame when the flag is set - Fix
plot_solution_sets_comparison()/SolutionComparator.plot_comparison()’s side-by-side maps each autoscaling their own colour bar independently – e.g. comparing a car-travel solution (spanning 3-12 minutes) against a public-transport one (spanning 30-120 minutes) rendered both with visually identical colour gradients, making the five-times-longer PT journeys look like a similar spread of outcomes. Panels using the default cost-based colouring now share onevmin/vmax(computed from their own selected solutions) by default; passshared_color_scale=Falseto restore each panel’s independent scale. Panels usingplot_site_allocation/plot_regions_not_meeting_threshold(categorical/fixed[0, 1]scales already) are unaffected either way - Fix
gap_absolute_description’s wording (“Spread of 2.6 units between best and worst groups”) always using the literal word “units” regardless of what unit the travel matrix was actually registered with – now names the real unit (e.g. “Spread of 2.6 minutes”), falling back to “units” only when none was ever registered - Fix
plot_population_impact_by_equity_group()’s x-axis label using the raw equity column name (e.g. the real-world IMD dataset’s unbalanced-parenthesis “Index of Multiple Deprivation (IMD) Decile (where 1 is most deprived 10% of LSOA”) instead of the human-readableadd_equity_data(label=...) - Fix
plot_combination_by_equity()’s per-group panels not saying how many regions or how much demand are actually in that group – a group with very little demand rendered as a near-empty map, visually indistinguishable from a well-served group with no problem at all. Each panel’s title now includes the region count and population headcount, e.g. “IMD decile: 1 (12 regions, 4,821 people)” - Fix
plot_population_impact_histogram()’s defaultkind="kde"view labelling its y-axis “Density (people-weighted)”, a statistics term meaningless to a non-technical reader. The axis now reads “Relative share of people”/“Relative share of regions”, and a newcaptionparameter adds a stakeholder-facing footnote by default (only forkind="kde", since a histogram’s bar heights are already literal counts needing no explanation) clarifying that curve height is a relative share, not a literal headcount, and namingkind="hist"as the way to get literal counts. Passcaption=""to suppress it, or a custom string to replace it - Fix accessibility/evaluation methods crashing with a cryptic pandas
TypeError(“Can only merge Series or DataFrame objects, a <class ‘NoneType’> was passed”) when no travel matrix had been registered – e.g. callingplot_accessibility()/two_step_floating_catchment()orevaluate_single_solution_single_objective()beforeadd_travel_matrix(). These now raise aValueErrornaming the missing registration step (.add_travel_matrix(), or.add_demand()for the equivalent missing-demand case on the evaluate path), matching the friendly checksolve()already had - Fix
get_hotspots()(and the other spatial-weights-based EDA methods) surfacing a libpysalFutureWarning(“use_index defaults to False but will default to True in future”) to the user on every rook/queen run –use_index=Trueis now passed explicitly, matching both the upcoming libpysal default and what the k-nearest branch already did, so all three neighbourhood methods now label neighbours by dataframe index consistently. The analysis results are unchanged (value alignment is positional); only the neighbour labels inside the cachedspatial_weightsobject differ for rook/queen- Also pin
esda<3: esda’s next major release changesMoran_Local’s default alternative hypothesis (directed to two-sided), which would roughly double p-values and silently reclassify borderline hotspots/coldspots as “Not Significant”. esda 2.9 has no parameter to pin either behaviour, so the version cap holds results steady until lokigi adopts one deliberately (see the comment at theMoran_Localcall insite_eda.py)
- Also pin
- Fix interactive maps (
interactive=Trueonplot_sites(),plot_region_geometry_layer(),plot_accessibility(),plot_site_utilisation(),plot_hotspots(),plot_quadrant_map()) rendering fully zoomed out to the whole world when the page put them inside a hidden container – most visibly every map on a Quartorevealjsslide other than the first, and any map in an inactive Quarto tabset panel. Leaflet derives its zoom from the pixel size of the map container, so a map initialised while its container isdisplay: nonemeasures 0x0 and clamps to zoom 0; showing the slide later fixes the tile layout but never re-runsfitBounds, leaving the map stuck zoomed out. Each map now carries a one-shotResizeObserverthat re-applies the correct bounds the first time the container gains a real size. Maps that were already laid out when they loaded (the normal HTML case) skip the observer entirely, and it disconnects after firing once, so it never overrides the reader’s own panning or zooming- Escape hatch: set
lokigi.plot_utils.DEFERRED_FIT_BOUNDS = Falseto stop attaching the guard
- Escape hatch: set
- Pin minimum version of numba to prevent build failure due to odd resolution to a very old version of numba by default
v0.8.0
- Add
add_secondary_demand()toSiteProblem, registering additional demand scenarios (e.g. current vs projected future demand, or time-varying demand) alongside the primary demand set viaadd_demand(), mirroringadd_secondary_travel_matrix()- A secondary demand scenario never drives site selection or search/pruning – the primary demand always does. Instead each registered scenario contributes
weighted_average__<label>andproportion_within_coverage_threshold__<label>columns – the only two metrics that actually vary with demand – to every solutionsolve()produces, so scenarios can be combined viacompute_pareto_front()or blended into the objective viaweights={"future_demand": 0.4, ...}, exactly like a secondary travel matrix’s columns - By default a secondary demand scenario only re-weights the primary travel matrix, keeping the added output additive (2 columns per scenario) rather than multiplying against every registered secondary travel matrix as well. Pass
also_weight_matrices=[...]to opt a scenario into also weighting one or more secondary travel matrices, producingweighted_average__<travel_label>__<demand_label>columns for just those combinations two_step_floating_catchment(),site_allocation_summary()(andSolutionComparator.compare_site_allocation()), andplot_region_geometry_layer()/plot_site_allocation_summary()all gain ademand=<label>argument, mirroring the existingmatrix=<label>argument, to score or plot under a chosen demand scenario instead of the primary- Secondary demand scenarios and secondary travel matrices share the same
labelnamespace (both suffix__<label>onto column names), so labels must be unique across both
- A secondary demand scenario never drives site selection or search/pruning – the primary demand always does. Instead each registered scenario contributes
- Add
two_step_floating_catchment()toSiteProblemandSiteSolutionSet, computing 2SFCA accessibility – how much supply (e.g. GPs, beds, appointment slots) is actually available to each demand region, once competition from other regions for the same sites is accounted for- Unlike binary threshold coverage, two regions equally within a site’s catchment can get different accessibility scores if one of them shares that site with far more competing demand, or has fewer other sites in reach
supply_colnames a numeric column oncandidate_sitesat call time rather than being registered viaadd_sites(), so the same problem can be scored under different supply definitions (doctors vs beds vs slots) without re-adding sitesSiteProblem.two_step_floating_catchment()scores an arbitrary site set directly – nosolve()is required. With nosite_names/site_indices, every registered candidate site is scored, which is only the same thing as “the current network” if the candidate pool doesn’t also include not-yet-built proposals – pass the currently-open subset explicitly if it does.SiteSolutionSet.two_step_floating_catchment()takes the samerank_on/solution_rank/site_names/site_indices/matrixsolution-selection arguments assite_allocation_summary()and scores that solution’s selected sites- Catchment membership uses an inclusive
<=againstcatchment_size, unlike the coverage metrics’ strict<againstthreshold_for_coverage– matches the standard 2SFCA convention rather than lokigi’s own coverage precedent - A site with no demand within
catchment_sizehas an undefined (NaN) supply-to-demand ratio and is excluded from every region’s accessibility score, with a warning naming it. A demand region with no site withincatchment_sizecorrectly scoresaccessibility == 0instead – a real “no supply available” result, kept distinguishable from the NaN case above return_site_ratios=Truealso returns the step-1 per-site table (supply,catchment_demand,n_regions_in_catchment,ratio), useful for finding which site is driving an implausible regional score- Descriptive only for now: does not feed into
solution_df,rank_on=, or anysolve()objective
- Add
distance_decay=totwo_step_floating_catchment()(SiteProblem,SiteSolutionSetandplot_accessibility()), enabling Enhanced 2SFCA – a softer catchment than a single hard cutoff, so a site 2 minutes away counts for more than one 14 minutes away instead of the two being identical just because both are “in catchment”catchment_size=is now optional; exactly one ofcatchment_sizeordistance_decaymust be given. Existing calls that only passcatchment_sizeare unaffected – it remains the single-band special case of the same underlying weight-matrix enginedistance_decaytakes two forms: a list of(upper_bound, weight)step-decay bands (Luo & Qi 2009’s E2SFCA, e.g.[(10, 1.0), (20, 0.68), (30, 0.22)]– their own published “weight set 1” for 0-10/10-20/20-30 minute zones, not an arbitrary example), or a dict describing a continuous kernel –{"method": "gaussian", "catchment_size": d0, "bandwidth": sigma}, Dai (2010)’s truncated Gaussian decay, weight 1 at distance 0 and weight 0 at the truncation radiuscatchment_size, decaying continuously in between; or{"method": "power", "catchment_size": d0, "scale": s, "alpha": a, "min_dist": m=0}, the classic gravity-model decay (weight(d) = (max(d, min_dist)/scale)**alpha, truncated beyondcatchment_size), parameterised to match pysal/access’sweights.gravity()n_regions_in_catchment/n_sites_in_catchmentnow count non-zero-weight membership rather than boolean in-range membership – the same thing undercatchment_size, generalised underdistance_decay- Cross-validated the generalised weight-matrix engine against pysal/access’s own published small hospital-accessibility test fixture (3 locations, 4 travel-cost scenarios, gravity-weighted 2SFCA) using
distance_decay={"method": "power", ...}– an independent implementation’s numbers, not just lokigi’s own hand arithmetic - Step 1/2’s elementwise-multiply-then-sum was replaced with a matrix-vector dot product (BLAS
gemvrather than a full N x M intermediate array), ~10-20x faster scoring hundreds of sites against thousands of regions in local benchmarks. Results are unaffected on valid data
- Add
plot_accessibility()toSiteProblemandSiteSolutionSet, mapping 2SFCA accessibility: a region choropleth ofaccessibility, overlaid with site markers. Markers are plain, uniformly coloured/sized location dots by default (site_colour/site_marker_size) – deliberately not colour/size-coded by anything, since that’s too easily misread as a raw capacity/overutilisation metric. Passshow_site_ratio=Trueto instead colour and size them by their step-1 supply-to-demandratio(red and small for an overloaded site, green and large for an uncontested one;site_cmap/missing_site_colour/marker_size_rangeapply in this mode only)- Computes
two_step_floating_catchment()automatically fromsupply_col/catchment_sizeand the usual selection arguments, following the same optional-precomputed-input pattern asplot_hotspots(hotspots_df=...); a precomputedregion_frame/site_framepair can be passed in instead (needed to plot aSiteSolutionSetresult at asolution_rank/rank_onother than the default) interactive=Truereturns a Folium map with both layers; the default returns a static matplotlib Axes with two colorbars- A site with an undefined (NaN) ratio – no demand in its catchment – is drawn in a distinct grey with its own legend entry (“No catchment demand”), since geopandas’
missing_kwdslabel only appears on a discretescheme=legend, not the continuous colorbar used here - Site markers are only drawn when
candidate_siteshas real point geometry, which includes tabular lat/long input (not just a GeoDataFrame passed directly toadd_sites())
- Computes
- Fix
plot_best_combination()(andplot_n_best_combinations()/plot_solution_comparison(), which share the same underlying helper) silently skipping site markers entirely for problems registered with tabular lat/long site data, instead of just the selected/unselected sites- The check gating site markers tested
_candidate_sites_type == "geopandas", which recordsadd_sites()’s input format rather than whethercandidate_sitesended up with real point geometry. Tabular lat/long input is converted to a GeoDataFrame internally but still leaves_candidate_sites_type == "pandas", so every problem registered that way – the most commonadd_sites()usage pattern – drew a region map with no site markers at all, with no error or warning
- The check gating site markers tested
- Fix site markers rendering at Folium’s default radius (2px, barely visible) on interactive maps from
plot_sites()andplot_site_utilisation(); both now useradius=8, matchingplot_accessibility()’s existing site-marker size - Fix
plot_pareto_summary()andplot_pareto_facets()collapsing the plot itself down to a sliver (in the worst case, near-zero width) when there are few metrics- Metric labels are rotated at a shallow angle along the x-axis; a long label overhangs a fair way past its own tick, and
tight_layout()reserves that overhang as margin regardless of the figure’s own width. With few metrics the default figure is narrow, so the roughly-fixed-size overhang could swallow nearly the whole figure. Long labels are now wrapped onto multiple lines before being set as tick labels, bounding the overhang to about onewidth_multiplier’s worth of characters
- Metric labels are rotated at a shallow angle along the x-axis; a long label overhangs a fair way past its own tick, and
- Add
site_allocation_summary()toSiteSolutionSet, reporting the share of demand (or of regions) whose closest selected site is each site in a chosen solution, and the average travel cost incurred by each site’s group- Answers “is this extra site worth opening?” – a site that is closest to only a small share of demand is a weak case for the capital cost, even where it lowers the average travel time
- Also answers “how much further would people have to travel if this site closed?” via the
average_travel_costcolumn – demand-weighted mean travel cost among a site’s closest regions by default (unweightedwhenby="regions"), inspired by work from Gill Baker showing that centralising services onto fewer sites would roughly double typical travel distance for patients, while a third site offered only limited further benefit by="demand"(the default) weights each region by the demand registered viaadd_demand();by="regions"counts every region equally, following the same people-vs-places naming rule as the coverage metrics above. Raises aValueErrorrather than falling back ifby="demand"is requested on a problem with no demand data, so a region count is never silently reported under a demand label- Selected sites that are closest to no region at all appear as explicit
0rows inn_regions/proportionrather than being dropped by the underlying grouping – a near-zero share is usually the finding being looked for.average_travel_costisNaNfor such a site instead of0, since there is no travel cost to average over zero regions - Takes the same solution-selection arguments as the plotting methods (
rank_on/solution_rank/site_names/site_indices) and the samematrix=keyword to summarise a registered secondary travel matrix instead of the primary one - Regions exactly equidistant from two selected sites are assigned to the lower-indexed one rather than split between them
- Add
plot_site_allocation_summary(), a horizontal bar chart ofsite_allocation_summary(). Uses the same “Set2” site colours asplot_best_combination(plot_site_allocation=True), so the chart reads as a quantitative version of the allocation map, and always labels each bar with its value so a site capturing 0% stays visiblemetric="proportion"(the default) plots allocation share;metric="average_travel_cost"plots the average travel cost column instead, labelled with the travel matrix’s registered unit (e.g. “10.0 miles”)- A zero-allocation site’s bar is drawn at zero length either way, but its label differs by metric: “0.0%” for
proportion(a real, meaningful value), versus “N/A” foraverage_travel_cost(there is no travel cost to average, and a “0” label there would misleadingly read as “instant to reach”)
- Add
SolutionComparator.compare_site_allocation(), putting two solutions’site_allocation_summary()results side by side with their difference – e.g. a 2-site solution against a 3-site one, showing how much of the new site’s catchment is genuinely new rather than taken from an existing site, or how much further a site’s former patients would now have to travel if it closedmetric="proportion"(the default) compares allocation share;metric="average_travel_cost"compares the average travel cost column instead- With
metric="proportion", a site absent from a solution isNaNand a site that is opened but closest to nothing is0.0, so “not opened” and “opened but unused” stay distinguishable. Withmetric="average_travel_cost"both cases areNaN, since neither has a travel cost to average
plot_pareto_facets()now quantifies each Strengths/Sacrifices entry with its rank, e.g.Sacrifices: Total build cost (18th of 18)instead of a bare metric name, since two “trade-off” metrics can differ hugely in how bad they are- New
rank_scope=argument controls what a rank is computed against:"all"(the default) ranks against every enumerated solution,"pareto_front"ranks only against the other Pareto-optimal solutions shown in the plot
- New
- Fix
plot_pareto_facets()subplot titles sometimes overlapping their own facet’s raw-value labels or bleeding into the next subplot- The title’s fixed 8pt
paddidn’t scale with its (variable) line count or leave headroom for theshow_raw_labelsvalue bubbles, which sit close to the top of the axes for a solution near the top of the normalised scale – most visible with a smallheight_per_row.padis now 20pt - The included-sites text wrapped to a fixed character width (
wrap_at) regardless of subplot width, so withncols>1a long site list could wrap wider than its own (narrower) column and overflow into the next one. The effective wrap width is now scaled down byncols
- The title’s fixed 8pt
- Fix
add_sites(capacity_col=...)silently accepting non-numeric valuescapacity_colwas never included in the numeric-column validation thatcost_colalready had, so a string-typed capacity column passedadd_sites()without error. Now validated the same way ascost_col,current_load_colandutilisation_col(below)
- Add
site_utilisation_summary()andplot_site_utilisation()toSiteProblem, reporting each candidate site’s real-world current utilisation – today’s baseline load against capacity, independent ofsolve()or any catchment/demand modelling (there is noSiteSolutionSetcounterpart, since there is nothing solved to select)add_sites()gains two new optional columns to register the baseline data:current_load_col(a raw current activity/caseload count, must be paired withcapacity_colso a ratio can be derived) orutilisation_col(a precomputed ratio/percentage, for analysts without raw counts). Giving both raises aValueErrorsite_utilisation_summary()returnscapacity/current_load/utilisation_ratio/headroom, each included only when derivable from whichever columns were registered. A site with no baseline data (typically not yet built) gets an explicitNaNinutilisation_ratio/headroom, not0.0–0.0would misleadingly mean “measured, and currently idle”. Values above 1.0 (genuinely over capacity) are left as-is, not clippedplot_site_utilisation()maps each site coloured and sized byutilisation_ratio. Deliberately inverted fromplot_accessibility()’s site markers:cmap="RdYlGn_r"(red = high utilisation = bad, here, versus red = low ratio = bad there), and larger markers mean a fuller site (versus smaller = worse there), so a hotspot is easy to spot. Raises aValueErrorifcandidate_siteshas no real point geometry, rather than silently drawing nothing
v0.7.0
Note: this is what is actually live on PyPI as lokigi==0.7.0. pyproject.toml was mistakenly bumped straight from 0.5.0 to 0.7.0 for what should have been the 0.6.0 release, so a 0.6.0 was never published. The entries below are what actually shipped under that version; the genuinely-new v0.8.0 work above had not yet been published as of this correction.
⚠️ Breaking changes
lokigi is pre-1.0, so breaking changes can land in any minor release. Read these before upgrading – each is detailed in the notes below.
proportion_within_coverage_thresholdandcoverage_by_equity_groupnow report demand-weighted values rather than counts of regions. Existing numbers change on any problem with non-uniform demand. The previous behaviour is available asproportion_regions_within_coverage_threshold/coverage_regions_by_equity_group- The
mclpobjective may now select a different combination of sites, because it ranks on the metric above. It now maximises covered demand, matching the textbook Maximal Covering Location Problem rank_on=with a coverage metric now returns the best-covering solution, not the worst. Anything ranking on a travel-cost metric is unchanged- Both coverage proportions are now
NaNrather than0.0when nothreshold_for_coveragewas supplied show_basemaphas been removed fromplot_region_geometry_layer(),plot_hotspots()andplot_quadrant_map(). Useadd_basemap, which is now the argument name on every plotting method. Passing the old name raises aTypeErrornaming the replacement
Notes
- Add support for secondary travel matrices via
add_secondary_travel_matrix(travel_matrix_df, source_col, label, ...)- Registers an additional travel/cost matrix (e.g. public transport alongside a primary car matrix) that is never used as the optimisation cost matrix – the matrix registered via
add_travel_matrix()always drives site selection, search, and pruning - Each registered secondary matrix contributes its own per-solution metric columns to
solution_df, suffixed__<label>(e.g.weighted_average__public_transport,min_cost__public_transporton the per-regionproblem_df), so a singlesolve()produces one candidate ranking with metrics for every registered matrix side by side – directly usable inParetoMetric(column=...),rank_on=..., and plots, without needing to.copy()the problem and solve twice - Any number of secondary matrices may be registered, each with its own
unit/from_unit/to_unitand optional per-matrixthreshold_for_coverage(falls back to the value passed tosolve()if not set) - Secondary matrices must be complete (a row for every demand location, a column for every candidate site, no missing values) –
solve()raises aKeyErrornaming the label and the specific gap otherwise, rather than silently producing metrics over a different denominator than the primary matrix - By default, each secondary matrix only contributes its core five metrics plus float-valued equity aggregations to
solution_df(not the dict-valued equity breakdowns or description strings, to keep the table from growing unboundedly with each registered matrix). Passsolve(..., full_secondary_metrics=True)to also include those, matching what the primary matrix already always returns - Plotting methods (
plot_best_combination,plot_n_best_combinations,plot_solution_comparison,plot_travel_time_distribution,check_solution_equity,plot_top_n_solution_equity,plot_combination_by_equity) accept a newmatrix=keyword to switch from the primary matrix to a registered secondary one plot_simple_pareto_front_pairs’sx_axis/y_axisparameters now accept anysolution_dfcolumn (previously typed as a fixedLiteralset that already undersold what was accepted)- Ranking on a secondary matrix’s columns (e.g.
rank_on="max__public_transport") only reorders candidates that were searched and pruned using the primary matrix – see the newadd_secondary_travel_matrix()docstring and themultiple_travel_matricesexample for thebrute_force_keep_best_n/_worst_ncaveat this implies SolutionComparatorand theproblem.copy()-per-mode workflow are unchanged and remain the right tool for two genuinely independent optimisations; secondary matrices are the alternative for trading modes off within one candidate ranking (see the new cross-reference in thecomparing_solutionsexample)
- Registers an additional travel/cost matrix (e.g. public transport alongside a primary car matrix) that is never used as the optimisation cost matrix – the matrix registered via
- Add
expand_dict_columnsandinplaceparameters toshow_solutions()show_solutions(expand_dict_columns=True)flattens every dict-valued column (weighted_by_equity_group,coverage_by_equity_group, etc., including their__<label>secondary-matrix equivalents underfull_secondary_metrics=True) into one column per dict key, named<column>__<key>. Off by default, sosolution_df’s shape is unchanged for existing callersshow_solutions(expand_dict_columns=True, inplace=True)also writes the expansion back tosolution_dfso it persists for later calls, plotting, andrank_on;inplacehas no effect unless combined withexpand_dict_columns=True, and warns if passed alone. Rounding is never made permanent
- Behaviour change: coverage metrics are now weighted by demand rather than counting every region equally
proportion_within_coverage_thresholdnow reports the proportion of total demand withinthreshold_for_coverage, weighted by the demand registered viaadd_demand(). Previously it was the proportion of regions, so a sparsely-populated LSOA counted as much as a dense onecoverage_by_equity_groupchanges in the same way, reporting demand-weighted coverage within each equity band- Because
mclpranks onproportion_within_coverage_threshold, themclpobjective may now select a different combination of sites on any problem with non-uniform demand. It now maximises covered demand, matching the textbook Maximal Covering Location Problem - Nothing changes for problems with uniform demand, including those that never call
add_demand()–solve()assumes equal demand in that case, which makes the demand-weighted and region-based figures identical - The weighting always uses the raw demand column, never the compound
weights=vector used byweighted_average, so the metric means “proportion of demand covered” regardless of what is passed toweights=
- Add
proportion_regions_within_coverage_thresholdandcoverage_regions_by_equity_group, preserving the previous region-counting behaviour- Naming rule: an unqualified coverage metric is demand-weighted (“what share of people”), and the
regionsvariants count every region equally (“what share of places”) - Both are added for registered secondary travel matrices too:
proportion_regions_within_coverage_threshold__<label>sits alongside its demand-weighted counterpart in the default per-matrix metric set, andcoverage_regions_by_equity_group__<label>appears underfull_secondary_metrics=True - Both are picked up automatically by
show_solutions(expand_dict_columns=True), which detects dict columns by content rather than by name
- Naming rule: an unqualified coverage metric is demand-weighted (“what share of people”), and the
- Fix coverage columns for secondary travel matrices being sorted backwards
plot_simple_pareto_front_pairs,plot_all_metric_pareto_front_pairs, andSolutionComparatorinferred “higher is better” by exact match againstproportion_within_coverage_threshold, so a suffixed column such asproportion_within_coverage_threshold__public_transportwas treated as a metric to minimise. Direction is now inferred for any coverage-proportion column, suffixed or not
- Both coverage proportions are
NaNwhen nothreshold_for_coveragewas supplied, rather than0.0 - Fix
rank_on=returning the worst solution when ranking on a coverage metric- Every
rank_oncall site sorted ascending unconditionally, which is right for the travel-cost metrics but backwards for coverage proportions, where higher is better.return_best_combination_details(),return_best_combination_site_names(),return_best_combination_site_indices()and therank_on-accepting plotting methods (plot_n_best_combinations_bar,plot_best_combination,plot_n_best_combinations,plot_travel_time_distribution,plot_combination_by_equity) all returned or plotted the least-covering solution when asked for the best one - Direction is now resolved per column, so
rank_on="proportion_within_coverage_threshold"(or anyregions/__<label>coverage column) ranks highest-first, while travel-cost metrics are unchanged and still rank lowest-first plot_travel_time_distribution(secondary_ranking=...)resolves the tie-breaker’s direction independently of the primary metric, so a coverage metric can be tie-broken by a travel cost with each sorted the right way round- Affects which solution these methods return for coverage rankings only; anything ranking on
weighted_average,unweighted_average,90th_percentile,maxortotal_costis byte-for-byte unchanged
- Every
- Solutions tied on a ranking metric now keep a stable, reproducible order
- Every sort over solutions uses a stable sort (
kind="mergesort"), so equally-good solutions are no longer reshuffled arbitrarily. pandas’ default single-column sort is quicksort, which is not stable, so which of several tied solutions was reported as “best” could differ between runs, machines or library versions - Affects
rank_on=ranking,pareto_summary(), and the solution the Pareto narrative methods anchor on. Ties are routine – on the sample Brighton problemmaxtakes only 5 distinct values across 15 candidate combinations solve()’s own ranking already sorted on two columns, which pandas handles with a stable lexsort, so no existing solve output changes
- Every sort over solutions uses a stable sort (
- BREAKING:
add_basemapis now the argument name on every plotting method, andshow_basemaphas been removedplot_region_geometry_layer(),plot_hotspots()andplot_quadrant_map()previously spelled itshow_basemap, whileplot_sites()andplot_resources()usedadd_basemap. Because the first three also accept**kwargs, passingadd_basemapto them never reached code that understood it – it crashed the static path with a confusingAttributeErrorfrom matplotlib (PatchCollection.set() got an unexpected keyword argument) and was silently ignored on the interactive path, drawing the tile layer anyway- Migration is a rename:
show_basemap=becomesadd_basemap=, with identical behaviour and the sameTruedefault - Those three methods explicitly reject the removed name with a
TypeErrornaming the replacement. Without that guard the same**kwargsforwarding would give the identical cryptic matplotlib error, or ignore it silently on the interactive path – so a bare removal would have been harder to diagnose than the original inconsistency
- Fix
plot_solution_comparison()andplot_solution_sets_comparison()raisingAttributeError: 'SiteSolutionSet' object has no attribute '_get_ordinal_suffix'- The ordinal-suffix helper is a module-level function in
lokigi.utilsbut was called as though it were a method on the solution set, so plotting any solution other than the top-ranked one crashed.solution_rank=1took a different branch and worked, which is why this went unnoticed
- The ordinal-suffix helper is a module-level function in
- Fix
plot_travel_time_distribution(bottom_n=...)raisingTypeError: list.append() takes no keyword arguments- The bottom-ranked slice was appended with a stray keyword argument, so passing
bottom_nat all crashed. Passingtop_nalone was unaffected
- The bottom-ranked slice was appended with a stray keyword argument, so passing
v0.5.0
- Add
n_jobsparameter tosolve(search_strategy="brute-force", ...)to evaluate combinations across multiple CPU cores (viajoblib)n_jobs=1(the default) is unchanged: byte-for-byte identical output to previous versionsn_jobs>1/n_jobs=-1always returns a correctly-ranked, correctly-boundedkeep_best_n/keep_worst_nresult; on an exact score tie spanning more combinations than the requested count, which specific tied combination is returned can differ from a serial run (their scores are identical either way)- Note: the first
solve(..., n_jobs=...)call in a process (or any call after switching to a differentn_jobsvalue) pays a one-time worker-pool startup cost – on Windows this can be several seconds regardless of workload size, since each worker process re-imports pandas/numpy/etc. from scratch. Calls that reuse the samen_jobsvalue reuse the already-running pool and are fast; for a small combination count, a single one-off parallel call can look slower thann_jobs=1purely because of this startup cost
v0.4.1
- Bugfixes for equity weighting
- Fix
weights={"equity": ...}giving the most weight to the least deprived regions instead of the most deprived, under both direction encodings - Rename
add_equity_data()’s ambiguousdirectionparameter todisadvantaged_end(directionis kept as a deprecated alias and now raises aFutureWarning); also fix its default to match the documented DLUHC decile convention - Fix incomplete equity data silently dropping demand points from every metric (max, weighted/unweighted averages, coverage), not just equity-specific ones;
solve()now raises a clear error when"equity"is weighted over incomplete data
- Fix
- Bugfixes for search strategies (greedy / GRASP / brute-force)
- Fix
max_value_cutoffbeing silently ignored by greedy and GRASP (only brute-force enforced it) - Fix
required_sites_colbeing ignored by GRASP, and greedy crashing when two or more sites were required - Fix
keep_best_n/keep_worst_nbeing effectively random formclp, and pruning before cost weighting was applied for brute-force - Fix a cost-weighting no-op silently inverting
mclp’s search in greedy, GRASP, and the shared final sort, so the worst combination could be returned as “best” - Fix GRASP’s
min_sites_differentdiversity threshold using the wrong formula, accepting solutions as more diverse than requested - Add a clear error when more sites are required than
pallows for brute-force (greedy/GRASP already had this)
- Fix
- Bugfixes for
solve()and single-solution evaluationsolve()now rejects unknown keyword arguments instead of silently swallowing typos- Fix mclp’s missing-demand warning exemption not applying when
objectivesis passed as a list - Fix weight keys (
"demand"/"equity") not being truly case-insensitive, and a related unreachable error for genuinely unrecognised weight keys - Fix
evaluate_single_solution_single_objectivesilently accepting partially-invalid, duplicate, or emptysite_indices/site_names
- Fix equity plot titles rendering raw
np.int64(...)reprs instead of plain site numbers; addshow_site_names=Trueto list site names instead - Add a backtest suite and document test conventions in
tests/README.md
v0.4.0
- Add support for setting site costs
add_sites()accepts a newcost_colparameter for each site’s fixed (e.g. build or operating) cost- The total cost of the selected sites is now always reported in
solution_dfviatotal_cost, regardless of whether cost is used to rank solutions solve(weights={"cost": ...})allows cost to influence which solution is chosen- Sites with a missing cost value now raise an error by default; pass
add_sites(..., allow_missing_cost=True)to opt out and havetotal_costpropagate asNaNinstead - Added an example notebook and sample dataset (Devon CDCs) demonstrating site costs
- Bugfixes for weights
- Fix the
"cost"weight key being silently ignored when passed with non-lowercase casing (e.g."Cost") - Fix
total_costsilently treating a missing per-site cost as $0 instead of propagating it as unknown
- Fix the
v0.3.0
- Add pareto front calculation and visualisation
- Add timeout to basemap calculations
- Documentation cleanups
- Bugfixes for weights
- fix a failure when using equal demand
- Dependecy fixes to avoid importing optional dependencies by default
v0.2.1
- Initial multiobjective optimisation work using weights
v0.2.0
- Add hotspot calculation and plotting
- Add quadrant/ninth plots for demand/deprivation, travel/deprivation, travel/demand
- Add helpers and examples for travel time calculation with Valhalla and r5py
- Add helpers and examples for modification of max speeds in .osm.pbf files
- Add exploratory code for routing optimization (unfinished - paused indefinitely)
v0.1.1
- Added missing plotly requirement.
- Made other requirements more permissive
v0.1
Initial release.
Please use with caution - testing suite is currently extremely limited
Support for discrete location optimization problems.
Problems can be solved with brute force (including optionally setting a list of mandatory sites), greedy, and GRASP.
Supported problem types are simple p-median (unweighted travel times), standard p-median (demand-weighted travel times), and Maximal Covering Location Problem (MCLP). Hybrid variants of simple and standard p-median allow a maximum travel time constraint to be included.
A range of plotting options are included including maps of the problem and solutions, travel time distributions, solution equity, and comparisons of multiple solution sets (e.g. car vs public transport solutions to the same problem).