v0.9.0

  • Add a caption parameter to plot_accessibility(): None (the default) prints a stakeholder-facing “how to read this” explanation of the region shading – and, depending on show_site_ratio below, of the site markers too – below the chart; pass "" to suppress it or a custom string to replace it, matching the existing caption convention on plot_pareto_summary()/plot_site_reallocation_matrix()/plot_population_impact_histogram(). Also factors the shared caption-rendering logic those four now use into lokigi.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.

  • ParetoMetric is renamed Metric. It now describes a metric for solve(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 ParetoMetric fails with a clear ImportError rather than changing behaviour silently. Update the import and the constructor calls; every field, default and method is unchanged. SiteSolutionSet.pareto_metrics and compute_pareto_front(metrics=...) keep their names.
  • GRASP now applies unreachable_cost to 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 with unreachable_cost set the returned rows’ weighted_average_for_ranking/unweighted_average_for_ranking/max_for_ranking held reachable-only figures – and since solve()’s final sort orders the returned pool on exactly those columns, a multi-solution GRASP pool could come back in the wrong order. Affects search_strategy="grasp" with unreachable_cost set; grasp_num_solutions=1 was 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, and population_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 with disadvantaged_end="high", whose numbers now change to the correct ordering. Problems that never set disadvantaged_end, or set it to "low", are unaffected.
  • site_allocation_summary()’s total_demand column is renamed allocated_demand. Reads correctly sitting next to the new site_capacity_summary()’s capacity column. Any code or notebook selecting total_demand from this method’s output (not from population_impact_summary()’s unrelated total_demand metric, or SiteProblem.total_demand, both unaffected) needs updating to allocated_demand.
  • The display-side rank_on parameter is renamed sort_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 finished SiteSolutionSet and 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 in plot_solution_sets_comparison()/SolutionComparator.plot_comparison()’s config dicts, all take sort_by= now. Having both an argument named rank_on that searches and one named rank_on that 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 metric solve() computes rather than only the one objectives implies. Every candidate combination already computed the full metric set – the objective merely selected which column ranked and pruned – so metrics like inter_tertile_ratio, 90th_percentile, demand_beyond_threshold_45, proportion_demand_improved or a <metric>__<label> secondary-matrix column can now drive the optimisation itself

    • Distinct from re-sorting a finished SiteSolutionSet on one of those columns, which only reorders candidates that survived a search ranked on something else. Under brute_force_keep_best_n or GRASP, the genuinely best solution for your metric may never have been retained; rank_on decides 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 Metric for explicit control. Metric is 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 objectives governs 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_n and n_jobs – the ranking transform is per-combination and needs no view of the batch, unlike weights={"cost": ...}, which must materialise every combination first
    • Not a job for weights, which holds row-level weights over demand regions: those change how weighted_average is computed, not which metric is ranked, and a per-combination scalar like inter_tertile_ratio has 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_group and friends), or is empty because its precondition wasn’t met (inter_tertile_ratio without add_equity_data(), coverage without threshold_for_coverage, population impact without baseline=) fails immediately with a message naming the fix, rather than after a full solve
    • Ranking on weighted_average/unweighted_average/max while unreachable_cost is set upgrades to the matching _for_ranking column and warns, since ranking on the reachable-only figure would reward a solution for stranding demand – the exact failure unreachable_cost exists to prevent. Relatedly, unreachable_cost is now required based on the column actually being ranked rather than on the objective (unchanged for every built-in objective, but correct for a custom rank_on: a coverage proportion already counts an unreachable pair as “not covered”, so it needs no substitution)
    • New custom_ranking_metric example works through both halves of this on the Devon MIU data: choosing sites on 90th_percentile rather than weighted_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
  • Add an objectives="custom" model, for ranking on rank_on with no model constraints at all. It requires rank_on and rejects max_value_cutoff (use hybrid_p_median for that). Mostly useful so the returned SiteSolutionSet doesn’t name a textbook model the run didn’t perform; describe_models() lists it, honestly flagged as not being from the location-allocation literature

  • Add SiteSolutionSet.ranking_metric, the Metric a solve actually ranked and pruned on. Read it rather than objectives when reporting what was optimised – plot_sites(), plot_n_best_combinations() and the distribution plots pick which numbers to annotate from objectives, which stops describing the ranking under rank_on, so they now name the real metric alongside. Annotations for a solve without rank_on are unchanged

  • Add Metric.format_value(), the level counterpart to the existing format_delta() – renders one value with the metric’s own unit, decimals and percentage handling

  • Add SiteSolutionSet.site_capacity_summary(), comparing a chosen solution’s allocated demand (from site_allocation_summary()) against capacity registered via add_sites(capacity_col=...) – “does the allocation fit?”, where site_allocation_summary() answers “who gets allocated where?”. Diagnostic only: it does not feed back into solve(), which still has no capacitated search strategy (capacitated=True raises NotImplementedError)

    • capacity_col is resolved at call time (falling back to the registered one), mirroring two_step_floating_catchment()’s call-time supply_col, so the same solution can be scored under a different capacity definition without re-registering sites
    • demand_to_capacity_rate (default 1.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, and allocated_utilisation_ratio (allocated_load / capacity, not clipped above 1.0); a zero-allocation site gets an explicit 0.0 ratio, not NaN – genuinely measured and idle
    • Additionally returns current_load/baseline_utilisation_ratio (whichever was registered), headroom, incremental_headroom_ratio, and residual_headroom whenever current_load_col or utilisation_col was registered via add_sites()
    • allocated_utilisation_ratio and incremental_headroom_ratio deliberately encode contradictory assumptions – the former treats allocated demand as replacing today’s activity (the whole-network reallocation solve() 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 proportion has)
    • Zero capacity with a nonzero allocation gives inf, not a clipped or coerced value
  • Add SiteSolutionSet.plot_site_capacity_summary(), a bar chart of site_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 ax parameter, for embedding this chart as one panel of a larger figure. When given, the caller owns the figure’s lifecycle – this method neither calls plt.tight_layout() nor closes it afterwards
  • Add SiteSolutionSet.plot_allocated_utilisation(), the map counterpart: colours each selected site by its allocated_utilisation_ratio, mirroring plot_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 map

  • Add metric="allocated_demand"/metric="n_regions" to plot_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 caption parameter to plot_site_utilisation(), matching the convention on plot_accessibility()/plot_pareto_summary()/plot_site_reallocation_matrix()/plot_population_impact_histogram()

  • two_step_floating_catchment()’s per_capita now rejects non-positive or non-numeric values, since a value of 0 would silently zero out every region’s accessibility score regardless of supply or demand

  • Add SiteSolutionSet.show_solutions_summary(), a stakeholder-facing view of solution_df for readers who aren’t going to parse ~30-40 jargon-named columns (weighted_average, inter_tertile_ratio, …) with no units and placeholder None/NaN/“N/A (No equity data)” columns whenever equity data, a coverage threshold, or a baseline weren’t registered

    • Always shows Sites in this option and Sites 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), and Longest journey (mins); Rank too for a multi-solution SiteSolutionSet
    • Adds a coverage section (People within <threshold> mins, % within <threshold> mins) only if threshold_for_coverage was 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 (see additional_site_names below) when at least one required site is configured
    • Adds Sites closed/Sites added (see sites_closed_vs_baseline/sites_added_vs_baseline below) 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, unlike Sites closed/Sites added above (which require solve(baseline=...)). The reference is controlled by the new diff_against parameter: "default" (the default) uses the sites flagged via add_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_against option (None by default, so existing behaviour is unchanged) to plot_pareto_summary() and plot_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-referencing show_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
  • Add unselected_site_names, additional_site_names, sites_closed_vs_baseline, and sites_added_vs_baseline to solution_df (EvaluatedCombination.return_solution_metrics()), so all four are available to any caller of show_solutions(), not only show_solutions_summary()

    • unselected_site_names: every registered candidate site absent from site_names, in canonical site-index order – always present, since it doesn’t depend on any optional registered data. show_solutions_summary()’s Sites not in this option now just formats this column rather than recomputing it
    • additional_site_names: site_names with the sites flagged via add_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 than site_names repeating 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, otherwise
    • sites_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-identical site_names rows apart. Only present when a baseline was supplied via solve(baseline=...) (or baseline_costs= directly); absent, not empty lists, otherwise
  • Add SiteSolutionSet.describe_solution_columns(), a grouped, beginner-facing alternative to show_solutions_colnames()’s flat column list – prints (or returns, via return_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_df or show_solutions()’s existing column set
  • 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_average shift, 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-solution SiteSolutionSet for use as a baseline. With no site_names/site_indices, defaults to the sites flagged via add_sites(required_sites_col=...)
    • Add SolutionComparator.population_impact_summary(), a per-demand-location diff of set_b (candidate) against set_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, and total_demand. Takes matrix=/demand= to diff a registered secondary travel matrix or demand scenario instead of the primary, and a meaningful_change_threshold (default 0.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; pass as_dict=True for the raw dict (e.g. to pull out one value for further computation). Either way, every value is a native Python int/float rather than a numpy scalar, avoiding numpy >=2.0’s np.float64(...) repr wrapper showing through on a bare dict
    • Add solve(baseline=...), threading the same comparison through every enumerated combination automatically: None (default, off, solution_df’s column set is unchanged), True (build from required_sites_col, inheriting this call’s objective/weights/threshold), or a one-solution SiteSolutionSet (typically from evaluate_baseline()). The baseline is evaluated once per solve() call, not once per combination, so the added cost is negligible for every search strategy (brute-force, greedy, GRASP). Adds demand_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_increase to solution_df
    • Secondary travel matrices and secondary demand scenarios get the demand-weighted subset of columns (demand_improved__<label>, etc.) by default; full_secondary_metrics=True also 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-wide weighted_average shift against population_impact_summary()’s per-region view, so the “dilution” is visible at a glance. by="demand" (default) counts people; by="regions" counts LSOAs. Takes an ax= pair to embed the two panels in a larger layout
    • Add SolutionComparator.population_impact_phrase(), turning population_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-n drill-down table (default n=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 under set_a/set_b respectively, so “closed site X’s demand went to site Y” reads off a single row), and People 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 as population_impact_summary(return_per_region=True) (which now also exposes previous_site/new_site on its own return_per_region=True output), 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, the worst_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 same n= as population_impact_worst_affected() to cap the coloured regions to the n most affected (only valid alongside direction="worsened"/"improved" – raises rather than silently ignoring n under direction="all", which already shows every region). Requires a region_geometry_layer (add_region_geometry_layer())
      • show_sites overlays candidate sites as points, mirroring plot_best_combination()’s own site-marker logic: "all" plots every site, with any closed relative to the baseline (in set_a’s solution but not set_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_markersize restyle it. Requires point geometry on the candidate sites (raises otherwise, matching plot_best_combination()’s own requirement)
    • Add SolutionComparator.plot_population_impact_histogram(), an overlaid before/after travel-cost distribution (set_a vs set_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 to population_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 via seaborn.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 respecting matrix= for its left-hand region-wide weighted_average panel – 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 SolutionComparator.site_reallocation_matrix(), cross-tabulating each demand location’s closest site under set_a against its closest site under set_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 aggregates compare_site_allocation() already gives. Rows are set_a’s selected sites, columns are set_b’s, cell values are the demand (or region count, via by="regions") whose closest site moved from row to column; every selected site gets a full row/column (0.0, not NaN, if it captures no reallocated demand), and the diagonal is the demand that didn’t move at all. Takes the same matrix=/demand= arguments as population_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: pass True to 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 of site_reallocation_matrix() (including its own changed_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, matching plot_pareto_summary()’s existing caption convention
  • Add absolute coverage headcounts alongside the existing coverage proportions – demand_within_coverage_threshold/regions_within_coverage_threshold on solution_df (and their __<label> secondary-matrix/demand-scenario variants), the literal headcount proportion_within_coverage_threshold/proportion_regions_within_coverage_threshold already 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 (None if none registered), for sanity-checking the new headcounts independently rather than recomputing the total ad hoc
    • Add SolutionComparator.population_impact_summary()’s demand_newly_covered/demand_newly_uncovered/regions_newly_covered/regions_newly_uncovered – the GROSS number of people/regions crossing threshold_for_coverage in each direction between set_a (baseline) and set_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 beyond_thresholds to evaluate_single_solution_single_objective(), evaluate_baseline(), and solve() – one or more “left behind” travel-cost thresholds, surfacing how many people/regions are beyond a cutoff rather than only the single worst-case max. Adds demand_beyond_threshold_<t>/regions_beyond_threshold_<t> to solution_df per threshold t (plus a _by_equity_group dict 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 unlike threshold_for_coverage, this accepts more than one value at once
    • A demand location with no reachable site (NaN travel cost) counts as beyond every threshold, matching within_threshold’s “NaN cost -> not covered” convention applied to the opposite direction
    • Absent, not NaN, on solution_df unless requested – no effect on solution_df’s schema for existing callers
  • Add SolutionComparator.population_impact_by_equity_group(), splitting population_impact_summary() by equity band: per-band regions_improved/regions_worsened/regions_unchanged, demand_improved/demand_worsened/demand_unchanged, band_total_demand, and the rate-normalised proportion_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, naming add_equity_data(), if no equity data is registered

    • solve(baseline=..., ...) on a problem with equity data registered also gains demand_improved_by_equity_group/demand_worsened_by_equity_group dict columns on solution_df – the same breakdown without needing to build a SolutionComparator
    • population_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 harm
    • population_impact_phrase()’s wording also now states meaningful_change_threshold explicitly whenever it is above 0 (“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_missing to add_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 like 9999 – which corrupted every average and map colour scale it touched – just to avoid a crash

    • allow_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 cryptic ValueError: 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 supplied
    • allow_missing=True opts 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 crashing evaluate_single_solution_single_objective()/solve() with ValueError: Encountered all NA values (from DataFrame.idxmin on 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 plain np.average/np.percentile/np.max propagation. How much was excluded is reported alongside them, never silently: new regions_unreachable/demand_unreachable/proportion_demand_unreachable metrics on solution_df (plus regions_unreachable_by_equity_group/demand_unreachable_by_equity_group when equity data is registered, and __<label> secondary-matrix/demand-scenario variants), 0/0.0 for every problem that never opts into allow_missing
    • add_secondary_travel_matrix()’s existing NaN rejection (raised once solve() builds its aligned frame, since the completeness check needs demand/site data that may not exist yet at registration time) now also respects allow_missing; its error message points at allow_missing=True instead of suggesting a sentinel fill
    • solve() (search/optimisation) does not support a primary matrix that actually contains a missing value on its own – it raises NotImplementedError naming 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) unless unreachable_cost is 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’s NotImplementedError asks for: a finite travel cost substituted for every unreachable pair, used ONLY to rank/prune combinations during search – never in solution_df’s reported weighted_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 whether unreachable_cost is set
    • The substituted view is exposed alongside the honest one as weighted_average_for_ranking/unweighted_average_for_ranking/max_for_ranking on solution_df (and on EvaluatedCombination/evaluate_single_solution_single_objective(unreachable_cost=...) directly) – always present, identical to their honest counterparts whenever unreachable_cost isn’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 against max_for_ranking, not the honest max – 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
  • 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 missing column value unless missing_kwds is passed, an invisible hole in the map indistinguishable from “outside the study area”. Covers plot_best_combination(), plot_n_best_combinations(), plot_solution_comparison(), plot_solution_sets_comparison() (cost-based and plot_site_allocation=True colouring both), and plot_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 claimed proportion “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 builtin min()/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 9999 sentinel for a handful of unrouteable pairs, shown corrupting a single-site solution’s demand-weighted average to over 12 hours, then fixed with allow_missing=True, treat_as_missing=9999

Other

  • Fix describe_models()’s closing instruction pointing at a prob.solve_pmedian(p=3) method that doesn’t exist – it now reads prob.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 and plot_n_best_combinations()’s shared colour bar were unlabelled entirely; check_solution_equity() and plot_combination_by_equity() labelled their axes/titles/colour bar with the raw min_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 via add_travel_matrix(unit=...); the equity axis/title now uses the human-readable add_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”, unlike plot_population_impact_by_equity_group()’s explicit “(most to least disadvantaged)” wording – bars are now reordered most- to least-disadvantaged per add_equity_data(disadvantaged_end=...) (via the same tertile logic as population_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 calls check_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-readable add_equity_data(label=...) (falling back to the raw equity column name if none was registered); a new title parameter sets the plot’s title via ax.set_title(), ignored when interactive=True since a Folium map has no equivalent built-in title support
  • Fix plot_region_geometry_layer()’s plain branch (neither plot_demand nor plot_equity set) ignoring plot_region_of_interest_only entirely – it always plotted the full, unfiltered region_geometry_layer regardless of the flag, and passing plot_region_of_interest_only=True actually raised UnboundLocalError (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 one vmin/vmax (computed from their own selected solutions) by default; pass shared_color_scale=False to restore each panel’s independent scale. Panels using plot_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-readable add_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 default kind="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 new caption parameter adds a stakeholder-facing footnote by default (only for kind="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 naming kind="hist" as the way to get literal counts. Pass caption="" 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. calling plot_accessibility()/two_step_floating_catchment() or evaluate_single_solution_single_objective() before add_travel_matrix(). These now raise a ValueError naming the missing registration step (.add_travel_matrix(), or .add_demand() for the equivalent missing-demand case on the evaluate path), matching the friendly check solve() already had
  • Fix get_hotspots() (and the other spatial-weights-based EDA methods) surfacing a libpysal FutureWarning (“use_index defaults to False but will default to True in future”) to the user on every rook/queen run – use_index=True is 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 cached spatial_weights object differ for rook/queen
    • Also pin esda<3: esda’s next major release changes Moran_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 the Moran_Local call in site_eda.py)
  • Fix interactive maps (interactive=True on plot_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 Quarto revealjs slide 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 is display: none measures 0x0 and clamps to zoom 0; showing the slide later fixes the tile layout but never re-runs fitBounds, leaving the map stuck zoomed out. Each map now carries a one-shot ResizeObserver that 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 = False to stop attaching the guard
  • 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() to SiteProblem, registering additional demand scenarios (e.g. current vs projected future demand, or time-varying demand) alongside the primary demand set via add_demand(), mirroring add_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> and proportion_within_coverage_threshold__<label> columns – the only two metrics that actually vary with demand – to every solution solve() produces, so scenarios can be combined via compute_pareto_front() or blended into the objective via weights={"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, producing weighted_average__<travel_label>__<demand_label> columns for just those combinations
    • two_step_floating_catchment(), site_allocation_summary() (and SolutionComparator.compare_site_allocation()), and plot_region_geometry_layer()/plot_site_allocation_summary() all gain a demand=<label> argument, mirroring the existing matrix=<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 label namespace (both suffix __<label> onto column names), so labels must be unique across both
  • Add two_step_floating_catchment() to SiteProblem and SiteSolutionSet, 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_col names a numeric column on candidate_sites at call time rather than being registered via add_sites(), so the same problem can be scored under different supply definitions (doctors vs beds vs slots) without re-adding sites
    • SiteProblem.two_step_floating_catchment() scores an arbitrary site set directly – no solve() is required. With no site_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 same rank_on/solution_rank/site_names/site_indices/matrix solution-selection arguments as site_allocation_summary() and scores that solution’s selected sites
    • Catchment membership uses an inclusive <= against catchment_size, unlike the coverage metrics’ strict < against threshold_for_coverage – matches the standard 2SFCA convention rather than lokigi’s own coverage precedent
    • A site with no demand within catchment_size has 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 within catchment_size correctly scores accessibility == 0 instead – a real “no supply available” result, kept distinguishable from the NaN case above
    • return_site_ratios=True also 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 any solve() objective
  • Add distance_decay= to two_step_floating_catchment() (SiteProblem, SiteSolutionSet and plot_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 of catchment_size or distance_decay must be given. Existing calls that only pass catchment_size are unaffected – it remains the single-band special case of the same underlying weight-matrix engine
    • distance_decay takes 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 radius catchment_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 beyond catchment_size), parameterised to match pysal/access’s weights.gravity()
    • n_regions_in_catchment/n_sites_in_catchment now count non-zero-weight membership rather than boolean in-range membership – the same thing under catchment_size, generalised under distance_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 gemv rather 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() to SiteProblem and SiteSolutionSet, mapping 2SFCA accessibility: a region choropleth of accessibility, 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. Pass show_site_ratio=True to instead colour and size them by their step-1 supply-to-demand ratio (red and small for an overloaded site, green and large for an uncontested one; site_cmap/missing_site_colour/marker_size_range apply in this mode only)
    • Computes two_step_floating_catchment() automatically from supply_col/catchment_size and the usual selection arguments, following the same optional-precomputed-input pattern as plot_hotspots(hotspots_df=...); a precomputed region_frame/site_frame pair can be passed in instead (needed to plot a SiteSolutionSet result at a solution_rank/rank_on other than the default)
    • interactive=True returns 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_kwds label only appears on a discrete scheme= legend, not the continuous colorbar used here
    • Site markers are only drawn when candidate_sites has real point geometry, which includes tabular lat/long input (not just a GeoDataFrame passed directly to add_sites())
  • Fix plot_best_combination() (and plot_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 records add_sites()’s input format rather than whether candidate_sites ended 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 common add_sites() usage pattern – drew a region map with no site markers at all, with no error or warning
  • Fix site markers rendering at Folium’s default radius (2px, barely visible) on interactive maps from plot_sites() and plot_site_utilisation(); both now use radius=8, matching plot_accessibility()’s existing site-marker size
  • Fix plot_pareto_summary() and plot_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 one width_multiplier’s worth of characters
  • Add site_allocation_summary() to SiteSolutionSet, 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_cost column – demand-weighted mean travel cost among a site’s closest regions by default (unweighted when by="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 via add_demand(); by="regions" counts every region equally, following the same people-vs-places naming rule as the coverage metrics above. Raises a ValueError rather than falling back if by="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 0 rows in n_regions/proportion rather than being dropped by the underlying grouping – a near-zero share is usually the finding being looked for. average_travel_cost is NaN for such a site instead of 0, 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 same matrix= 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 of site_allocation_summary(). Uses the same “Set2” site colours as plot_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 visible
    • metric="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” for average_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 closed
    • metric="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 is NaN and a site that is opened but closest to nothing is 0.0, so “not opened” and “opened but unused” stay distinguishable. With metric="average_travel_cost" both cases are NaN, 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
  • 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 pad didn’t scale with its (variable) line count or leave headroom for the show_raw_labels value bubbles, which sit close to the top of the axes for a solution near the top of the normalised scale – most visible with a small height_per_row. pad is now 20pt
    • The included-sites text wrapped to a fixed character width (wrap_at) regardless of subplot width, so with ncols>1 a 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 by ncols
  • Fix add_sites(capacity_col=...) silently accepting non-numeric values
    • capacity_col was never included in the numeric-column validation that cost_col already had, so a string-typed capacity column passed add_sites() without error. Now validated the same way as cost_col, current_load_col and utilisation_col (below)
  • Add site_utilisation_summary() and plot_site_utilisation() to SiteProblem, reporting each candidate site’s real-world current utilisation – today’s baseline load against capacity, independent of solve() or any catchment/demand modelling (there is no SiteSolutionSet counterpart, 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 with capacity_col so a ratio can be derived) or utilisation_col (a precomputed ratio/percentage, for analysts without raw counts). Giving both raises a ValueError
    • site_utilisation_summary() returns capacity/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 explicit NaN in utilisation_ratio/headroom, not 0.00.0 would misleadingly mean “measured, and currently idle”. Values above 1.0 (genuinely over capacity) are left as-is, not clipped
    • plot_site_utilisation() maps each site coloured and sized by utilisation_ratio. Deliberately inverted from plot_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 a ValueError if candidate_sites has 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_threshold and coverage_by_equity_group now report demand-weighted values rather than counts of regions. Existing numbers change on any problem with non-uniform demand. The previous behaviour is available as proportion_regions_within_coverage_threshold / coverage_regions_by_equity_group
  • The mclp objective 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 NaN rather than 0.0 when no threshold_for_coverage was supplied
  • show_basemap has been removed from plot_region_geometry_layer(), plot_hotspots() and plot_quadrant_map(). Use add_basemap, which is now the argument name on every plotting method. Passing the old name raises a TypeError naming 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_transport on the per-region problem_df), so a single solve() produces one candidate ranking with metrics for every registered matrix side by side – directly usable in ParetoMetric(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_unit and optional per-matrix threshold_for_coverage (falls back to the value passed to solve() 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 a KeyError naming 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). Pass solve(..., 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 new matrix= keyword to switch from the primary matrix to a registered secondary one
    • plot_simple_pareto_front_pairs’s x_axis/y_axis parameters now accept any solution_df column (previously typed as a fixed Literal set 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 new add_secondary_travel_matrix() docstring and the multiple_travel_matrices example for the brute_force_keep_best_n/_worst_n caveat this implies
    • SolutionComparator and the problem.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 the comparing_solutions example)
  • Add expand_dict_columns and inplace parameters to show_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 under full_secondary_metrics=True) into one column per dict key, named <column>__<key>. Off by default, so solution_df’s shape is unchanged for existing callers
    • show_solutions(expand_dict_columns=True, inplace=True) also writes the expansion back to solution_df so it persists for later calls, plotting, and rank_on; inplace has no effect unless combined with expand_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_threshold now reports the proportion of total demand within threshold_for_coverage, weighted by the demand registered via add_demand(). Previously it was the proportion of regions, so a sparsely-populated LSOA counted as much as a dense one
    • coverage_by_equity_group changes in the same way, reporting demand-weighted coverage within each equity band
    • Because mclp ranks on proportion_within_coverage_threshold, the mclp objective 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 by weighted_average, so the metric means “proportion of demand covered” regardless of what is passed to weights=
  • Add proportion_regions_within_coverage_threshold and coverage_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 regions variants 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, and coverage_regions_by_equity_group__<label> appears under full_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
  • Fix coverage columns for secondary travel matrices being sorted backwards
    • plot_simple_pareto_front_pairs, plot_all_metric_pareto_front_pairs, and SolutionComparator inferred “higher is better” by exact match against proportion_within_coverage_threshold, so a suffixed column such as proportion_within_coverage_threshold__public_transport was treated as a metric to minimise. Direction is now inferred for any coverage-proportion column, suffixed or not
  • Both coverage proportions are NaN when no threshold_for_coverage was supplied, rather than 0.0
  • Fix rank_on= returning the worst solution when ranking on a coverage metric
    • Every rank_on call 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 the rank_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 any regions/__<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, max or total_cost is byte-for-byte unchanged
  • 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 problem max takes 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
  • BREAKING: add_basemap is now the argument name on every plotting method, and show_basemap has been removed
    • plot_region_geometry_layer(), plot_hotspots() and plot_quadrant_map() previously spelled it show_basemap, while plot_sites() and plot_resources() used add_basemap. Because the first three also accept **kwargs, passing add_basemap to them never reached code that understood it – it crashed the static path with a confusing AttributeError from 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= becomes add_basemap=, with identical behaviour and the same True default
    • Those three methods explicitly reject the removed name with a TypeError naming the replacement. Without that guard the same **kwargs forwarding 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() and plot_solution_sets_comparison() raising AttributeError: 'SiteSolutionSet' object has no attribute '_get_ordinal_suffix'
    • The ordinal-suffix helper is a module-level function in lokigi.utils but 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=1 took a different branch and worked, which is why this went unnoticed
  • Fix plot_travel_time_distribution(bottom_n=...) raising TypeError: list.append() takes no keyword arguments
    • The bottom-ranked slice was appended with a stray keyword argument, so passing bottom_n at all crashed. Passing top_n alone was unaffected

v0.5.0

  • Add n_jobs parameter to solve(search_strategy="brute-force", ...) to evaluate combinations across multiple CPU cores (via joblib)
    • n_jobs=1 (the default) is unchanged: byte-for-byte identical output to previous versions
    • n_jobs>1/n_jobs=-1 always returns a correctly-ranked, correctly-bounded keep_best_n/keep_worst_n result; 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 different n_jobs value) 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 same n_jobs value reuse the already-running pool and are fast; for a small combination count, a single one-off parallel call can look slower than n_jobs=1 purely 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 ambiguous direction parameter to disadvantaged_end (direction is kept as a deprecated alias and now raises a FutureWarning); 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
  • Bugfixes for search strategies (greedy / GRASP / brute-force)
    • Fix max_value_cutoff being silently ignored by greedy and GRASP (only brute-force enforced it)
    • Fix required_sites_col being ignored by GRASP, and greedy crashing when two or more sites were required
    • Fix keep_best_n/keep_worst_n being effectively random for mclp, 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_different diversity threshold using the wrong formula, accepting solutions as more diverse than requested
    • Add a clear error when more sites are required than p allows for brute-force (greedy/GRASP already had this)
  • Bugfixes for solve() and single-solution evaluation
    • solve() now rejects unknown keyword arguments instead of silently swallowing typos
    • Fix mclp’s missing-demand warning exemption not applying when objectives is 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_objective silently accepting partially-invalid, duplicate, or empty site_indices/site_names
  • Fix equity plot titles rendering raw np.int64(...) reprs instead of plain site numbers; add show_site_names=True to 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 new cost_col parameter for each site’s fixed (e.g. build or operating) cost
    • The total cost of the selected sites is now always reported in solution_df via total_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 have total_cost propagate as NaN instead
    • 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_cost silently treating a missing per-site cost as $0 instead of propagating it as unknown

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).

Back to top