Skip to content

Add a demand-driven transition-rate solver (#32) - #37

Draft
mmyrte wants to merge 12 commits into
developfrom
claude/linear-program-implementation-2wf1m7
Draft

Add a demand-driven transition-rate solver (#32)#37
mmyrte wants to merge 12 commits into
developfrom
claude/linear-program-implementation-2wf1m7

Conversation

@mmyrte

@mmyrte mmyrte commented Jul 31, 2026

Copy link
Copy Markdown
Member

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

claude and others added 5 commits July 31, 2026 10:43
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
mmyrte marked this pull request as draft July 31, 2026 16:26
@mmyrte
mmyrte force-pushed the claude/linear-program-implementation-2wf1m7 branch from 471c4f8 to 530b693 Compare August 3, 2026 09:00
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
@mmyrte mmyrte linked an issue Aug 3, 2026 that may be closed by this pull request
mmyrte and others added 5 commits August 4, 2026 10:54
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a demand-driven transition-rate solver

2 participants