Add a demand-driven transition-rate solver (#32) - #37
Draft
mmyrte wants to merge 12 commits into
Draft
Conversation
extrapolate_trans_rates() fits one independent regression per transition and has nowhere to state a scenario target. This adds a coupled LP that derives per-transition flows from per-class area targets, alongside it. - trans_rate_bounds() derives per-edge min/max rates from observed history. Rates are annualised before min/max and re-inflated to the extrapolated step length, since observed intervals are irregular; edges absent in a period count as rate 0, not as missing; the i -> i diagonal is reconstructed, as trans_meta_t does not carry it. - trans_rate_reachability() answers, without needing targets, what area each class can attain under mass balance and hard historic rates. - solve_trans_rates() solves for the flows, in shares internally and in cells at the interface, with soft rate bounds, hard-zeroed non-viable edges, an L1 terminal fit, optional minimax fairness, L1 historic preference, trajectory shape and smoothness terms. It runs the reachability precheck and reports how far outside history each target and each flow lies rather than suppressing it, failing only above a configurable ratio. - trans_rates_from_solution() writes a solution to trans_rates_t for one or more runs; trans_rate_areas() replays a rate table forward, so the solved trajectory is recoverable from trans_rates_t alone and can be compared against what an allocation run realises. The formulation stays inside lpSolve: terminal fit, historic preference and fairness are their L1 and minimax equivalents. Constraints are built in triplet form, so memory is linear in the non-zero coefficients. The defects found in the SSP-CH reference implementation are not reproduced: the curvature row accumulates its coefficients instead of overwriting them, non-viable edges are hard zeros rather than the cheapest available slack, and step lengths are vectors derived from periods_t. Note that shape constraints remain inert at the default shape_strictness of 0, because a straight line satisfies every one of the one-sided curvature inequalities; terminal_band defaults to NA because a hard band together with hard-forbidden edges turns an out-of-reach target into an infeasible program. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfnFoLafnaPbbH9x47fwjz
mmyrte
marked this pull request as draft
July 31, 2026 16:26
mmyrte
force-pushed
the
claude/linear-program-implementation-2wf1m7
branch
from
August 3, 2026 09:00
471c4f8 to
530b693
Compare
solve_trans_rates() was ~475 lines of variable bookkeeping, constraint assembly, objective and extraction in one body. It is now ~80 lines that name the steps, with the program itself in R/trans_rates_lp.R: a variable layout, a model object gathering what the blocks need, one function per block of constraint rows, the objective, and the extraction back into tables. Two internal topics collect the documentation: trans_rate_lp for the program, trans_rate_inputs for the unit conversions and table reshaping that happen before it. The precheck is now visibly the same program as the solve, with the penalty blocks switched off and the rate bounds made hard, sharing the balance, bound and monotonicity blocks rather than repeating them. One behavioural fix fell out of the move. Hard-zeroing a forbidden edge with a single row summing its steps is equivalent on non-negative flows, but that row is dense enough for lpSolve's default scaling to fail on it numerically: with the real SSP-CH bounds over four steps, minimising urban area returned status 5 rather than a solution. One row per edge and step solves cleanly at any scaling. Reachability and terminal areas are unchanged on the SSP-CH inputs; solved flows move slightly between alternate optima of equal objective, which the degeneracy of the program allows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfnFoLafnaPbbH9x47fwjz
allow for non-regular observation periods, but maintain check on regularity for extrapolation periods (allow two-day deviation for leap years)
Two classes replace the function-and-list construction. lp_problem holds a sparse program as two tables: one row per decision variable, and one row per non-zero constraint coefficient tagged with the block it belongs to. That is what the environment in new_lp_problem() was imitating, and it makes the shapes of things visible -- there are no more bare vectors whose length has to be inferred, and a solve can take the subset of blocks it needs. trans_rate_lp inherits from it and adds one method per constraint block, named after the block: add_all() derives the method name from the block name, so the two cannot drift apart. Blocks are listed in a table that records which are enabled and which enter a solve or the reachability precheck; the precheck is now literally the same program with the penalty blocks left out and the rate bounds made hard. Variables are keyed by id_lulc, id_lulc_anterior, id_lulc_posterior, id_trans and id_period rather than by arithmetic on offsets, so the constraint blocks are joins and the solved values come back already keyed. Steps are periods throughout, taking their length from the period_length_d that periods_t now carries. Addresses the FIXME/TODO comments: period_interval_years() and trans_rate_time_grid() are gone, being a subset of periods_t; init_area is derived from the last observed period of a lulc_data_t; reachability answers per class and period and no longer takes a monotone_sign; trans_rates_from_solution() becomes the trans_rates_t active binding over an id_run field, with the non-viable flow it used to tolerate now excluded by the program itself; edges are transitions. Reachability and terminal areas are unchanged on the SSP-CH inputs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfnFoLafnaPbbH9x47fwjz
Most of evoland never solves a linear program, so requiring lpSolve of every user buys nothing. It moves to Suggests, and lp_problem$new() refuses to construct without it -- which covers trans_rate_lp through inheritance, with a second check at the top of its own initialize so the failure comes before the model tables are built rather than after. require_suggested() gives the message the package's existing guards give for processx and mlr3viz, in one place instead of three lines each. trans_rate_bounds() and trans_rate_areas() are unaffected: they work on tables alone. The tests covering them still run without lpSolve, and the rest of the file exits early. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfnFoLafnaPbbH9x47fwjz
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
extrapolate_trans_rates()fits one independent regression per transition and has nowhere to state a scenario target. This PR adds a coupled LP that derives per-transition flows from per-class area targets.trans_rate_bounds()derives per-transition (aka edge) min/max rates from observed history. Rates are annualised before min/max and re-inflated to the extrapolated step length, since observed intervals are irregular; transitions absent in a period count as rate 0, not as missing; the i -> i diagonal is reconstructed, astrans_meta_tdoes not carry it.trans_rate_reachability()answers, without needing targets, what area each class can attain under mass balance and hard historic rates.solve_trans_rates()solves for the flows, in shares internally and in cells at the interface, with soft rate bounds, hard-zeroed non-viable transitions, an L1 terminal fit, optional minimax fairness, L1 historic preference, trajectory shape and smoothness terms. It runs the reachability precheck and reports how far outside history each target and each flow lies rather than suppressing it, failing only above a configurable ratio.trans_rates_from_solution()writes a solution totrans_rates_tfor one or more runs;trans_rate_areas()replays a rate table forward, so the solved trajectory is recoverable from trans_rates_t alone and can be compared against what an allocation run realises.