perf: batch the background inventory and build the model from precomputed data - #64
Merged
Merged
Conversation
Solve all intermediate flows of a background database against a single factorization and keep only the aggregated elementary flow vector (B @ supply_array) instead of building the full per-process inventory matrix and annotating it through to_dataframe() once per flow and method. The inventory never depended on the LCIA method, so it is computed once. Activities and biosphere flows are resolved from one pass over each database, results are cached per (project, database, modified token, activity, cutoff), and databases are calculated in parallel by default. Dynamic characterization now runs in a single characterize() call for all elementary flows, with the method's characterization functions built once and cached. cutoff no longer defaults to 1e4: it truncated the unaggregated inventory before summing, which biased flow amounts low. Flows without a characterization factor in any category are dropped instead, since they cannot affect any impact; retain_flows keeps the ones needed for flow limits.
…ase study Documents that uncharacterized elementary flows are dropped and how to keep them with retain_flows, plus the entry point guard needed for the parallel calculation in scripts. methanol_and_iron_v0.5.0.ipynb preserves the case study exactly as it was run for the paper with optimex 0.5.0, whose background inventories were truncated.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
The expression rules read the background inventory, the database mapping and the characterization factors through Pyomo Params, one indexed lookup with full index validation per scalar. None of those tensors carry decision variables, so they are now combined into plain Python numbers before the expressions are built: - the background inventory is collapsed over background databases once, per (intermediate flow, elementary flow, year) - the sum over elementary flows is folded into the characterization factors, so the impact expressions run over intermediate flows instead of every elementary flow -- mathematically the same, but a much smaller tree, which the LP writer also has to walk - installation- and operation-driven flows, always used together, become one Expression indexed only over the (process, flow) pairs that carry data - production rates, vintage flow rates and the active vintages per (process, year) are cached instead of recomputed per rule call get_dynamic_inventory now assembles the inventory from solved flow values and the same data instead of evaluating the scaled_inventory expression once per (process, flow, year), and maps flow codes to ids from one pass over the biosphere database rather than a query per row. Rows that are exactly zero are no longer emitted. On the paper case study: model build 103.6 s -> 1.7 s, solve wall clock 55.7 s -> 0.1 s, dynamic inventory 19.8 s -> 0.2 s, with identical objective, identical variable values and identical model size.
TimoDiepers
force-pushed
the
perf/background-inventory
branch
from
August 20, 2026 09:16
5de2f9b to
b6da919
Compare
Building and factorizing the technosphere matrix of a background database is the bulk of the LCA processing, and a new Python session used to redo it for every database. Calculated inventories are now also written to optimex-inventory-cache inside the Brightway project, keyed by the database's modified token, the activity identity and the cutoff, so a stale entry misses instead of being served. On the methanol & iron case study the processor drops from 37 s to 0.3 s on the second run, i.e. the whole pipeline from 38 s to 1.5 s. Configurable through background_inventory.use_disk_cache and disk_cache_dir; clear_lca_caches(include_disk=True) removes the files.
TimoDiepers
force-pushed
the
perf/background-inventory
branch
from
August 20, 2026 09:27
b6da919 to
4ade918
Compare
Constraining an elementary flow that was dropped for lacking a characterization factor failed with a bare list of valid keys. The error now names the setting that keeps such a flow. Adds end-to-end coverage that a retained, uncharacterized background flow still drives a cumulative flow limit, and gives the notebook cells the ids nbformat 4.5 expects.
The background inventory collapsed over databases was held three times: the flat (intermediate flow, elementary flow, year) dict plus the two groupings built from it. Building the impact factors from a grouping instead lets the flat form go, cutting create_model's peak memory on the paper case study from +192 MB to +139 MB.
The constraint rules are indexed over the model's own sets, so a limit naming a flow the model does not have, or a year outside the horizon, was dropped without a word. OptimizationModelInputs rejects those on construction, but assigning a limit to an already-built instance bypasses that. create_model now warns about limits it ignores, pointing at retain_flows for the dropped-flow case, and about limits on flows that no process exchanges, which can never bind.
Group the paper version of the methanol & pig iron case study with the data
and plotting code it needs, so the reproducibility artifacts live next to the
notebook that produces them:
notebooks/paper/
├── methanol_and_iron_v0.5.0.ipynb
├── data/ (was notebooks/data/paper/)
└── plots/ (was notebooks/plots/)
Shared inputs (model_inputs_2050.json, product_system.svg) stay in
notebooks/data/ and are referenced as ../data/.
The docs example notebook (methanol_and_iron.ipynb) no longer reaches into
paper/: it loads its own saved model inputs, and its result exports are
commented out since only the paper needs those files on disk. The
baseline_design/evolution_results round-trip stays active - it is what lets
Scenario 4 run without keeping the earlier models in RAM - and now writes to
notebooks/data/.
TimoDiepers
force-pushed
the
perf/background-inventory
branch
from
August 20, 2026 14:31
c873fe5 to
2c8ebb7
Compare
Merged
TimoDiepers
added a commit
that referenced
this pull request
Aug 20, 2026
Resolve conflicts against the v0.6.0 performance refactor (#64): - optimizer.py: main moved the flow and production rates into cached helpers (`flow_rate`, `production_rate`) that summed the tensor over the whole operation window. Both now take the lifecycle stage `tau` and return the per-tau entry, i.e. the flow/output per operating unit and year; the cache key carries `tau`. Call sites pass `t - v`. - optimizer.py: `OperationCapacity` keeps the PR's unit-count form and is indexed by ACTIVE_VINTAGE_TIME only, so `get_production_value` is now unused and removed. - tests/test_single_route_lca_comparison.py: main rewrote the file with LF endings and added `test_background_inventory_handles_squeezed_solver_result`, so the whole file conflicted; rebuilt as main's version plus the PR's restated single-year test and new multi-period test. - CHANGES.md: PR entries moved to [Unreleased] above the released 0.6.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TimoDiepers
added a commit
that referenced
this pull request
Aug 20, 2026
Resolve conflicts against the v0.6.0 performance refactor (#64): - optimizer.py: main moved the flow and production rates into cached helpers (`flow_rate`, `production_rate`) that summed the tensor over the whole operation window. Both now take the lifecycle stage `tau` and return the per-tau entry, i.e. the flow/output per operating unit and year; the cache key carries `tau`. Call sites pass `t - v`. - optimizer.py: `OperationCapacity` keeps the PR's unit-count form and is indexed by ACTIVE_VINTAGE_TIME only, so `get_production_value` is now unused and removed. - tests/test_single_route_lca_comparison.py: main rewrote the file with LF endings and added `test_background_inventory_handles_squeezed_solver_result`, so the whole file conflicted; rebuilt as main's version plus the PR's restated single-year test and new multi-period test. - CHANGES.md: PR entries moved to [Unreleased] above the released 0.6.0.
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.
Why
Profiling the methanol & pig iron case study (ecoinvent 3.12 + four premise REMIND-EU vintages, four impact categories, 20 intermediate flows) showed almost all of the setup time going into
LCADataProcessor: 460 s of a 613 s end-to-end run.Two hotspots, both of them avoidable work rather than unavoidable computation.
Hotspot 1 — background inventory (~290 s)
Per intermediate flow, per impact method,
_calculate_inventory_of_dbdid:lca.lci(demand=...)B @ diag(x), the full 3341 x 43648 unaggregated inventory, of which only the row sums are usedto_dataframe(cutoff=1e4)annotate=True, callingbd.get_node()on ~3775 background activities per call — columns thatgroupby("row_code")then discardsHotspot 2 — dynamic characterization (~150 s)
_construct_characterization_tensorcalledcharacterize()once per elementary flow with a one-row dataframe. The per-call cost is not the characterization itself butcreate_characterization_functions_from_method, rebuilt every time:What changed
Batched inventory. All intermediate flows of a database are solved against a single factorization, and only the aggregated elementary flow vector is built:
That is the column of
B A^-1belonging to the flow — exactly what the optimization consumes.lci()is bypassed on purpose,to_dataframe()is gone entirely, and the method loop is removed.One-pass lookups.
bd.get_node()costs 0.3–1.4 s per call; iterating a full background database costs ~1.2 s. Activities are now resolved from a per-database name index, biosphere codes and names from a single pass over the biosphere database.Caching. Inventories are cached at module level per
(project, database, modified token, activity identity, cutoff), in the style ofbw_timex's background LCI cache. Themodifiedtoken means editing a database invalidates its entries instead of silently reusing stale ones. A rerun in the same session takes 0.2 s.clear_lca_caches()empties it.Parallel by default.
calculation_method="parallel"was aNotImplementedErrorstub; it now runs one worker process per background database via stdlibProcessPoolExecutor(no new dependency), and is the default. Building and factorizing each database's technosphere matrix is the remaining bulk of the work and is fully independent between databases. A single pending database stays in-process, so there is no pool overhead in that case. Workers inherit both the project and the Brightway base directories.Batched characterization. One
characterize()call for all flows per method, with the characterization functions built once and cached. The GWP branch also had an O(n^2)df.loc[df["flow"] == ...]lookup per result row, replaced with a map.Changed results — the old numbers were wrong
background_inventory.cutoffdefaulted to1e4, which truncated the unaggregated inventory to its 10 000 largest entries before summing per elementary flow. The resulting flow amounts were systematically low. Verified against bw2calc's own LCIA score for the case study's intermediate flows inei312_REMIND-EU_SSP2_NDC_2020:The default is now
None(keep every non-zero flow); a numericcutoffstill works but applies after aggregation. On the case study the unconstrained climate objective moves from 1.6890e-06 to 1.7327e-06 (+2.6%).Because nothing is dropped by magnitude any more, the number of elementary flows per database column rises sharply (445 -> 2794 for one flow), which by itself made model building 3x slower. So flows that have no characterization factor in any configured category are now dropped: they contribute exactly zero to every impact, and the inventory is expressed per (process, elementary flow, year) in the model. Flows constrained directly — iridium in the case study — are kept with
background_inventory.retain_flows, orrestrict_to_characterized_flows=Falsekeeps everything.Results
Case study, Apple M2 Pro, 10 cores:
LCADataProcessorcreate_modelRerunning the processor in the same session: 0.2 s.
Docs and notebooks
docs/content/optimization_setup.mdgains a Background Inventory section covering every setting, with warnings about dropped flows and about theif __name__ == "__main__":guard that a script needs for the parallel default (notebooks do not).docs/content/constraints.mdwarns at the flow limit section that constraining an uncharacterized flow requiresretain_flows.notebooks/methanol_and_iron.ipynbretains iridium explicitly and explains why.notebooks/methanol_and_iron_v0.5.0.ipynbpreserves the case study exactly as it was run for the paper with optimex 0.5.0, since the results there were produced with the truncating inventory.Tests
tests/test_background_inventory_performance.py, six new tests: agreement with bw2calc's inventory, cache reuse across processors, parallel == sequential, cutoff behaviour, flow dropping, and both ways of keeping a flow. Full suite: 127 passed, 3 skipped.Notes for review
black(from the repo's pre-commit config) reformatted parts oflca_processor.pythat this PR does not otherwise touch; the file was not previously black-clean.flake8still reports pre-existingE501s in untouched docstrings (37 on main, 26 here).scikit-umfpackis in the working tree'spyproject.tomlbut not committed here. It only affects which solver backs the factorization; the batching wins do not depend on it.Part 2 — building and solving the model
With the LCA processing fixed, the remaining time was in Pyomo. Profiling
create_modelon the paper inputs (7 processes, 20 intermediate flows, 751 elementary flows, 6 background databases, 31 years):Nearly 29 million
Paramlookups, each running full index validation, to read tensors that contain no decision variables at all.What changed
Data is combined as data. The background inventory is collapsed over background databases once —
sum_bkg G[bkg, i, e] * M[bkg, t]— into a plain dict keyed by (intermediate flow, elementary flow, year), instead of being re-evaluated through Params inside every expression rule.The sum over elementary flows is folded into the characterization factors. The impact of a process was written as
where each
scaled_inventory[p, e, t]is itself a sum over intermediate flows. SinceQand the background inventory are both data, the sum overecan be done up front, giving a characterized impact per unit of intermediate flow. The expression then runs over 20 intermediate flows instead of 751 elementary flows — the same number, from a tree that is orders of magnitude smaller. That matters twice: once when Pyomo builds it, and again when the LP writer walks it.Sparse, combined flow expressions.
scaled_*_dependent_on_installationandscaled_*_dependent_on_operationwere always used as a pair, and both were indexed densely over (process, flow, year) — for the elementary flows that is 163 k entries of which a handful are non-zero, since a process emits only a few flows directly. They are now a singlescaled_technosphere_flow/scaled_biosphere_flow/scaled_internal_demand_flowexpression indexed only over the (process, flow) pairs that carry data or a vintage override. Nothing outsideoptimizer.pyreferenced the old names.Caching in the rules. Production rates per (process, product, vintage), operation flow rates per (process, flow, vintage) and the active vintages per (process, year) were recomputed inside rules that run tens of thousands of times, each scanning the whole
ACTIVE_VINTAGE_TIMEset or the whole process-time range. All three are now precomputed or memoized.Post-processing.
get_dynamic_inventory()evaluatedscaled_inventory[p, e, t]— a deep expression tree — 163 k times, then calledbiosphere_db.get(code=...)once per row. It now multiplies solved flow values by the same background inventory data, and maps codes to ids from a single pass over the biosphere database.Results
Paper case study (
notebooks/data/paper/model_inputs_2050_paper.jsonplus the notebook's constraints), Apple M2 Pro:create_modelsolve_modelwall clockget_dynamic_inventory()Gurobi itself was always ~0.04 s here; the 55 s was Pyomo writing the LP file, which shrinks with the expression tree.
Smaller scenario (unconstrained, 230 flows, 4 databases): build 24.4 s → 0.9 s, solve 16.8 s → 0.1 s.
Equivalence
Both scenarios were solved before and after and compared:
1.732682875061e-06,2.880103392725e-06)var_installationand up to 2 983var_operationvalues identicalget_dynamic_inventory()returns the same 45 651 non-zero rows, max relative deviation1.5e-15The only intentional output change: rows whose amount is exactly zero are no longer emitted by
get_dynamic_inventory()(162 967 → 45 651 rows).characterize()discards them anyway.solve_modelgained no new behaviour;product_demand_fulfillment_rulenow returnsConstraint.Feasiblewhen a sparse expression collapses to a constant, which previously could not happen because every expression existed densely.Two things found on the way, not changed here
1.7326829e-06and1.7326549e-06. Within one process it is deterministic, andMethod=1, Threads=1also pins it. The LP has alternative optima and Gurobi's default concurrent method picks between them. Worth pinning for published runs.appsi_gurobicannot load the model:ProcessDeploymentLimitMax[...]has neither a lower nor an upper bound, because the limit defaults tofloat("inf")and the constraint is built unconditionally.Constraint.Skipfor infinite limits would remove those rows (Gurobi reports 4 056 rows for a model Pyomo counts as 4 410), but it changes the reported model size, so I left it alone.Addendum — on-disk inventory cache
With everything above in place, the LCA processing was ~97% of the runtime, essentially all of it bw2calc building and factorizing one technosphere matrix per background database. That work was cached for the running session but repeated by every new one.
Calculated inventories are now also written to
optimex-inventory-cache/inside the current Brightway project, one file per background database, keyed by the database'smodifiedtoken, the activity identity and the cutoff — a stale entry misses rather than being served. Writes are atomic (temp file plusos.replace) and happen in the parent process, so parallel workers never contend for the same file. Superseded files of the same database are pruned on write.Methanol & iron case study, whole pipeline:
The tensor built from the cache is bit-identical to a freshly calculated one (27 600 entries, max relative deviation 0.0). Cache size is ~1.3 MB per background database.
Settings:
background_inventory.use_disk_cache(defaultTrue) anddisk_cache_dir.lca_processor.clear_lca_caches(include_disk=True)removes the files. Three tests cover reuse across a cleared session, invalidation after a database edit, and the disabled case.