Solve technosphere blocks by Neumann series instead of LU - #236
Merged
TimoDiepers merged 4 commits intoSep 17, 2026
Merged
Conversation
A technosphere block is `I - T` for a productive economy, so a Jacobi-preconditioned Neumann series converges on it, and does so far more cheaply than an LU factorization: on a premise-sized vintage block (43.6k rows, 525k nonzeros) ~110 sparse matrix products at 20-25 ms per right-hand side, against ~4 s for the UMFPACK factorization. There is no break-even number of right-hand sides - a series solve costs less than UMFPACK's triangular solve on top of an LU it already has. `_IterativeBlockSolver` is reached through `allow_iterative=True` on both solver factories, which `BackgroundSolver` passes by default, and through `TimexLCA._solve_functional_unit` for the whole expanded technosphere and both base LCA builds. Blocks it cannot handle - a zero on the diagonal, a divergent series, or too large a backward error - fall back to the LU backend, so the fast path is an optimization and never a constraint on which systems can be solved. `BW_TIMEX_NO_ITERATIVE_SOLVER=1` disables it process-wide. Acceptance is a backward error, `||A x - b|| / (||A|| ||x|| + ||b||)`, not `||r|| / ||b||`: a functional unit of one vehicle pulling 20,000 kWh has a solution some 1e6 larger than its demand, and scaling by the demand alone rejects a perfectly good solve. Also replaces `BlockStructure.detect`'s `np.unique(..., axis=0)` over the stacked group pairs, which lexsorts a void view of all 2 * nnz values, with a bincount over encoded pairs: 0.89 s -> 0.009 s on a premise-sized technosphere, identical pairs. Measured on the premise EV teaching notebook, paired runs in cold processes: `lci()` 22.9-23.7 s -> 3.9-4.0 s, whole notebook 40-43 s -> 19-21 s, static score unchanged to 1.3e-14.
Three phase-counting tests broke on CI: they assert how many MKL numeric factorizations `prepare()` performs, and blocks are now solved by series, which performs none. They are about the LU path, so they say so - a `lu_block_solver` fixture sets `BW_TIMEX_NO_ITERATIVE_SOLVER` and asserts the switch took, rather than letting a count of zero pass for a subject that never ran. The same pin goes on the grouped-solve phase count, which compared two zeroes and passed vacuously. Adds what was missing on the other side: that an ordinary run on a machine with MKL makes no pardiso calls at all, and that the series and pardiso agree on the same unit supply. Fallback tests are now parametrized over every backend the machine has instead of only its preferred one, so pardiso is exercised as the target of a fallback (divergent block, zero diagonal, disabled by env var), the fallback is asserted to be that backend, and a persistent solver's fallback is asserted never to be pardiso - whose factorization lives in MKL's single global slot.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #236 +/- ##
==========================================
+ Coverage 84.86% 85.27% +0.40%
==========================================
Files 16 16
Lines 3767 3871 +104
==========================================
+ Hits 3197 3301 +104
Misses 570 570 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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.
What
Background blocks and the functional unit are now solved by repeated sparse matrix-vector products instead of by factorizing the matrix.
Factorizing an ecoinvent-sized block costs ~4 s, and we only ever ask it a handful of questions. Multiplying costs 20-25 ms per demand column — cheaper than one solve against a factorization we already have, so there is no point at which factorizing wins.
If the iteration cannot handle a block (a zero on the diagonal, or it does not settle on an answer), that block goes to the existing solver instead, so nothing that worked before stops working.
BW_TIMEX_NO_ITERATIVE_SOLVER=1turns the new path off entirely.Also fixes a slow step in
BlockStructure.detectthat sorted every matrix entry to find which database groups touch each other: 0.89 s → 0.009 s, same result.Measured
Premise EV teaching notebook, cold runs, paired before/after:
tlca.lci()Scores are unchanged to 14 digits.
Tests
622 passed, 7 skipped. New: the iterative solver against a direct solve (single and batched demands, both fallbacks, the opt-out), and a run of the whole pipeline on every solver backend the machine has, with and without the new path, checking scores and inventories match.