From 9085fbc30fced7bf19b03029d2a5279b2e22e7cf Mon Sep 17 00:00:00 2001 From: TimoDiepers Date: Mon, 6 Jul 2026 09:59:09 +0200 Subject: [PATCH 1/8] fix: snap background consumer lookup to nearest registered year Defense-in-depth for the traverse_background producer/consumer year-band mismatch: when a descended background node is consumed at a grouped year with no time-mapped column for its own (variant, code), snap to the nearest registered year instead of raising KeyError. --- bw_timex/timeline_builder.py | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/bw_timex/timeline_builder.py b/bw_timex/timeline_builder.py index ab8809de..cc9e1791 100644 --- a/bw_timex/timeline_builder.py +++ b/bw_timex/timeline_builder.py @@ -446,12 +446,37 @@ def get_time_mapping_key(self, node_id: int, node_hash: int) -> int: time_mapping_id (key) of the corresponding time-mapped activity. """ - try: - return self.activity_time_mapping[ - (("temporalized", self.nodes[node_id]["code"]), node_hash) + code = self.nodes[node_id]["code"] + db_keys = (("temporalized", code), self.nodes[node_id].key) + for db_key in db_keys: + try: + return self.activity_time_mapping[(db_key, node_hash)] + except KeyError: + continue + return self._nearest_time_mapping_key(db_keys, node_hash) + + def _nearest_time_mapping_key(self, db_keys: tuple, node_hash: int) -> int: + """Fallback for a node consumed at a year that has no time-mapped column. + + A background producer that is descended into is registered in + ``activity_time_mapping`` at its producer-side (delivery) years, but its + own production-edge TD shifts the years at which it *operates* (and is + therefore consumed as an input). When that operating year falls outside + the producer-side band, the exact lookup misses. Rather than raise (which + would leave the edge dangling and the technosphere nonsquare), snap to + the nearest registered year for the same node so the edge attaches to an + existing column. Mirrors the nearest-database interpolation used + everywhere else for out-of-band dates. + """ + for db_key in db_keys: + candidates = [ + (h, v) + for (k, h), v in self.activity_time_mapping.items() + if k == db_key ] - except KeyError: - return self.activity_time_mapping[((self.nodes[node_id].key), node_hash)] + if candidates: + return min(candidates, key=lambda hv: abs(hv[0] - node_hash))[1] + raise KeyError((db_keys[-1], node_hash)) def _leaf_background_producers(self, edges_df: pd.DataFrame) -> set: """Producers that are leaves (never traversed into) and live in a static From 7ed8929f8257b94b646766dff6c78d2e48d65942 Mon Sep 17 00:00:00 2001 From: TimoDiepers Date: Mon, 6 Jul 2026 10:02:59 +0200 Subject: [PATCH 2/8] test: failing conservation test for background production-edge TD --- tests/conftest.py | 1 + .../fixtures/background_prod_td_db_fixture.py | 69 +++++++++++++++++++ tests/test_background_production_td.py | 28 ++++++++ 3 files changed, 98 insertions(+) create mode 100644 tests/fixtures/background_prod_td_db_fixture.py create mode 100644 tests/test_background_production_td.py diff --git a/tests/conftest.py b/tests/conftest.py index a16af030..03374cdc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +from .fixtures.background_prod_td_db_fixture import background_prod_td_db from .fixtures.background_td_db_fixture import background_td_db from .fixtures.background_td_deep_db_fixture import background_td_deep_db from .fixtures.background_td_deep_chain_db_fixture import background_td_deep_chain_db diff --git a/tests/fixtures/background_prod_td_db_fixture.py b/tests/fixtures/background_prod_td_db_fixture.py new file mode 100644 index 00000000..96f78353 --- /dev/null +++ b/tests/fixtures/background_prod_td_db_fixture.py @@ -0,0 +1,69 @@ +import bw2data as bd +import numpy as np +import pytest +from bw2data.tests import bw2test +from bw_temporalis import TemporalDistribution + + +@pytest.fixture +@bw2test +def background_prod_td_db(): + """fu -> bg_A -> bg_B -> bg_C -> CO2, two dated variants. + + bg_A->bg_B carries a technosphere TD (triggers the variant-split descent). + bg_B carries a PRODUCTION-edge TD spread over several years, so the descent + must register bg_B at the same production-TD-weighted cohorts it consumes + bg_C at. All coefficients are 1, so the total impact must equal 1.0. + """ + biosphere = bd.Database("biosphere") + biosphere.write( + {("biosphere", "CO2"): {"type": "emission", "name": "carbon dioxide"}} + ) + co2 = biosphere.get("CO2") + + foreground = bd.Database("foreground") + foreground.register() + bg20 = bd.Database("background_2020") + bg20.register() + bg30 = bd.Database("background_2030") + bg30.register() + + fu = foreground.new_node("fu", name="fu", unit="unit") + fu["reference product"] = "fu" + fu.save() + fu.new_edge(input=fu, amount=1, type="production").save() + + td_a_to_b = TemporalDistribution( + date=np.array([0, 10], dtype="timedelta64[Y]"), + amount=np.array([0.6, 0.4]), + ) + prod_td_b = TemporalDistribution( + date=np.array([0, 3, 6], dtype="timedelta64[Y]"), + amount=np.array([0.5, 0.3, 0.2]), + ) + + variants = {} + for db in (bg20, bg30): + bg_a = db.new_node("bg_A", name="bg_A", unit="k"); bg_a["reference product"] = "bg_A"; bg_a.save() + bg_b = db.new_node("bg_B", name="bg_B", unit="k"); bg_b["reference product"] = "bg_B"; bg_b.save() + bg_c = db.new_node("bg_C", name="bg_C", unit="k"); bg_c["reference product"] = "bg_C"; bg_c.save() + + bg_a.new_edge(input=bg_a, amount=1, type="production").save() + pb = bg_b.new_edge(input=bg_b, amount=1, type="production") + pb["temporal_distribution"] = prod_td_b + pb.save() + bg_c.new_edge(input=bg_c, amount=1, type="production").save() + + e = bg_a.new_edge(input=bg_b, amount=1, type="technosphere") + e["temporal_distribution"] = td_a_to_b + e.save() + bg_b.new_edge(input=bg_c, amount=1, type="technosphere").save() + bg_c.new_edge(input=co2, amount=1, type="biosphere").save() + variants[db.name] = {"bg_A": bg_a, "bg_B": bg_b, "bg_C": bg_c} + + fu.new_edge(input=variants["background_2020"]["bg_A"], amount=1, type="technosphere").save() + + bd.Method(("GWP", "example")).write([(("biosphere", "CO2"), 1)]) + for dbn in bd.databases: + bd.Database(dbn).process() + return variants diff --git a/tests/test_background_production_td.py b/tests/test_background_production_td.py new file mode 100644 index 00000000..a40ba35e --- /dev/null +++ b/tests/test_background_production_td.py @@ -0,0 +1,28 @@ +from datetime import datetime + +import pytest + +from bw_timex import TimexLCA + +METHOD = ("GWP", "example") +DATABASE_DATES = { + "background_2020": datetime(2020, 1, 1), + "background_2030": datetime(2030, 1, 1), + "foreground": "dynamic", +} + + +@pytest.mark.parametrize("graph_traversal", ["priority", "bfs"]) +def test_first_level_production_td_conserves(background_prod_td_db, graph_traversal): + t = TimexLCA({("foreground", "fu"): 1}, METHOD, DATABASE_DATES) + t.build_timeline( + starting_datetime="2020-01-01", + temporal_grouping="year", + graph_traversal=graph_traversal, + traverse_background=True, + cutoff=1e-9, + max_calc=2000, + ) + t.lci() + t.static_lcia() + assert t.static_score == pytest.approx(t.base_lca.score, rel=1e-6) From 48884ea12884d2c4992736239d48b374ca751c01 Mon Sep 17 00:00:00 2001 From: TimoDiepers Date: Mon, 6 Jul 2026 10:05:14 +0200 Subject: [PATCH 3/8] feat: add production-TD outer-product fold helper --- bw_timex/edge_extractor.py | 20 ++++++++++++++++++++ tests/test_edge_extractor.py | 23 ++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/bw_timex/edge_extractor.py b/bw_timex/edge_extractor.py index 1e4ff07f..0d6e1262 100644 --- a/bw_timex/edge_extractor.py +++ b/bw_timex/edge_extractor.py @@ -220,6 +220,26 @@ def _normalized_production_edge_td_from_proxy(self, process_id: int): return None return td / abs(production["amount"]) + @staticmethod + def _fold_production_td(base_td, prod_td): + """Convolve ``base_td`` with a producer's normalized production-edge TD. + + Unlike ``_join_datetime_and_timedelta_distributions`` (which tiles the + producer amounts and drops the consumer-side amounts), this takes the + outer product of amounts and the outer sum of dates, so the cohort + weights carried in ``base_td`` are preserved. Ravel is ``base``-major so + the result stays index-aligned with a sibling ``base_td`` folded the same + way. Used to register a descended background producer at its + production-TD-weighted cohorts. + """ + date = ( + base_td.date.reshape(-1, 1) + prod_td.date.reshape(1, -1) + ).ravel() + amount = ( + base_td.amount.reshape(-1, 1) * prod_td.amount.reshape(1, -1) + ).ravel() + return TemporalDistribution(date=date, amount=amount) + def _producer_process_in_variant(self, product_id: int, db_name: str): """Resolve the process producing ``product_id`` within variant ``db_name`` from the proxy. diff --git a/tests/test_edge_extractor.py b/tests/test_edge_extractor.py index e87b1098..8a1a5056 100644 --- a/tests/test_edge_extractor.py +++ b/tests/test_edge_extractor.py @@ -4,7 +4,10 @@ import pytest from bw_temporalis import TemporalDistribution -from bw_timex.edge_extractor import _join_datetime_and_timedelta_distributions +from bw_timex.edge_extractor import ( + _join_datetime_and_timedelta_distributions, + VariantBackgroundMixin, +) class TestJoinDatetimeAndTimedeltaDistributions: @@ -91,3 +94,21 @@ def test_invalid_types_raises(self): ) with pytest.raises(ValueError, match="Can't join"): _join_datetime_and_timedelta_distributions("not_a_td", td_consumer) + + +def test_fold_production_td_outer_product(): + base = TemporalDistribution( + date=np.array([0, 10], dtype="timedelta64[Y]"), + amount=np.array([0.6, 0.4]), + ) + prod = TemporalDistribution( + date=np.array([0, 3], dtype="timedelta64[Y]"), + amount=np.array([0.5, 0.5]), + ) + out = VariantBackgroundMixin._fold_production_td(base, prod) + # dates: 0+0, 0+3, 10+0, 10+3 (i-major) + assert list(out.date.astype("timedelta64[Y]").astype(int)) == [0, 3, 10, 13] + # amounts: 0.6*0.5, 0.6*0.5, 0.4*0.5, 0.4*0.5 + np.testing.assert_allclose(out.amount, [0.3, 0.3, 0.2, 0.2]) + # total weight preserved (prod is normalized) + assert out.amount.sum() == pytest.approx(base.amount.sum()) From 0d7e8f11355f65e8693c68c82bc713ecc442042d Mon Sep 17 00:00:00 2001 From: TimoDiepers Date: Mon, 6 Jul 2026 10:09:01 +0200 Subject: [PATCH 4/8] fix: fold production-edge TD into first-level background variant split --- bw_timex/edge_extractor.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/bw_timex/edge_extractor.py b/bw_timex/edge_extractor.py index 0d6e1262..d144984f 100644 --- a/bw_timex/edge_extractor.py +++ b/bw_timex/edge_extractor.py @@ -402,6 +402,26 @@ def _emit_variant_split_for_consumer_date( variant_id = self._resolve_in_variant(producer_process, db_name) self.variant_resolved_producers.add(variant_id) + # If this background producer has a production-edge TD, spread it into + # production-TD-weighted cohorts on the PRODUCER side too, so it is + # registered at exactly the cohort-years it is later consumed at + # (bands match -> no KeyError) with weights = exchange x production + # (conserves). Fold identically into all three arrays to keep them + # index-aligned for extract_edge_data. + producer_production_td = self._normalized_production_edge_td_from_proxy( + variant_id + ) + if producer_production_td is not None: + masked_td_producer = self._fold_production_td( + masked_td_producer, producer_production_td + ) + masked_abs_td_producer = self._fold_production_td( + masked_abs_td_producer, producer_production_td + ) + masked_distribution = self._fold_production_td( + masked_distribution, producer_production_td + ) + edges.append( Edge( edge_type=edge_type, @@ -418,14 +438,6 @@ def _emit_variant_split_for_consumer_date( ) child_td, child_abs_td = masked_distribution, masked_abs_td_producer - producer_production_td = self._normalized_production_edge_td_from_proxy( - variant_id - ) - if producer_production_td is not None: - child_td = (masked_distribution * producer_production_td).simplify() - child_abs_td = _join_datetime_and_timedelta_distributions( - producer_production_td, masked_abs_td_producer - ) # Cutoff-tracking estimate only — never feeds emitted amounts. # Emitted amounts come from the masked distributions above. From a077300c9031dbf70edcf3db11708fa00fa5a637 Mon Sep 17 00:00:00 2001 From: TimoDiepers Date: Mon, 6 Jul 2026 10:25:10 +0200 Subject: [PATCH 5/8] fix: fold production-edge TD into background descent edges --- bw_timex/edge_extractor.py | 33 ++++++--- tests/conftest.py | 5 +- .../fixtures/background_prod_td_db_fixture.py | 71 +++++++++++++++++++ tests/test_background_production_td.py | 16 +++++ 4 files changed, 113 insertions(+), 12 deletions(-) diff --git a/bw_timex/edge_extractor.py b/bw_timex/edge_extractor.py index d144984f..8629e22e 100644 --- a/bw_timex/edge_extractor.py +++ b/bw_timex/edge_extractor.py @@ -539,6 +539,27 @@ def _descend_variant_subtree( if self._is_static_background(input_id): self.variant_resolved_producers.add(input_id) + # Fold this producer's own production-edge TD into the producer + # side so it is registered at the same production-TD-weighted + # cohorts it is consumed at (bands match -> no KeyError; weights + # = exchange x production -> conserves). Fold identically into + # td_producer/abs_td_producer/distribution to keep them aligned. + producer_production_td = None + if producer_process is not None: + producer_production_td = ( + self._normalized_production_edge_td_from_proxy(producer_process) + ) + if producer_production_td is not None: + td_producer = self._fold_production_td( + td_producer, producer_production_td + ) + abs_td_producer = self._fold_production_td( + abs_td_producer, producer_production_td + ) + distribution = self._fold_production_td( + distribution, producer_production_td + ) + edges.append( Edge( edge_type=edge_type, @@ -557,18 +578,8 @@ def _descend_variant_subtree( if not will_descend: continue - child_td, child_abs_td = distribution, abs_td_producer - producer_production_td = ( - self._normalized_production_edge_td_from_proxy(producer_process) - ) - if producer_production_td is not None: - child_td = (distribution * producer_production_td).simplify() - child_abs_td = _join_datetime_and_timedelta_distributions( - producer_production_td, abs_td_producer - ) - queue.append( - (producer_process, child_td, td_producer, child_abs_td, new_supply) + (producer_process, distribution, td_producer, abs_td_producer, new_supply) ) return edges diff --git a/tests/conftest.py b/tests/conftest.py index 03374cdc..c49a306e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,7 @@ -from .fixtures.background_prod_td_db_fixture import background_prod_td_db +from .fixtures.background_prod_td_db_fixture import ( + background_prod_td_db, + background_prod_td_deep_db, +) from .fixtures.background_td_db_fixture import background_td_db from .fixtures.background_td_deep_db_fixture import background_td_deep_db from .fixtures.background_td_deep_chain_db_fixture import background_td_deep_chain_db diff --git a/tests/fixtures/background_prod_td_db_fixture.py b/tests/fixtures/background_prod_td_db_fixture.py index 96f78353..bfe8116e 100644 --- a/tests/fixtures/background_prod_td_db_fixture.py +++ b/tests/fixtures/background_prod_td_db_fixture.py @@ -67,3 +67,74 @@ def background_prod_td_db(): for dbn in bd.databases: bd.Database(dbn).process() return variants + + +@pytest.fixture +@bw2test +def background_prod_td_deep_db(): + """fu -> bg_A -> bg_B -> bg_C -> bg_D -> CO2, two variants. + + bg_A->bg_B carries a technosphere TD (starts the descent, one level above + the node under test). bg_C -- reached one level deeper, inside the + locked-variant descent -- carries the production-edge TD and has its own + downstream technosphere child (bg_D), so bg_C is re-queued and dequeued as + ``cur_id`` inside ``_descend_variant_subtree`` (unlike a leaf production-TD + node, which never re-enters the loop and so can't expose the bug). This + exercises the descent emit site rather than the first-level split. Total + impact must equal 1.0. + """ + biosphere = bd.Database("biosphere") + biosphere.write( + {("biosphere", "CO2"): {"type": "emission", "name": "carbon dioxide"}} + ) + co2 = biosphere.get("CO2") + + foreground = bd.Database("foreground") + foreground.register() + bg20 = bd.Database("background_2020") + bg20.register() + bg30 = bd.Database("background_2030") + bg30.register() + + fu = foreground.new_node("fu", name="fu", unit="unit") + fu["reference product"] = "fu" + fu.save() + fu.new_edge(input=fu, amount=1, type="production").save() + + td_a_to_b = TemporalDistribution( + date=np.array([0, 10], dtype="timedelta64[Y]"), + amount=np.array([0.6, 0.4]), + ) + prod_td_c = TemporalDistribution( + date=np.array([0, 3, 6], dtype="timedelta64[Y]"), + amount=np.array([0.5, 0.3, 0.2]), + ) + + variants = {} + for db in (bg20, bg30): + bg_a = db.new_node("bg_A", name="bg_A", unit="k"); bg_a["reference product"] = "bg_A"; bg_a.save() + bg_b = db.new_node("bg_B", name="bg_B", unit="k"); bg_b["reference product"] = "bg_B"; bg_b.save() + bg_c = db.new_node("bg_C", name="bg_C", unit="k"); bg_c["reference product"] = "bg_C"; bg_c.save() + bg_d = db.new_node("bg_D", name="bg_D", unit="k"); bg_d["reference product"] = "bg_D"; bg_d.save() + + bg_a.new_edge(input=bg_a, amount=1, type="production").save() + bg_b.new_edge(input=bg_b, amount=1, type="production").save() + pc = bg_c.new_edge(input=bg_c, amount=1, type="production") + pc["temporal_distribution"] = prod_td_c + pc.save() + bg_d.new_edge(input=bg_d, amount=1, type="production").save() + + e = bg_a.new_edge(input=bg_b, amount=1, type="technosphere") + e["temporal_distribution"] = td_a_to_b + e.save() + bg_b.new_edge(input=bg_c, amount=1, type="technosphere").save() + bg_c.new_edge(input=bg_d, amount=1, type="technosphere").save() + bg_d.new_edge(input=co2, amount=1, type="biosphere").save() + variants[db.name] = {"bg_A": bg_a, "bg_B": bg_b, "bg_C": bg_c, "bg_D": bg_d} + + fu.new_edge(input=variants["background_2020"]["bg_A"], amount=1, type="technosphere").save() + + bd.Method(("GWP", "example")).write([(("biosphere", "CO2"), 1)]) + for dbn in bd.databases: + bd.Database(dbn).process() + return variants diff --git a/tests/test_background_production_td.py b/tests/test_background_production_td.py index a40ba35e..39dd579a 100644 --- a/tests/test_background_production_td.py +++ b/tests/test_background_production_td.py @@ -26,3 +26,19 @@ def test_first_level_production_td_conserves(background_prod_td_db, graph_traver t.lci() t.static_lcia() assert t.static_score == pytest.approx(t.base_lca.score, rel=1e-6) + + +@pytest.mark.parametrize("graph_traversal", ["priority", "bfs"]) +def test_deep_production_td_conserves(background_prod_td_deep_db, graph_traversal): + t = TimexLCA({("foreground", "fu"): 1}, METHOD, DATABASE_DATES) + t.build_timeline( + starting_datetime="2020-01-01", + temporal_grouping="year", + graph_traversal=graph_traversal, + traverse_background=True, + cutoff=1e-9, + max_calc=2000, + ) + t.lci() + t.static_lcia() + assert t.static_score == pytest.approx(t.base_lca.score, rel=1e-6) From af84c311a487830e84953382281661d031abd959 Mon Sep 17 00:00:00 2001 From: TimoDiepers Date: Mon, 6 Jul 2026 10:52:45 +0200 Subject: [PATCH 6/8] test: convergent background production-edge TD conserves --- tests/conftest.py | 1 + .../fixtures/background_prod_td_db_fixture.py | 66 +++++++++++++++++++ tests/test_background_production_td.py | 18 +++++ 3 files changed, 85 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index c49a306e..0b31ac49 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ from .fixtures.background_prod_td_db_fixture import ( + background_prod_td_convergent_db, background_prod_td_db, background_prod_td_deep_db, ) diff --git a/tests/fixtures/background_prod_td_db_fixture.py b/tests/fixtures/background_prod_td_db_fixture.py index bfe8116e..df0cfd92 100644 --- a/tests/fixtures/background_prod_td_db_fixture.py +++ b/tests/fixtures/background_prod_td_db_fixture.py @@ -138,3 +138,69 @@ def background_prod_td_deep_db(): for dbn in bd.databases: bd.Database(dbn).process() return variants + + +@pytest.fixture +@bw2test +def background_prod_td_convergent_db(): + """fu -> bg_A -> {bg_S, bg_R -> bg_S}; bg_S -> CO2, two variants. + + bg_S (production-edge TD) is reached both directly from bg_A and via bg_R, + so it appears at multiple cohorts through two paths. Total impact = 2.0 + (bg_A demands bg_S once directly and once through bg_R, coefficients 1). + """ + biosphere = bd.Database("biosphere") + biosphere.write( + {("biosphere", "CO2"): {"type": "emission", "name": "carbon dioxide"}} + ) + co2 = biosphere.get("CO2") + + foreground = bd.Database("foreground") + foreground.register() + bg20 = bd.Database("background_2020") + bg20.register() + bg30 = bd.Database("background_2030") + bg30.register() + + fu = foreground.new_node("fu", name="fu", unit="unit") + fu["reference product"] = "fu" + fu.save() + fu.new_edge(input=fu, amount=1, type="production").save() + + td = TemporalDistribution( + date=np.array([0, 8], dtype="timedelta64[Y]"), + amount=np.array([0.7, 0.3]), + ) + prod_td_s = TemporalDistribution( + date=np.array([0, 4], dtype="timedelta64[Y]"), + amount=np.array([0.6, 0.4]), + ) + + variants = {} + for db in (bg20, bg30): + bg_a = db.new_node("bg_A", name="bg_A", unit="k"); bg_a["reference product"] = "bg_A"; bg_a.save() + bg_r = db.new_node("bg_R", name="bg_R", unit="k"); bg_r["reference product"] = "bg_R"; bg_r.save() + bg_s = db.new_node("bg_S", name="bg_S", unit="k"); bg_s["reference product"] = "bg_S"; bg_s.save() + + bg_a.new_edge(input=bg_a, amount=1, type="production").save() + bg_r.new_edge(input=bg_r, amount=1, type="production").save() + ps = bg_s.new_edge(input=bg_s, amount=1, type="production") + ps["temporal_distribution"] = prod_td_s + ps.save() + + e1 = bg_a.new_edge(input=bg_s, amount=1, type="technosphere") + e1["temporal_distribution"] = td + e1.save() + e2 = bg_a.new_edge(input=bg_r, amount=1, type="technosphere") + e2["temporal_distribution"] = td + e2.save() + bg_r.new_edge(input=bg_s, amount=1, type="technosphere").save() + bg_s.new_edge(input=co2, amount=1, type="biosphere").save() + variants[db.name] = {"bg_A": bg_a, "bg_R": bg_r, "bg_S": bg_s} + + fu.new_edge(input=variants["background_2020"]["bg_A"], amount=1, type="technosphere").save() + + bd.Method(("GWP", "example")).write([(("biosphere", "CO2"), 1)]) + for dbn in bd.databases: + bd.Database(dbn).process() + return variants diff --git a/tests/test_background_production_td.py b/tests/test_background_production_td.py index 39dd579a..aae86c88 100644 --- a/tests/test_background_production_td.py +++ b/tests/test_background_production_td.py @@ -42,3 +42,21 @@ def test_deep_production_td_conserves(background_prod_td_deep_db, graph_traversa t.lci() t.static_lcia() assert t.static_score == pytest.approx(t.base_lca.score, rel=1e-6) + + +@pytest.mark.parametrize("graph_traversal", ["priority", "bfs"]) +def test_convergent_production_td_conserves( + background_prod_td_convergent_db, graph_traversal +): + t = TimexLCA({("foreground", "fu"): 1}, METHOD, DATABASE_DATES) + t.build_timeline( + starting_datetime="2020-01-01", + temporal_grouping="year", + graph_traversal=graph_traversal, + traverse_background=True, + cutoff=1e-9, + max_calc=5000, + ) + t.lci() + t.static_lcia() + assert t.static_score == pytest.approx(t.base_lca.score, rel=1e-6) From fd4e2c23a961312b55ce39ab8f236992b03b4ff3 Mon Sep 17 00:00:00 2001 From: TimoDiepers Date: Mon, 6 Jul 2026 11:10:08 +0200 Subject: [PATCH 7/8] test: make convergent production-TD test exercise the over-count bug bg_S previously only had a biosphere output, so it was never actually consumed downstream at its production-TD-shifted cohorts and the test passed regardless of whether the descent-site fix was present. Route bg_S through a new technosphere child (bg_T) before the biosphere flow so both parent paths genuinely re-consume it. Also log a warning when a background consumer lookup falls back to the nearest registered year, so a real year mismatch is observable instead of resolving silently. --- bw_timex/timeline_builder.py | 12 +++++++++++- tests/fixtures/background_prod_td_db_fixture.py | 17 ++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/bw_timex/timeline_builder.py b/bw_timex/timeline_builder.py index cc9e1791..8fb05857 100644 --- a/bw_timex/timeline_builder.py +++ b/bw_timex/timeline_builder.py @@ -475,7 +475,17 @@ def _nearest_time_mapping_key(self, db_keys: tuple, node_hash: int) -> int: if k == db_key ] if candidates: - return min(candidates, key=lambda hv: abs(hv[0] - node_hash))[1] + nearest_hash, nearest_value = min( + candidates, key=lambda hv: abs(hv[0] - node_hash) + ) + logger.warning( + "No exact time-mapped column for node {} at requested year-hash " + "{}; snapping to nearest registered year-hash {} instead.", + db_key, + node_hash, + nearest_hash, + ) + return nearest_value raise KeyError((db_keys[-1], node_hash)) def _leaf_background_producers(self, edges_df: pd.DataFrame) -> set: diff --git a/tests/fixtures/background_prod_td_db_fixture.py b/tests/fixtures/background_prod_td_db_fixture.py index df0cfd92..98d32575 100644 --- a/tests/fixtures/background_prod_td_db_fixture.py +++ b/tests/fixtures/background_prod_td_db_fixture.py @@ -143,11 +143,15 @@ def background_prod_td_deep_db(): @pytest.fixture @bw2test def background_prod_td_convergent_db(): - """fu -> bg_A -> {bg_S, bg_R -> bg_S}; bg_S -> CO2, two variants. + """fu -> bg_A -> {bg_S, bg_R -> bg_S}; bg_S -> bg_T -> CO2, two variants. bg_S (production-edge TD) is reached both directly from bg_A and via bg_R, - so it appears at multiple cohorts through two paths. Total impact = 2.0 - (bg_A demands bg_S once directly and once through bg_R, coefficients 1). + so it appears at multiple cohorts through two paths. bg_S has a downstream + technosphere child (bg_T) so it is genuinely consumed at the production-TD- + shifted cohorts through both parents -- unlike a bare biosphere-emitting + leaf, which is never "consumed" and so can't expose the over-count bug. + Total impact = 2.0 (bg_A demands bg_S once directly and once through bg_R, + coefficients 1). """ biosphere = bd.Database("biosphere") biosphere.write( @@ -181,12 +185,14 @@ def background_prod_td_convergent_db(): bg_a = db.new_node("bg_A", name="bg_A", unit="k"); bg_a["reference product"] = "bg_A"; bg_a.save() bg_r = db.new_node("bg_R", name="bg_R", unit="k"); bg_r["reference product"] = "bg_R"; bg_r.save() bg_s = db.new_node("bg_S", name="bg_S", unit="k"); bg_s["reference product"] = "bg_S"; bg_s.save() + bg_t = db.new_node("bg_T", name="bg_T", unit="k"); bg_t["reference product"] = "bg_T"; bg_t.save() bg_a.new_edge(input=bg_a, amount=1, type="production").save() bg_r.new_edge(input=bg_r, amount=1, type="production").save() ps = bg_s.new_edge(input=bg_s, amount=1, type="production") ps["temporal_distribution"] = prod_td_s ps.save() + bg_t.new_edge(input=bg_t, amount=1, type="production").save() e1 = bg_a.new_edge(input=bg_s, amount=1, type="technosphere") e1["temporal_distribution"] = td @@ -195,8 +201,9 @@ def background_prod_td_convergent_db(): e2["temporal_distribution"] = td e2.save() bg_r.new_edge(input=bg_s, amount=1, type="technosphere").save() - bg_s.new_edge(input=co2, amount=1, type="biosphere").save() - variants[db.name] = {"bg_A": bg_a, "bg_R": bg_r, "bg_S": bg_s} + bg_s.new_edge(input=bg_t, amount=1, type="technosphere").save() + bg_t.new_edge(input=co2, amount=1, type="biosphere").save() + variants[db.name] = {"bg_A": bg_a, "bg_R": bg_r, "bg_S": bg_s, "bg_T": bg_t} fu.new_edge(input=variants["background_2020"]["bg_A"], amount=1, type="technosphere").save() From 679302c4e8fa141a832b5c81919e96f6953aa1a8 Mon Sep 17 00:00:00 2001 From: TimoDiepers Date: Thu, 30 Jul 2026 14:21:38 +0200 Subject: [PATCH 8/8] wip --- ...ackground-out-of-range-variant-mismatch.md | 96 ++ .../2026-06-23-adjoint-traversal-scoring.md | 605 ++++++++++++ .../plans/2026-06-24-persistent-cache.md | 883 ++++++++++++++++++ .../2026-06-24-premise-temporal-annotation.md | 788 ++++++++++++++++ ...6-background-production-td-conservation.md | 729 +++++++++++++++ ...-06-23-adjoint-traversal-scoring-design.md | 163 ++++ .../2026-06-24-persistent-cache-design.md | 180 ++++ ...6-24-premise-temporal-annotation-design.md | 183 ++++ ...s-vs-timex-diesel-car-comparison-design.md | 139 +++ ...raverse-background-production-td-design.md | 138 +++ .../example_electric_vehicle_premise.ipynb | 4 +- ...e_premise_temporal_comparison_trails.ipynb | 286 ++++++ ...ample_premise_temporal_distributions.ipynb | 158 ++-- 13 files changed, 4282 insertions(+), 70 deletions(-) create mode 100644 docs/superpowers/bug-traverse-background-out-of-range-variant-mismatch.md create mode 100644 docs/superpowers/plans/2026-06-23-adjoint-traversal-scoring.md create mode 100644 docs/superpowers/plans/2026-06-24-persistent-cache.md create mode 100644 docs/superpowers/plans/2026-06-24-premise-temporal-annotation.md create mode 100644 docs/superpowers/plans/2026-07-06-background-production-td-conservation.md create mode 100644 docs/superpowers/specs/2026-06-23-adjoint-traversal-scoring-design.md create mode 100644 docs/superpowers/specs/2026-06-24-persistent-cache-design.md create mode 100644 docs/superpowers/specs/2026-06-24-premise-temporal-annotation-design.md create mode 100644 docs/superpowers/specs/2026-07-02-trails-vs-timex-diesel-car-comparison-design.md create mode 100644 docs/superpowers/specs/2026-07-06-traverse-background-production-td-design.md create mode 100644 notebooks/example_premise_temporal_comparison_trails.ipynb diff --git a/docs/superpowers/bug-traverse-background-out-of-range-variant-mismatch.md b/docs/superpowers/bug-traverse-background-out-of-range-variant-mismatch.md new file mode 100644 index 00000000..1db865fa --- /dev/null +++ b/docs/superpowers/bug-traverse-background-out-of-range-variant-mismatch.md @@ -0,0 +1,96 @@ +# Bug: `traverse_background` — out-of-range dates source different variants on producer vs consumer side + +**Status:** open (diagnosed, not fixed). Separate from the already-fixed +`max_calc`-truncation bug (`ed1ae5e`, *cap max_calc-truncated background frontier +as static leaf*). + +**Affects:** `TimexLCA.build_timeline(traverse_background=True)` on real premise +backgrounds with deeply-stacked long-lifetime temporal distributions. + +## Symptom + +`build_timeline` raises + +``` +KeyError: (('dp312_SSP2_NDC_2020', ''), 1970) +``` + +in `timeline_builder.get_time_mapping_key`, or (if the missing key is forced in) +`NonsquareTechnosphere` in `lci()`. Small in-range graphs never hit it; it needs +a real premise chain whose stacked negative TDs push a node's date **outside** +`database_dates`. + +## Reproduction + +Reliably reproduced by the diesel-car premise case +(`notebooks/example_premise_temporal_comparison_trails.ipynb`): a +`transport, passenger, car, diesel` foreground on the `dp312_SSP2_NDC_*` +(REMIND-EU SSP2-NDC) variants, `traverse_background=True`, +`cutoff=1e-3`, `max_calc=2000`, `graph_traversal="bfs"`, `starting_datetime=2050`. + +**A minimal fixture reproduction was NOT found** despite ~8 attempts +(single-path and dual-role, in-range and out-of-range, 2–3 variants). The trigger +needs the specific convergence / dual-role structure of the premise graph (a +common infrastructure activity reached both as a first-level market and deep as a +variant-resolved producer, at an out-of-range date). This is the main thing to +crack to get a TDD failing test. + +## Root cause (evidenced) + +The failing node is **"road construction" (`road`, RoW)**, consumed at year +**1970** — *below* the earliest database date (2020), i.e. **out of range**. + +Instrumenting the raw (pre-rounding) dates shows road construction's **producer** +date-spread and **consumer** date-spread **match** (both `1970–2009`, plus a +separate `2010–2048` foreground cohort). So this is **not** a date/rounding +bug — the years agree. + +The mismatch is the **variant (database)**: + +- The **consumer** side resolves road@1970 to the **nearest database by date** → + `background_2020` (the normal, always-working interpolation path). Lookup key: + `(('dp312_SSP2_NDC_2020', code), 1970)`. +- The **producer** side registers road@1970 under whatever **variant the descent + routed it to** (path/cohort-dependent — e.g. the 2040 or 2050 variant), because + the registration uses `db_key = producer_node["database"]`. + +Same logical activity, same year, but **two different variant node-ids** → +registered under one `(variant, 1970)`, looked up under another → `KeyError`. +(`road_ids` has one id per variant, confirming distinct per-variant nodes.) + +Intended behaviour (per maintainer): a node at an out-of-range date should just +**take data from the nearest database on both sides**, as the normal +interpolation already does everywhere else. The producer/registration side is the +one not doing so. + +## Where to look + +- `bw_timex/timeline_builder.py` + - producer registration loop: `db_key = producer_node["database"]` (the + variant-resolved producer is keyed under its routed variant). + - `get_time_mapping_key` (consumer lookup): uses `self.nodes[node_id].key` + (the nearest-DB-resolved variant on the consumer side). + - `_leaf_background_producers` / `add_column_temporal_market_shares_to_timeline` + (the nearest-DB market path the consumer side follows). +- `bw_timex/edge_extractor.py` + - `_descend_variant_subtree`: variant routing during descent (cohort/date that + determines the producer's variant id), and `variant_resolved_producers`. + +## Suggested fix direction + +Reconcile the variant assignment for out-of-range dates so the producer side uses +the **nearest database by the node's own date**, matching the consumer/market +interpolation — instead of the descent-routed variant. Equivalently: a +variant-resolved producer whose date falls outside `database_dates` should be +treated like the nearest-DB market (the same treatment the consumer side already +applies), not temporalized under its routed variant. + +## TDD note + +Write the failing test first. The open problem is a **minimal** reproduction: +construct a 2–3 variant fixture where the same background activity is reached both +as a first-level nearest-DB market and as a deep variant-resolved producer at a +date pushed below the earliest DB by stacked negative TDs, and assert +`build_timeline` + `lci()` + `static_lcia()` succeed and conserve impact. If a +minimal fixture stays elusive, the diesel case is the reliable (integration) +reproduction. diff --git a/docs/superpowers/plans/2026-06-23-adjoint-traversal-scoring.md b/docs/superpowers/plans/2026-06-23-adjoint-traversal-scoring.md new file mode 100644 index 00000000..9b25200c --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-adjoint-traversal-scoring.md @@ -0,0 +1,605 @@ +# Adjoint Static-Score Intensities for Traversal Scoring (P1) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the per-node-visit linear solve in `bw_timex`'s priority graph traversal with a single precomputed adjoint score-intensity vector, eliminating the dominant traversal cost while leaving traversal logic and results unchanged (within numeric tolerance). + +**Architecture:** `bw_graph_tools.CachingSolver.scores(indices, amounts)` currently solves `A x = e_index` once per unique activity index, then returns `score_row @ supply`. That equals `λ[index]` where `A.T λ = score_row`. We add an `AdjointCachingSolver(CachingSolver)` that solves the adjoint once and serves `scores()` as a pure lookup, and an `AdjointScoringGraphTraversal(NewNodeEachVisitGraphTraversal)` that installs it. `bw_timex`'s `EdgeExtractor`/`TimelineBuilder`/`TimexLCA.build_timeline` gain an opt-in flag that passes this traversal class through to `bw_temporalis.TemporalisLCA`. Default behavior is unchanged. + +**Tech Stack:** Python 3.13, `bw2calc`, `bw_temporalis`, `bw_graph_tools`, `scipy.sparse`, `numpy`, `pytest`, `uv`. + +## Global Constraints + +- Package manager: use `uv` for all Python invocations (`uv run pytest ...`). Never `pip`/`conda`. +- Do NOT git-commit unless the human explicitly asks. The "Commit" steps below are written per the TDD template; the human has standing instructions against automatic commits, so **stage nothing and skip the commit step unless told otherwise** — treat each "Commit" step as "stop, report, await instruction." +- Keep the existing priority traversal path the default. Adjoint scoring is opt-in this cycle; no default switch until the validation gate (Task 6) is reviewed. +- Correctness gate is **numeric tolerance** (`rtol=1e-9` for score equivalence; `rtol=1e-6` for end-to-end timeline/score parity on fixtures), not byte-equality. +- Single configured `TimexLCA.method` only; one adjoint solve. No multi-method support this cycle. +- New module name: `bw_timex/adjoint_scoring.py`. New tests: `tests/test_adjoint_scoring.py`. +- Do not edit anything under `.venv/` (read-only reference for `bw_graph_tools` internals). + +--- + +## File Structure + +- **Create `bw_timex/adjoint_scoring.py`** — holds `AdjointCachingSolver` and `AdjointScoringGraphTraversal`. Single responsibility: adjoint-based node scoring for the priority traversal. No `bw_timex` domain logic. +- **Modify `bw_timex/edge_extractor.py`** — `EdgeExtractor.__init__` accepts/passes a `graph_traversal` *class* through to `TemporalisLCA` (currently it only forwards `**kwargs`; we make the seam explicit and safe). +- **Modify `bw_timex/timeline_builder.py`** — thread an `adjoint_scoring: bool` argument; when true (and engine is `"priority"`), pass `graph_traversal=AdjointScoringGraphTraversal` into the `EdgeExtractor(...)` call. +- **Modify `bw_timex/timex_lca.py`** — `build_timeline(...)` gains `adjoint_scoring: bool = False`, validated and forwarded to `TimelineBuilder`, and folded into the timeline cache key. +- **Modify `bw_timex/validation.py`** — add `adjoint_scoring` to the `BuildTimelineInputs` validation model. +- **Modify `bw_timex/__init__.py`** — export `AdjointCachingSolver`, `AdjointScoringGraphTraversal` (keeps parity with the module exporting other engine classes like `EdgeExtractor`). +- **Create `tests/test_adjoint_scoring.py`** — unit tests (math/equivalence, index bridge, signs), integration parity tests on fixtures, and the validation/benchmark test. + +Reference signatures (from `.venv`, do not edit those files): +- `bw_graph_tools.graph_traversal.utils.CachingSolver`: + - `__init__(self, lca)` + - `set_score_row(self, characterized_biosphere)` → sets `self.score_row = np.asarray(characterized_biosphere.sum(axis=0)).ravel()` + - `scores(self, indices: list[int], amounts: list[float]) -> list[float]` + - `add_to_cache(self, index, unit_score)`, `in_cache(self, indices)`, `self._score_cache: dict` +- `bw_graph_tools.graph_traversal.new_node_each_visit.NewNodeEachVisitGraphTraversal`: + - `__init__(self, lca, settings, *, functional_unit_unique_id=-1, static_activity_indices=set())`; base sets `self._caching_solver = settings.caching_solver or CachingSolver(lca)`; NNEVGT.__init__ sets `self.characterized_biosphere` and, if present, calls `self._caching_solver.set_score_row(self.characterized_biosphere)`. + - classmethod `calculate(cls, lca_object, ...)` builds `GraphTraversalSettings(...)`, does `instance = cls(...)`, `instance.traverse()`, returns `{"nodes","edges","flows","calculation_count"}`. `bw_temporalis.TemporalisLCA` calls `graph_traversal.calculate(...)` where `graph_traversal` is the class passed to its constructor (default `NewNodeEachVisitGraphTraversal`). + +--- + +### Task 1: `AdjointCachingSolver` — adjoint solve + lookup `scores` + +**Files:** +- Create: `bw_timex/adjoint_scoring.py` +- Test: `tests/test_adjoint_scoring.py` + +**Interfaces:** +- Consumes: `bw_graph_tools.graph_traversal.utils.CachingSolver`; a built `bw2calc.LCA` with `.technosphere_matrix` (scipy sparse) and the `set_score_row` contract. +- Produces: + - `class AdjointCachingSolver(CachingSolver)` + - `AdjointCachingSolver.set_score_row(self, characterized_biosphere) -> None` (computes `self.lambda_vector: np.ndarray`) + - `AdjointCachingSolver.scores(self, indices: list[int], amounts: list[float]) -> list[float]` + - attribute `self.lambda_vector: np.ndarray | None` (signed adjoint intensities, index = technosphere matrix column) + - attribute `self.solve_count: int` (number of linear solves performed; must be exactly 1 after first `set_score_row`, and stay 1 across `scores` calls) + +- [ ] **Step 1: Write the failing test (adjoint equals per-index solve)** + +```python +# tests/test_adjoint_scoring.py +import numpy as np +import scipy.sparse as sp +from bw_graph_tools.graph_traversal.utils import CachingSolver +from bw_timex.adjoint_scoring import AdjointCachingSolver + + +class _FakeLCA: + """Minimal stand-in exposing the attributes CachingSolver/AdjointCachingSolver read.""" + def __init__(self, technosphere, biosphere, cfs): + self.technosphere_matrix = sp.csr_matrix(technosphere) + self._biosphere = sp.csr_matrix(biosphere) + self._cfs = np.asarray(cfs, dtype=float) + + def characterized_biosphere(self): + # characterized biosphere = diag(cf) @ B (rows: biosphere flows, cols: products) + return sp.csr_matrix(sp.diags(self._cfs) @ self._biosphere) + + +def _make_lca(): + # 3x3 invertible technosphere (diagonal-dominant), 2 biosphere flows, 3 products + A = np.array([[1.0, -0.2, 0.0], + [-0.1, 1.0, -0.3], + [0.0, -0.4, 1.0]]) + B = np.array([[2.0, 0.0, 1.0], + [0.0, 3.0, 0.0]]) + cfs = [1.0, 0.5] + return _FakeLCA(A, B, cfs) + + +def test_adjoint_scores_match_per_index_solve(): + lca = _make_lca() + char_bio = lca.characterized_biosphere() + + reference = CachingSolver(lca) + reference.set_score_row(char_bio) + ref_scores = reference.scores([0, 1, 2], [1.0, 1.0, 1.0]) + + adjoint = AdjointCachingSolver(lca) + adjoint.set_score_row(char_bio) + adj_scores = adjoint.scores([0, 1, 2], [1.0, 1.0, 1.0]) + + np.testing.assert_allclose(adj_scores, ref_scores, rtol=1e-9) + + +def test_adjoint_does_single_solve_and_scales_by_amount(): + lca = _make_lca() + adjoint = AdjointCachingSolver(lca) + adjoint.set_score_row(lca.characterized_biosphere()) + base = adjoint.scores([1], [1.0])[0] + scaled = adjoint.scores([1], [4.0])[0] + np.testing.assert_allclose(scaled, 4.0 * base, rtol=1e-12) + assert adjoint.solve_count == 1 # no per-index solves during scoring +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_adjoint_scoring.py::test_adjoint_scores_match_per_index_solve -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'bw_timex.adjoint_scoring'`. + +- [ ] **Step 3: Write minimal implementation** + +```python +# bw_timex/adjoint_scoring.py +"""Adjoint-based node scoring for the priority graph traversal. + +The stock ``bw_graph_tools.CachingSolver`` computes a node's static unit score by +solving ``A x = e_index`` once per unique activity index and returning +``score_row @ x``. That value equals ``lambda[index]`` where ``A.T lambda = +score_row``. Solving the adjoint system *once* yields the unit score for every +activity, so traversal scoring becomes a pure lookup with no per-node solves. +""" + +from __future__ import annotations + +import numpy as np +from scipy.sparse.linalg import spsolve + +from bw_graph_tools.graph_traversal.utils import CachingSolver + + +class AdjointCachingSolver(CachingSolver): + """Drop-in ``CachingSolver`` that scores via one adjoint solve.""" + + def __init__(self, lca): + super().__init__(lca) + self.lambda_vector: np.ndarray | None = None + self.solve_count: int = 0 + + def set_score_row(self, characterized_biosphere) -> None: + # Sets ``self.score_row`` (length = number of technosphere columns). + super().set_score_row(characterized_biosphere) + a_transpose = self.lca.technosphere_matrix.transpose().tocsc() + self.lambda_vector = np.asarray( + spsolve(a_transpose, np.asarray(self.score_row, dtype=float)) + ).ravel() + self.solve_count += 1 + # Pre-fill the inherited cache so any code path that consults it agrees + # with the lookup-based ``scores`` below. + for index, unit_score in enumerate(self.lambda_vector): + self._score_cache[index] = float(unit_score) + + def scores(self, indices, amounts) -> list[float]: + if self.lambda_vector is None: + raise RuntimeError( + "set_score_row must be called before scores (lambda not computed)" + ) + lam = self.lambda_vector + return [float(lam[index]) * float(amount) + for index, amount in zip(indices, amounts)] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_adjoint_scoring.py -v` +Expected: both tests PASS. + +- [ ] **Step 5: Commit** (per Global Constraints: stop and await instruction instead of committing) + +--- + +### Task 2: `AdjointScoringGraphTraversal` — install the adjoint solver in the traversal + +**Files:** +- Modify: `bw_timex/adjoint_scoring.py` +- Test: `tests/test_adjoint_scoring.py` + +**Interfaces:** +- Consumes: `bw_graph_tools.graph_traversal.new_node_each_visit.NewNodeEachVisitGraphTraversal`; `AdjointCachingSolver` (Task 1). +- Produces: `class AdjointScoringGraphTraversal(NewNodeEachVisitGraphTraversal)` whose `self._caching_solver` is an `AdjointCachingSolver` with `lambda_vector` populated, usable anywhere `NewNodeEachVisitGraphTraversal` is (including its `.calculate(...)` classmethod). + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_adjoint_scoring.py +from bw_timex.adjoint_scoring import AdjointScoringGraphTraversal + + +def test_traversal_subclass_installs_adjoint_solver(monkeypatch): + # The traversal's __init__ reads lca.score and builds characterized biosphere + # via library helpers; verify our subclass swaps in the adjoint solver after + # the base class finishes its own setup. + from bw_timex.adjoint_scoring import AdjointCachingSolver + + seen = {} + + real_init = AdjointScoringGraphTraversal.__mro__[1].__init__ # NNEVGT.__init__ + + def fake_init(self, *args, **kwargs): + # Stub out the heavy base init: set the minimal attributes the subclass + # override relies on, then let the override run. + import scipy.sparse as sp + self.lca = args[0] + self.characterized_biosphere = self.lca.characterized_biosphere() + self._caching_solver = None # base would set the stock solver here + + monkeypatch.setattr( + AdjointScoringGraphTraversal.__mro__[1], "__init__", fake_init + ) + + lca = _make_lca() + inst = AdjointScoringGraphTraversal(lca, object()) + assert isinstance(inst._caching_solver, AdjointCachingSolver) + assert inst._caching_solver.lambda_vector is not None + assert inst._caching_solver.solve_count == 1 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_adjoint_scoring.py::test_traversal_subclass_installs_adjoint_solver -v` +Expected: FAIL with `ImportError`/`AttributeError` (name not defined). + +- [ ] **Step 3: Write minimal implementation** + +```python +# append to bw_timex/adjoint_scoring.py +from bw_graph_tools.graph_traversal.new_node_each_visit import ( + NewNodeEachVisitGraphTraversal, +) + + +class AdjointScoringGraphTraversal(NewNodeEachVisitGraphTraversal): + """Priority traversal that scores nodes via a single adjoint solve. + + Identical to ``NewNodeEachVisitGraphTraversal`` except the caching solver is + replaced with an :class:`AdjointCachingSolver`, so node scoring performs no + per-node linear solves. Heap ordering, cutoff, ``max_calc``, and all other + traversal behavior are inherited unchanged. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # The base class already computed ``self.characterized_biosphere`` and + # called ``set_score_row`` on the stock solver. Swap in the adjoint + # solver and (re)compute the score row / lambda on it. + solver = AdjointCachingSolver(self.lca) + solver.set_score_row(self.characterized_biosphere) + self._caching_solver = solver +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_adjoint_scoring.py -v` +Expected: all tests PASS. + +- [ ] **Step 5: Commit** (per Global Constraints: stop and await instruction) + +--- + +### Task 3: Export the new classes + +**Files:** +- Modify: `bw_timex/__init__.py` +- Test: `tests/test_adjoint_scoring.py` + +**Interfaces:** +- Produces: `bw_timex.AdjointCachingSolver`, `bw_timex.AdjointScoringGraphTraversal` importable from the package root. + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_adjoint_scoring.py +def test_public_exports(): + import bw_timex + assert hasattr(bw_timex, "AdjointCachingSolver") + assert hasattr(bw_timex, "AdjointScoringGraphTraversal") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_adjoint_scoring.py::test_public_exports -v` +Expected: FAIL (`AttributeError`). + +- [ ] **Step 3: Write minimal implementation** + +Add to `bw_timex/__init__.py` after the existing `from .edge_extractor import EdgeExtractor` line: + +```python +from .adjoint_scoring import AdjointCachingSolver, AdjointScoringGraphTraversal +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_adjoint_scoring.py::test_public_exports -v` +Expected: PASS. + +- [ ] **Step 5: Commit** (per Global Constraints: stop and await instruction) + +--- + +### Task 4: Thread the opt-in flag through `EdgeExtractor` and `TimelineBuilder` + +**Files:** +- Modify: `bw_timex/edge_extractor.py:553-608` (`EdgeExtractor.__init__`) +- Modify: `bw_timex/timeline_builder.py:32` (`TimelineBuilder.__init__` signature) and `:129-140` (the `EdgeExtractor(...)` call) +- Test: `tests/test_adjoint_scoring.py` + +**Interfaces:** +- Consumes: `AdjointScoringGraphTraversal` (Task 2); `bw_temporalis.TemporalisLCA`'s `graph_traversal` constructor parameter (a class, default `NewNodeEachVisitGraphTraversal`). +- Produces: + - `TimelineBuilder.__init__(..., adjoint_scoring: bool = False, ...)` — when `True` and `graph_traversal == "priority"`, the priority `EdgeExtractor` is constructed with the adjoint traversal class. + - `EdgeExtractor` correctly forwards a `graph_traversal=` kwarg to `TemporalisLCA` (it already passes `**kwargs`; this task adds a regression test pinning that, since `graph_traversal` collides with the `TimelineBuilder` string selector name and must not be confused). + +Context: `TimelineBuilder.__init__` (`bw_timex/timeline_builder.py`) has a positional/keyword `graph_traversal: str = "priority"` selector and builds either `EdgeExtractorBFS` or `EdgeExtractor`. The priority branch (`:130-140`) calls `EdgeExtractor(base_lca, starting_datetime=..., *args, edge_filter_function=..., cutoff=..., max_calc=..., static_activity_indices=..., traverse_background=..., **kwargs)`. + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_adjoint_scoring.py +import bw2data as bd +from datetime import datetime +from bw_timex import TimexLCA +from bw_timex.adjoint_scoring import AdjointScoringGraphTraversal + + +def _build_tlca(db_fixture_unused): + fu = bd.get_node(database="foreground", code="A") + return TimexLCA( + demand={fu.id: 1}, + method=("GWP", "example"), + database_dates={ + "db_2022": datetime.strptime("2022", "%Y"), + "db_2024": datetime.strptime("2024", "%Y"), + "foreground": "dynamic", + }, + ) + + +def test_timeline_builder_uses_adjoint_class(temporal_grouping_db_monthly, monkeypatch): + # Spy: record the graph_traversal class TemporalisLCA is constructed with. + import bw_timex.edge_extractor as ee + captured = {} + real_init = ee.EdgeExtractor.__init__ + + def spy_init(self, *args, **kwargs): + captured["graph_traversal"] = kwargs.get("graph_traversal") + return real_init(self, *args, **kwargs) + + monkeypatch.setattr(ee.EdgeExtractor, "__init__", spy_init) + + tlca = _build_tlca(temporal_grouping_db_monthly) + tlca.build_timeline(adjoint_scoring=True) # added in Task 5 + assert captured["graph_traversal"] is AdjointScoringGraphTraversal +``` + +(If executing tasks strictly in order, this test depends on Task 5's `build_timeline` flag; mark it xfail until Task 5, or implement Tasks 4 and 5 together before running. The two tasks share one reviewer gate.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_adjoint_scoring.py::test_timeline_builder_uses_adjoint_class -v` +Expected: FAIL — `build_timeline()` has no `adjoint_scoring` kwarg yet (`TypeError`). + +- [ ] **Step 3: Implement — `EdgeExtractor` regression guard + `TimelineBuilder` wiring** + +In `bw_timex/edge_extractor.py`, `EdgeExtractor.__init__` already forwards `**kwargs` to `super().__init__` (which is `TemporalisLCA.__init__`, accepting `graph_traversal`). Add an explicit guard comment and keep behavior; no functional change needed beyond ensuring `graph_traversal` is not popped. Confirm by leaving the `super().__init__(*args, **kwargs)` line intact. + +In `bw_timex/timeline_builder.py`, change the `__init__` signature to add `adjoint_scoring: bool = False` (place it next to `graph_traversal: str = "priority"`): + +```python + graph_traversal: str = "priority", + adjoint_scoring: bool = False, +``` + +Then in the priority branch, modify the `EdgeExtractor(...)` call to inject the class when requested. Replace the call at `:130-140` with: + +```python + elif graph_traversal == "priority": + priority_kwargs = dict(kwargs) + if adjoint_scoring: + from .adjoint_scoring import AdjointScoringGraphTraversal + priority_kwargs["graph_traversal"] = AdjointScoringGraphTraversal + self.edge_extractor = EdgeExtractor( + base_lca, + starting_datetime=self.starting_datetime, + *args, + edge_filter_function=edge_filter_function, + cutoff=self.cutoff, + max_calc=self.max_calc, + static_activity_indices=set(static_background_activity_ids), + traverse_background=self.traverse_background, + **priority_kwargs, + ) +``` + +Note: `adjoint_scoring=True` with `graph_traversal == "bfs"` is ignored (BFS already avoids per-subgraph LCA). Document this in the `TimelineBuilder` docstring. + +- [ ] **Step 4: Run test** (after Task 5 wires `build_timeline`) + +Run: `uv run pytest tests/test_adjoint_scoring.py::test_timeline_builder_uses_adjoint_class -v` +Expected: PASS. + +- [ ] **Step 5: Commit** (per Global Constraints: stop and await instruction) + +--- + +### Task 5: `build_timeline(adjoint_scoring=...)` + validation + cache key + +**Files:** +- Modify: `bw_timex/timex_lca.py:219-231` (`build_timeline` signature), `:295-327` (validation + cache key), `:384` (`TimelineBuilder(...)` call) +- Modify: `bw_timex/validation.py` (the `BuildTimelineInputs` model) +- Test: `tests/test_adjoint_scoring.py` + +**Interfaces:** +- Consumes: `TimelineBuilder(..., adjoint_scoring=...)` (Task 4); the `BuildTimelineInputs` validator. +- Produces: `TimexLCA.build_timeline(..., adjoint_scoring: bool = False, ...)` that validates the flag, includes it in `timeline_cache_key`, and forwards it to `TimelineBuilder`. + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_adjoint_scoring.py +def test_build_timeline_accepts_adjoint_flag(temporal_grouping_db_monthly): + tlca = _build_tlca(temporal_grouping_db_monthly) + tl = tlca.build_timeline(adjoint_scoring=True) + assert tl is not None + # Distinct flag value must not collide in the cache with the default run. + tlca.build_timeline(adjoint_scoring=False) + assert tlca._last_timeline_build_key[ -1: ] is not None # key recomputed +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_adjoint_scoring.py::test_build_timeline_accepts_adjoint_flag -v` +Expected: FAIL — `build_timeline() got an unexpected keyword argument 'adjoint_scoring'`. + +- [ ] **Step 3: Implement** + +In `bw_timex/validation.py`, add `adjoint_scoring: bool = False` to the `BuildTimelineInputs` model (mirror the existing boolean field `traverse_background`). + +In `bw_timex/timex_lca.py` `build_timeline`, add the parameter after `traverse_background`: + +```python + traverse_background: bool = False, + adjoint_scoring: bool = False, +``` + +Add it to the `BuildTimelineInputs(...)` construction (after `traverse_background=traverse_background,`): + +```python + adjoint_scoring=adjoint_scoring, +``` + +Add it to `timeline_cache_key` (append before the `edge_filter_function` element so existing-order callers still behave; append at the end to be safe): + +```python + traverse_background, + adjoint_scoring, + "default" if edge_filter_function is None else id(edge_filter_function), + ) +``` + +Forward it to `TimelineBuilder(...)` at `:384` by adding `adjoint_scoring=adjoint_scoring,` to that call's kwargs. + +Document in the `build_timeline` docstring: "adjoint_scoring (bool, default False): use precomputed adjoint static-score intensities for priority-engine node scoring instead of a per-node linear solve. Results are equivalent within numerical tolerance; this is a performance option for the 'priority' engine and is ignored for 'bfs'." + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_adjoint_scoring.py::test_build_timeline_accepts_adjoint_flag tests/test_adjoint_scoring.py::test_timeline_builder_uses_adjoint_class -v` +Expected: both PASS. + +- [ ] **Step 5: Commit** (per Global Constraints: stop and await instruction) + +--- + +### Task 6: End-to-end equivalence + benchmark (the validation gate) + +**Files:** +- Test: `tests/test_adjoint_scoring.py` + +**Interfaces:** +- Consumes: `TimexLCA.build_timeline(adjoint_scoring=...)`; existing pytest fixtures `temporal_grouping_db_monthly` and `background_td_deep_chain_db` (registered in `tests/conftest.py`). +- Produces: regression tests proving (a) timeline/score parity within `rtol=1e-6`, and (b) a non-increasing linear-solve count for traversal scoring. + +- [ ] **Step 1: Write the equivalence test (timeline + score parity)** + +```python +# append to tests/test_adjoint_scoring.py +import pandas as pd + + +def _scores_for(db_fixture, adjoint: bool): + fu = bd.get_node(database="foreground", code="A") + tlca = TimexLCA( + demand={fu.id: 1}, + method=("GWP", "example"), + database_dates={ + "db_2022": datetime.strptime("2022", "%Y"), + "db_2024": datetime.strptime("2024", "%Y"), + "foreground": "dynamic", + }, + ) + tlca.build_timeline(adjoint_scoring=adjoint) + tlca.lci(expand_technosphere=True, build_dynamic_biosphere=True) + tlca.static_lcia() + return tlca.static_score, tlca.timeline + + +def test_adjoint_matches_default_scores_and_timeline(temporal_grouping_db_monthly): + score_default, tl_default = _scores_for(temporal_grouping_db_monthly, adjoint=False) + score_adjoint, tl_adjoint = _scores_for(temporal_grouping_db_monthly, adjoint=True) + + np.testing.assert_allclose(score_adjoint, score_default, rtol=1e-6) + + # Compare the numeric 'amount' column after aligning on the stable edge keys. + key = ["producer_name", "consumer_name", "date_producer", "date_consumer"] + a = tl_default.sort_values(key).reset_index(drop=True) + b = tl_adjoint.sort_values(key).reset_index(drop=True) + assert list(a["producer_name"]) == list(b["producer_name"]) + np.testing.assert_allclose( + a["amount"].to_numpy(), b["amount"].to_numpy(), rtol=1e-6 + ) +``` + +- [ ] **Step 2: Run it to verify it passes (parity holds)** + +Run: `uv run pytest tests/test_adjoint_scoring.py::test_adjoint_matches_default_scores_and_timeline -v` +Expected: PASS. If it FAILS, the adjoint scoring diverged — debug with `superpowers:systematic-debugging` before proceeding; do not loosen `rtol`. + +- [ ] **Step 3: Write the solve-count benchmark test (deep chain)** + +```python +# append to tests/test_adjoint_scoring.py +def test_adjoint_reduces_scoring_solves(background_td_deep_chain_db): + """Adjoint scoring performs exactly one linear solve regardless of graph size.""" + import bw_timex.adjoint_scoring as adj + + solve_counts = [] + real_set = adj.AdjointCachingSolver.set_score_row + + def counting_set(self, char_bio): + real_set(self, char_bio) + solve_counts.append(self.solve_count) + + fu = bd.get_node(database="foreground", code="A") + tlca = TimexLCA( + demand={fu.id: 1}, + method=("GWP", "example"), + database_dates={ + "background": datetime.strptime("2020", "%Y"), + "foreground": "dynamic", + }, + ) + import pytest + with pytest.MonkeyPatch.context() as mp: + mp.setattr(adj.AdjointCachingSolver, "set_score_row", counting_set) + tlca.build_timeline(adjoint_scoring=True) + + # One adjoint solve total for traversal scoring (vs one-per-unique-index before). + assert solve_counts and all(c == 1 for c in solve_counts) +``` + +Note: confirm the exact `database_dates`/fixture wiring for `background_td_deep_chain_db` by reading `tests/fixtures/background_td_deep_chain_db_fixture.py` and the matching `tests/test_background_traversal.py` setup, and adapt the demand node / dates accordingly. The fixture's database name(s) and the foreground node code must match that fixture, not the monthly one. + +- [ ] **Step 4: Run the benchmark test** + +Run: `uv run pytest tests/test_adjoint_scoring.py::test_adjoint_reduces_scoring_solves -v` +Expected: PASS (exactly one adjoint solve). + +- [ ] **Step 5: Run the full suite to confirm no regressions** + +Run: `uv run pytest -q` +Expected: all pre-existing tests still PASS (default path untouched), plus the new `test_adjoint_scoring.py` tests. + +- [ ] **Step 6: Commit** (per Global Constraints: stop and await instruction) + +--- + +## Self-Review + +**Spec coverage:** +- Adjoint precompute `Aᵀλ = Bᵀh` once on base matrices → Task 1 (`AdjointCachingSolver.set_score_row`). ✓ +- `StaticScoreIntensities` isolated unit with single-activity equivalence test → realized as `AdjointCachingSolver` (the seam *is* the score provider; a separate class would duplicate `score_row`/index logic, so it is folded in). Equivalence test = Task 1 Step 1. ✓ +- Seam A: inject into priority engine via `bw_graph_tools` solver subclass → Tasks 2,4,5. ✓ +- Opt-in flag on `build_timeline` → Task 5; threaded via Task 4. ✓ +- Numeric-tolerance correctness gate → Task 6 (`rtol=1e-9` math, `rtol=1e-6` end-to-end). ✓ +- Validation/benchmark harness → Task 6 (solve-count test + full-suite regression). ✓ +- Conservative pruning guardrail → covered by Task 6 timeline parity (same edges retained within tolerance under identical cutoff/max_calc). ✓ +- Index-space bridge risk → `score_row`/`lambda_vector` share the technosphere-column index space that `CachingSolver.scores` already uses; Task 1 test exercises it directly against the stock solver. ✓ +- Sign/substitution conventions → inherited unchanged (scoring uses the same `characterized_biosphere`/`score_row` and the same downstream sign handling as today); Task 6 fixtures include signed/substitution-bearing chains via the standard suite. ✓ +- Out-of-scope items (P2/P3/UX/premise) → not present in any task. ✓ + +**Placeholder scan:** No TBD/TODO/"add error handling". Two explicit "confirm/adapt" notes (Task 4 ordering dependency; Task 6 deep-chain fixture wiring) point to concrete files to read, not vague work. ✓ + +**Type consistency:** `AdjointCachingSolver` (Task 1) used identically in Tasks 2/4/6; `AdjointScoringGraphTraversal` (Task 2) used identically in Tasks 4/5; `adjoint_scoring` bool name consistent across `validation.py`, `TimelineBuilder`, `build_timeline`, and the cache key. `lambda_vector`/`solve_count` attribute names consistent across Tasks 1,2,6. ✓ diff --git a/docs/superpowers/plans/2026-06-24-persistent-cache.md b/docs/superpowers/plans/2026-06-24-persistent-cache.md new file mode 100644 index 00000000..0ce59f86 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-persistent-cache.md @@ -0,0 +1,883 @@ +# Persistent Disk Cache (P3) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist the two expensive, stable, serializable solve-result caches — adjoint λ intensities and background unit LCI — to a platformdirs disk cache so they survive across Python sessions, keyed/invalidated by bw2data `modified` tokens, on by default with an off switch and a clear helper. + +**Architecture:** A new `bw_timex/persistent_cache.py` owns all disk I/O, keying, and atomic writes. `PersistentDict` (a `MutableMapping` wrapping the existing in-memory `BACKGROUND_UNIT_LCI_CACHE` plus a disk dir) transparently persists background unit-LCI triplets with zero change to the builder's cache logic. `LambdaDiskCache` persists the 1-D λ vector; `AdjointCachingSolver.set_score_row` consults it (reached via attributes stashed on `base_lca`, because `bw_temporalis` instantiates the traversal class itself). `TimexLCA` gains a `persistent_cache` flag, builds the λ key from method + involved-database `modified` tokens, and swaps the background dict. + +**Tech Stack:** Python 3.13, numpy (`np.savez`/`np.load`), platformdirs, scipy.sparse, bw2data, pytest, uv. + +## Global Constraints + +- Package manager: `uv` for all Python (`uv run pytest ...`). Never pip/conda. +- COMMITS ENABLED on branch `feat/persistent-cache` (off `feat/adjoint-traversal-scoring`). Commit per task. End every commit message body with exactly these two trailers: + `Co-Authored-By: Claude Opus 4.8 ` + `Claude-Session: https://claude.ai/code/session_01NadULkEstbs67wxr2W8DtL` +- Cache is NEVER load-bearing: any corrupt/unreadable/wrong-version file is treated as a miss (recompute, overwrite); cache operations never raise out of the cache layer. +- Writes are atomic: write to a unique temp file in the same dir, then `os.replace`. +- All tests set env var `BW_TIMEX_CACHE_DIR` to a pytest `tmp_path` so the real user cache is never touched. +- Keying is modified-token + method (NOT content hashing). Background key form is exactly `("db_code", project, db, code, modified)`. λ key form is exactly `("lambda", project, method, tuple(sorted((db, modified) for db in databases)))`. +- On by default, gated by the existing `use_global_lci_cache=True`; new `TimexLCA(..., persistent_cache=True)` is the off switch (`False` ⇒ behavior identical to today, zero disk I/O). +- Format-version path segment is `v1`; cache root is `/bw_timex/v1/{background_unit_lci,adjoint_intensities}/`. +- Do not edit anything under `.venv/`. + +--- + +## File Structure + +- **Create `bw_timex/persistent_cache.py`** — disk cache primitives: `cache_root()`, `_atomic_write_bytes()`, `_key_to_filename()`, `PersistentDict`, `LambdaDiskCache`, `clear_persistent_cache()`. One responsibility: persistence. No bw_timex domain logic. +- **Modify `bw_timex/adjoint_scoring.py`** — `AdjointCachingSolver.set_score_row` consults a λ cache + key read from `self.lca` attributes (`_bw_timex_lambda_cache`, `_bw_timex_lambda_key`); on hit, use stored λ and skip the solve (`solve_count` stays 0); on miss, solve then save. +- **Modify `bw_timex/_lci_cache.py`** — extend `clear_background_lci_cache()` to also clear the disk cache. +- **Modify `bw_timex/__init__.py`** — export `clear_persistent_cache` (and re-export remains for `clear_background_lci_cache`). +- **Modify `bw_timex/timex_lca.py`** — `__init__` gains `persistent_cache: bool = True`; when enabled, wrap the background cache in `PersistentDict`; before the traversal in `build_timeline`, stash `_bw_timex_lambda_cache`/`_bw_timex_lambda_key` on `base_lca` (only when adjoint scoring is active); clean them up afterward. +- **Modify `pyproject.toml`** — promote `platformdirs` to a direct dependency. +- **Create `tests/test_persistent_cache.py`** — unit tests for the primitives + λ hook; end-to-end cache tests on fixtures. + +Reference facts (verified in the current tree): +- `bw_timex/dynamic_biosphere_builder.py: get_background_lci_cache_key` returns `("db_code", bd.projects.current, db, code, modified)` for stable entries, `("temporalized", code)` / `("activity_id", act)` otherwise. `BACKGROUND_UNIT_LCI_CACHE` only ever receives `db_code` keys (others route to `_instance_unit_lci_cache`). +- Background value = triplets `(bioflow_ids: np.int64[], activity_ids: np.int64[], values: np.float64[])`. +- `bw_timex/timex_lca.py:206-207`: `self._background_unit_lci_cache = BACKGROUND_UNIT_LCI_CACHE if use_global_lci_cache else {}`. +- `AdjointCachingSolver(lca)` stores `self.lca`; `set_score_row` computes `lambda_vector` via `spsolve(A.T, score_row)` and sets `solve_count`. +- `AdjointScoringGraphTraversal` is passed as a CLASS to `bw_temporalis.TemporalisLCA`, which instantiates it via `.calculate()`; the `base_lca` passed in becomes `self.lca` on the traversal and solver — hence the attribute-stash seam. + +--- + +### Task 1: `persistent_cache.py` core — `cache_root`, atomic write, `PersistentDict`, `clear_persistent_cache` + +**Files:** +- Create: `bw_timex/persistent_cache.py` +- Test: `tests/test_persistent_cache.py` + +**Interfaces:** +- Consumes: numpy, platformdirs, stdlib (`os`, `io`, `uuid`, `hashlib`, `shutil`, `pathlib`). +- Produces: + - `cache_root() -> pathlib.Path` (`/bw_timex/v1`, base = `$BW_TIMEX_CACHE_DIR` if set else `platformdirs.user_cache_path("bw_timex", "bw_timex")`) + - `clear_persistent_cache() -> None` + - `class PersistentDict(collections.abc.MutableMapping)` with `__init__(self, memory: dict, disk_dir: pathlib.Path)`; persists values of the form `(np.ndarray, np.ndarray, np.ndarray)`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_persistent_cache.py +import os +import numpy as np +import pytest + + +@pytest.fixture(autouse=True) +def _cache_dir(tmp_path, monkeypatch): + monkeypatch.setenv("BW_TIMEX_CACHE_DIR", str(tmp_path)) + # Re-import is unnecessary: cache_root() reads the env var at call time. + yield tmp_path + + +def _triplet(): + return ( + np.array([0, 1], dtype=np.int64), + np.array([2, 3], dtype=np.int64), + np.array([1.5, -2.0], dtype=np.float64), + ) + + +def test_cache_root_uses_env_override(tmp_path): + from bw_timex.persistent_cache import cache_root + root = cache_root() + assert str(root).startswith(str(tmp_path)) + assert root.name == "v1" + + +def test_persistent_dict_roundtrip_across_instances(tmp_path): + from bw_timex.persistent_cache import PersistentDict + disk = tmp_path / "bg" + key = ("db_code", "proj", "background", "x", 123) + + d1 = PersistentDict(memory={}, disk_dir=disk) + d1[key] = _triplet() + + # Fresh memory, same disk dir == cross-session reuse. + d2 = PersistentDict(memory={}, disk_dir=disk) + assert key in d2 + bio, act, val = d2[key] + np.testing.assert_array_equal(bio, _triplet()[0]) + np.testing.assert_array_equal(act, _triplet()[1]) + np.testing.assert_allclose(val, _triplet()[2]) + + +def test_persistent_dict_missing_key_raises(tmp_path): + from bw_timex.persistent_cache import PersistentDict + d = PersistentDict(memory={}, disk_dir=tmp_path / "bg") + with pytest.raises(KeyError): + _ = d[("db_code", "p", "db", "code", 1)] + + +def test_persistent_dict_corrupt_file_is_miss(tmp_path): + from bw_timex.persistent_cache import PersistentDict + disk = tmp_path / "bg" + key = ("db_code", "p", "db", "code", 1) + d = PersistentDict(memory={}, disk_dir=disk) + d[key] = _triplet() + # Corrupt every file on disk. + for f in disk.iterdir(): + f.write_bytes(b"not a real npz") + d2 = PersistentDict(memory={}, disk_dir=disk) + with pytest.raises(KeyError): + _ = d2[key] + # Corrupt file was removed. + assert not any(disk.iterdir()) + + +def test_persistent_dict_no_tmp_leftovers(tmp_path): + from bw_timex.persistent_cache import PersistentDict + disk = tmp_path / "bg" + d = PersistentDict(memory={}, disk_dir=disk) + d[("db_code", "p", "db", "code", 1)] = _triplet() + assert not any(p.name.endswith(".tmp") for p in disk.iterdir()) + + +def test_clear_persistent_cache_removes_tree(tmp_path): + from bw_timex.persistent_cache import PersistentDict, clear_persistent_cache, cache_root + disk = cache_root() / "background_unit_lci" + d = PersistentDict(memory={}, disk_dir=disk) + d[("db_code", "p", "db", "code", 1)] = _triplet() + assert cache_root().exists() + clear_persistent_cache() + assert not cache_root().exists() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_persistent_cache.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'bw_timex.persistent_cache'`. + +- [ ] **Step 3: Write the implementation** + +```python +# bw_timex/persistent_cache.py +"""Persistent (cross-session) disk cache for expensive, stable solve results. + +Two consumers use this module: the background unit-LCI triplet cache +(:class:`PersistentDict`) and the adjoint lambda vector cache +(:class:`LambdaDiskCache`). The cache is never load-bearing — any unreadable or +wrong-version entry is treated as a miss and recomputed. Keys are invalidated +by bw2data ``modified`` tokens embedded in the key, so this module performs no +content hashing of matrices. +""" + +from __future__ import annotations + +import hashlib +import io +import os +import shutil +import uuid +from collections.abc import MutableMapping +from pathlib import Path + +import numpy as np +import platformdirs + +_VERSION = "v1" + + +def cache_root() -> Path: + """Return the versioned cache root, honoring ``BW_TIMEX_CACHE_DIR``.""" + override = os.environ.get("BW_TIMEX_CACHE_DIR") + base = Path(override) if override else Path( + platformdirs.user_cache_path(appname="bw_timex", appauthor="bw_timex") + ) + return base / _VERSION + + +def clear_persistent_cache() -> None: + """Delete the entire on-disk bw_timex cache tree (best effort).""" + root = cache_root() + if root.exists(): + shutil.rmtree(root, ignore_errors=True) + + +def _key_to_filename(key, suffix: str) -> str: + digest = hashlib.blake2b(repr(key).encode("utf-8"), digest_size=20).hexdigest() + return f"{digest}{suffix}" + + +def _atomic_write_bytes(path: Path, data: bytes) -> None: + """Write ``data`` to ``path`` atomically; swallow write failures.""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.parent / f".{path.stem}.{os.getpid()}.{uuid.uuid4().hex}.tmp" + try: + tmp.write_bytes(data) + os.replace(tmp, path) + except Exception: + try: + tmp.unlink() + except OSError: + pass + + +class PersistentDict(MutableMapping): + """In-memory dict mirrored to disk; values are triplets of numpy arrays. + + ``memory`` is the existing in-session dict (so cross-object sharing is + preserved); ``disk_dir`` is where entries persist as ``.npz`` files. + """ + + def __init__(self, memory: dict, disk_dir: Path): + self._mem = memory + self._dir = Path(disk_dir) + + def _path(self, key) -> Path: + return self._dir / _key_to_filename(key, ".npz") + + def __contains__(self, key) -> bool: + return key in self._mem or self._path(key).exists() + + def __getitem__(self, key): + if key in self._mem: + return self._mem[key] + path = self._path(key) + if not path.exists(): + raise KeyError(key) + try: + with np.load(path) as npz: + value = (npz["bio"], npz["act"], npz["val"]) + except Exception: + try: + path.unlink() + except OSError: + pass + raise KeyError(key) + self._mem[key] = value + return value + + def __setitem__(self, key, value) -> None: + self._mem[key] = value + bio, act, val = value + buf = io.BytesIO() + np.savez(buf, bio=bio, act=act, val=val) + _atomic_write_bytes(self._path(key), buf.getvalue()) + + def __delitem__(self, key) -> None: + existed = self._mem.pop(key, None) is not None + path = self._path(key) + if path.exists(): + path.unlink() + existed = True + if not existed: + raise KeyError(key) + + def __iter__(self): + return iter(self._mem) + + def __len__(self) -> int: + return len(self._mem) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_persistent_cache.py -v` +Expected: all 6 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add bw_timex/persistent_cache.py tests/test_persistent_cache.py +git commit # message: "feat: add persistent_cache core (PersistentDict, cache_root, clear)" +``` + +--- + +### Task 2: `LambdaDiskCache` — persist the 1-D λ vector + +**Files:** +- Modify: `bw_timex/persistent_cache.py` +- Test: `tests/test_persistent_cache.py` + +**Interfaces:** +- Consumes: `_key_to_filename`, `_atomic_write_bytes`, `cache_root` (Task 1). +- Produces: `class LambdaDiskCache` with `__init__(self, disk_dir: pathlib.Path)`, `load(self, key) -> np.ndarray | None`, `save(self, key, array: np.ndarray) -> None`. + +- [ ] **Step 1: Write the failing tests** + +```python +# append to tests/test_persistent_cache.py +def test_lambda_cache_roundtrip(tmp_path): + from bw_timex.persistent_cache import LambdaDiskCache + disk = tmp_path / "lam" + key = ("lambda", "proj", ("m", "x"), (("db", 7),)) + c1 = LambdaDiskCache(disk) + assert c1.load(key) is None + arr = np.array([1.0, 2.0, 3.0], dtype=np.float64) + c1.save(key, arr) + c2 = LambdaDiskCache(disk) # fresh instance, same dir + loaded = c2.load(key) + np.testing.assert_allclose(loaded, arr) + + +def test_lambda_cache_corrupt_is_none(tmp_path): + from bw_timex.persistent_cache import LambdaDiskCache + disk = tmp_path / "lam" + key = ("lambda", "p", ("m",), (("db", 1),)) + c = LambdaDiskCache(disk) + c.save(key, np.array([1.0])) + for f in disk.iterdir(): + f.write_bytes(b"garbage") + assert LambdaDiskCache(disk).load(key) is None + assert not any(disk.iterdir()) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_persistent_cache.py::test_lambda_cache_roundtrip -v` +Expected: FAIL with `ImportError: cannot import name 'LambdaDiskCache'`. + +- [ ] **Step 3: Write the implementation** + +```python +# append to bw_timex/persistent_cache.py +class LambdaDiskCache: + """Persist/reuse the adjoint lambda vector (a 1-D float array).""" + + def __init__(self, disk_dir: Path): + self._dir = Path(disk_dir) + + def _path(self, key) -> Path: + return self._dir / _key_to_filename(key, ".npz") + + def load(self, key): + path = self._path(key) + if not path.exists(): + return None + try: + with np.load(path) as npz: + return npz["lam"] + except Exception: + try: + path.unlink() + except OSError: + pass + return None + + def save(self, key, array) -> None: + buf = io.BytesIO() + np.savez(buf, lam=np.asarray(array, dtype=np.float64)) + _atomic_write_bytes(self._path(key), buf.getvalue()) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_persistent_cache.py -v` +Expected: all tests PASS (8 total). + +- [ ] **Step 5: Commit** + +```bash +git add bw_timex/persistent_cache.py tests/test_persistent_cache.py +git commit # message: "feat: add LambdaDiskCache for persistent adjoint lambda vector" +``` + +--- + +### Task 3: λ cache hook in `AdjointCachingSolver` + +**Files:** +- Modify: `bw_timex/adjoint_scoring.py` (`AdjointCachingSolver.set_score_row`) +- Test: `tests/test_persistent_cache.py` + +**Interfaces:** +- Consumes: `LambdaDiskCache` (Task 2); `self.lca` carries optional attributes `_bw_timex_lambda_cache` (a `LambdaDiskCache` or None) and `_bw_timex_lambda_key` (a hashable key or None). +- Produces: modified `set_score_row` — on a cache hit, `lambda_vector` is loaded and `solve_count` stays 0 (no `spsolve`); on a miss, it solves once (`solve_count == 1`) and writes to the cache. + +- [ ] **Step 1: Write the failing tests** + +```python +# append to tests/test_persistent_cache.py (reuse _make_lca from tests/test_adjoint_scoring.py pattern) +import scipy.sparse as sp + + +class _FakeLCA: + def __init__(self, A, B, cfs): + self.technosphere_matrix = sp.csr_matrix(A) + self._biosphere = sp.csr_matrix(B) + self._cfs = np.asarray(cfs, dtype=float) + self.solver = None + def characterized_biosphere(self): + return sp.csr_matrix(sp.diags(self._cfs) @ self._biosphere) + def decompose_technosphere(self): + pass + def solve_linear_system(self, demand): + from scipy.sparse.linalg import spsolve + return spsolve(self.technosphere_matrix.tocsc(), demand) + + +def _make_lca(): + A = np.array([[1.0, -0.2, 0.0], [-0.1, 1.0, -0.3], [0.0, -0.4, 1.0]]) + B = np.array([[2.0, 0.0, 1.0], [0.0, 3.0, 0.0]]) + return _FakeLCA(A, B, [1.0, 0.5]) + + +def test_solver_saves_then_skips_solve_on_hit(tmp_path): + from bw_timex.persistent_cache import LambdaDiskCache + from bw_timex.adjoint_scoring import AdjointCachingSolver + cache = LambdaDiskCache(tmp_path / "lam") + key = ("lambda", "p", ("m",), (("db", 1),)) + + lca1 = _make_lca() + lca1._bw_timex_lambda_cache = cache + lca1._bw_timex_lambda_key = key + s1 = AdjointCachingSolver(lca1) + s1.set_score_row(lca1.characterized_biosphere()) + assert s1.solve_count == 1 # miss → solved once + saved = s1.lambda_vector.copy() + + lca2 = _make_lca() + lca2._bw_timex_lambda_cache = cache + lca2._bw_timex_lambda_key = key + s2 = AdjointCachingSolver(lca2) + s2.set_score_row(lca2.characterized_biosphere()) + assert s2.solve_count == 0 # hit → no solve + np.testing.assert_allclose(s2.lambda_vector, saved) + + +def test_solver_without_cache_attrs_behaves_as_before(tmp_path): + from bw_timex.adjoint_scoring import AdjointCachingSolver + lca = _make_lca() # no cache attrs set + s = AdjointCachingSolver(lca) + s.set_score_row(lca.characterized_biosphere()) + assert s.solve_count == 1 + assert s.lambda_vector is not None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_persistent_cache.py::test_solver_saves_then_skips_solve_on_hit -v` +Expected: FAIL — `assert s2.solve_count == 0` fails (currently always solves, count becomes 1). + +- [ ] **Step 3: Modify `set_score_row`** + +Replace the body of `AdjointCachingSolver.set_score_row` in `bw_timex/adjoint_scoring.py` with: + +```python + def set_score_row(self, characterized_biosphere) -> None: + # Sets ``self.score_row`` (length = number of technosphere columns). + # Cheap column-sum; needed for inherited consumers even on a cache hit. + super().set_score_row(characterized_biosphere) + + cache = getattr(self.lca, "_bw_timex_lambda_cache", None) + key = getattr(self.lca, "_bw_timex_lambda_key", None) + + if cache is not None and key is not None: + stored = cache.load(key) + if stored is not None: + self.lambda_vector = np.asarray(stored, dtype=float).ravel() + self._prefill_score_cache() + return # cache hit: skip the adjoint solve (solve_count stays 0) + + a_transpose = self.lca.technosphere_matrix.transpose().tocsc() + self.lambda_vector = np.asarray( + spsolve(a_transpose, np.asarray(self.score_row, dtype=float)) + ).ravel() + self.solve_count += 1 + self._prefill_score_cache() + + if cache is not None and key is not None: + cache.save(key, self.lambda_vector) + + def _prefill_score_cache(self) -> None: + # Pre-fill the inherited per-index cache so any code path that consults + # it agrees with the lookup-based ``scores`` below. + for index, unit_score in enumerate(self.lambda_vector): + self._score_cache[index] = float(unit_score) +``` + +(This extracts the existing prefill loop into `_prefill_score_cache` and reuses it on both paths.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_persistent_cache.py tests/test_adjoint_scoring.py -v` +Expected: new tests PASS and all existing adjoint tests still PASS (the no-cache-attrs path is unchanged: `solve_count == 1`). + +- [ ] **Step 5: Commit** + +```bash +git add bw_timex/adjoint_scoring.py tests/test_persistent_cache.py +git commit # message: "feat: consult persistent lambda cache in AdjointCachingSolver.set_score_row" +``` + +--- + +### Task 4: platformdirs dependency + clear-cache wiring + exports + +**Files:** +- Modify: `pyproject.toml` (dependencies list) +- Modify: `bw_timex/_lci_cache.py` (`clear_background_lci_cache`) +- Modify: `bw_timex/__init__.py` +- Test: `tests/test_persistent_cache.py` + +**Interfaces:** +- Consumes: `clear_persistent_cache` (Task 1). +- Produces: `bw_timex.clear_persistent_cache` importable from package root; `clear_background_lci_cache()` also clears the disk cache; `platformdirs` declared as a direct dependency. + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_persistent_cache.py +def test_exports_and_combined_clear(tmp_path): + import bw_timex + from bw_timex.persistent_cache import PersistentDict, cache_root + assert hasattr(bw_timex, "clear_persistent_cache") + # clear_background_lci_cache also wipes disk. + d = PersistentDict(memory={}, disk_dir=cache_root() / "background_unit_lci") + d[("db_code", "p", "db", "code", 1)] = _triplet() + assert cache_root().exists() + bw_timex.clear_background_lci_cache() + assert not cache_root().exists() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_persistent_cache.py::test_exports_and_combined_clear -v` +Expected: FAIL (`AttributeError: module 'bw_timex' has no attribute 'clear_persistent_cache'`, or the disk tree persists after the combined clear). + +- [ ] **Step 3: Implement** + +In `pyproject.toml`, add `"platformdirs"` to the `dependencies` list (after `"pydantic>=2.0",`): + +```toml + "pydantic>=2.0", + "platformdirs", +] +``` + +In `bw_timex/_lci_cache.py`, extend `clear_background_lci_cache` so it also clears disk: + +```python +def clear_background_lci_cache() -> None: + """Clear all module-level bw_timex caches (unit LCI, biosphere exchanges, solve, nodes) and the persistent disk cache.""" + BACKGROUND_UNIT_LCI_CACHE.clear() + BIOSPHERE_EXCHANGES_CACHE.clear() + LCI_SOLVE_CACHE.clear() + NODES_CACHE.clear() + from .persistent_cache import clear_persistent_cache + clear_persistent_cache() +``` + +In `bw_timex/__init__.py`, add after the existing `from .adjoint_scoring import ...` line: + +```python +from .persistent_cache import clear_persistent_cache +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_persistent_cache.py -v` +Expected: all PASS. Then confirm the dep resolves: `uv sync` exits 0 (platformdirs already in `uv.lock`). + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml bw_timex/_lci_cache.py bw_timex/__init__.py tests/test_persistent_cache.py +git commit # message: "feat: platformdirs dep, clear_persistent_cache export, combined clear" +``` + +--- + +### Task 5: `TimexLCA` wiring — `persistent_cache` flag, background swap, λ key stash + +**Files:** +- Modify: `bw_timex/timex_lca.py:102-107` (`__init__` signature), `:206-207` (background cache), `build_timeline` (λ stash around traversal) +- Test: `tests/test_persistent_cache.py` + +**Interfaces:** +- Consumes: `PersistentDict`, `LambdaDiskCache`, `cache_root` (Tasks 1–2); `AdjointCachingSolver`'s attribute contract (`base_lca._bw_timex_lambda_cache`, `base_lca._bw_timex_lambda_key`) (Task 3). +- Produces: + - `TimexLCA.__init__(..., persistent_cache: bool = True)`. + - When `persistent_cache and use_global_lci_cache`, `self._background_unit_lci_cache` is a `PersistentDict` over `cache_root()/"background_unit_lci"` backed by `BACKGROUND_UNIT_LCI_CACHE`. + - `TimexLCA._build_lambda_cache_key() -> tuple` = `("lambda", bd.projects.current, str(self.method), tuple(sorted((db, bd.databases[db].get("modified")) for db in self.database_dates)))`. + - During `build_timeline(adjoint_scoring=True, ...)` with `persistent_cache` on, `self.base_lca._bw_timex_lambda_cache`/`_bw_timex_lambda_key` are set before the traversal and removed afterward (in a `finally`). + +- [ ] **Step 1: Write the failing tests** + +```python +# append to tests/test_persistent_cache.py +from datetime import datetime + + +def _tlca(persistent): + import bw2data as bd + from bw_timex import TimexLCA + fu = bd.get_node(database="foreground", code="A") + return TimexLCA( + demand={fu.id: 1}, + method=("GWP", "example"), + database_dates={ + "db_2022": datetime.strptime("2022", "%Y"), + "db_2024": datetime.strptime("2024", "%Y"), + "foreground": "dynamic", + }, + persistent_cache=persistent, + ) + + +def test_background_cache_is_persistentdict_when_enabled(temporal_grouping_db_monthly): + from bw_timex.persistent_cache import PersistentDict + tlca = _tlca(persistent=True) + assert isinstance(tlca._background_unit_lci_cache, PersistentDict) + + +def test_background_cache_plain_dict_when_disabled(temporal_grouping_db_monthly): + from bw_timex.persistent_cache import PersistentDict + tlca = _tlca(persistent=False) + assert not isinstance(tlca._background_unit_lci_cache, PersistentDict) + + +def test_lambda_key_includes_method_and_modified(temporal_grouping_db_monthly): + tlca = _tlca(persistent=True) + key = tlca._build_lambda_cache_key() + assert key[0] == "lambda" + assert key[2] == str(("GWP", "example")) + # db modified tokens are present + dbs = dict(key[3]) + assert "db_2022" in dbs and "foreground" in dbs + + +def test_lambda_attrs_cleaned_after_build(temporal_grouping_db_monthly): + tlca = _tlca(persistent=True) + tlca.build_timeline(adjoint_scoring=True) + assert getattr(tlca.base_lca, "_bw_timex_lambda_cache", None) is None + assert getattr(tlca.base_lca, "_bw_timex_lambda_key", None) is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_persistent_cache.py::test_background_cache_is_persistentdict_when_enabled -v` +Expected: FAIL — `TimexLCA.__init__` has no `persistent_cache` kwarg (`TypeError`). + +- [ ] **Step 3: Implement** + +In `bw_timex/timex_lca.py`, add the parameter to `__init__`: + +```python + def __init__( + self, + demand: dict, + method: tuple, + database_dates: dict = None, + use_global_lci_cache: bool = True, + persistent_cache: bool = True, + ) -> None: +``` + +Store it early in `__init__` (next to other attribute assignments, before the background-cache assignment at line ~206): + +```python + self.persistent_cache = persistent_cache +``` + +Replace the background-cache assignment (`timex_lca.py:206-207`) with: + +```python + if use_global_lci_cache and persistent_cache: + from .persistent_cache import PersistentDict, cache_root + self._background_unit_lci_cache = PersistentDict( + memory=BACKGROUND_UNIT_LCI_CACHE, + disk_dir=cache_root() / "background_unit_lci", + ) + else: + self._background_unit_lci_cache = ( + BACKGROUND_UNIT_LCI_CACHE if use_global_lci_cache else {} + ) +``` + +Add the key builder method (place it near `create_demand_timing`): + +```python + def _build_lambda_cache_key(self) -> tuple: + """Persistent-cache key for the adjoint lambda vector. + + Keyed by project, method, and the ``modified`` tokens of every database + in ``database_dates`` so any tracked database edit invalidates it. + """ + dbs = tuple( + sorted( + (db, bd.databases[db].get("modified") if db in bd.databases else None) + for db in self.database_dates + ) + ) + return ("lambda", bd.projects.current, str(self.method), dbs) +``` + +In `build_timeline`, wrap the call that runs the traversal (the line that constructs `TimelineBuilder(...)` / triggers `EdgeExtractor` traversal — currently around `timex_lca.py:384`) so the λ attributes are set on `base_lca` only when adjoint scoring + persistence are both active, and always cleaned up: + +```python + lambda_attrs_set = False + if adjoint_scoring and self.persistent_cache: + from .persistent_cache import LambdaDiskCache, cache_root + self.base_lca._bw_timex_lambda_cache = LambdaDiskCache( + cache_root() / "adjoint_intensities" + ) + self.base_lca._bw_timex_lambda_key = self._build_lambda_cache_key() + lambda_attrs_set = True + try: + self.timeline_builder = TimelineBuilder( + # ... existing arguments unchanged ... + ) + # ... existing body that builds self.timeline ... + finally: + if lambda_attrs_set: + self.base_lca._bw_timex_lambda_cache = None + self.base_lca._bw_timex_lambda_key = None +``` + +Note for the implementer: `adjoint_scoring` is already a parameter of `build_timeline` (added in P1). Keep all existing `TimelineBuilder(...)` arguments exactly as they are; only wrap them with the stash/cleanup shown above. Place the stash AFTER `base_lca` is guaranteed to exist (it is created in `__init__`). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_persistent_cache.py -v` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add bw_timex/timex_lca.py tests/test_persistent_cache.py +git commit # message: "feat: wire persistent_cache flag + lambda key into TimexLCA" +``` + +--- + +### Task 6: End-to-end validation gate + +**Files:** +- Test: `tests/test_persistent_cache.py` + +**Interfaces:** +- Consumes: the full wiring (Tasks 1–5); fixtures `temporal_grouping_db_monthly` and `background_td_deep_chain_db` (registered in `tests/conftest.py`). +- Produces: regression tests proving cold/warm cache equivalence, zero-disk-IO when disabled, λ reuse across instances, and clear behavior. + +- [ ] **Step 1: Write the equivalence + behavior tests** + +```python +# append to tests/test_persistent_cache.py +def _run_full(persistent): + tlca = _tlca(persistent=persistent) + tlca.build_timeline(adjoint_scoring=True) + tlca.lci(expand_technosphere=True, build_dynamic_biosphere=True) + tlca.static_lcia() + return tlca.static_score + + +def test_cold_vs_warm_cache_scores_identical(temporal_grouping_db_monthly): + cold = _run_full(persistent=True) # populates disk + warm = _run_full(persistent=True) # reuses disk + np.testing.assert_allclose(warm, cold, rtol=1e-12) + + +def test_persistent_false_writes_no_disk(temporal_grouping_db_monthly): + from bw_timex.persistent_cache import cache_root + _run_full(persistent=False) + # No cache files written when disabled. + assert not cache_root().exists() or not any(cache_root().rglob("*.npz")) + + +def test_lambda_reused_from_disk_second_instance(temporal_grouping_db_monthly): + import bw_timex.adjoint_scoring as adj + counts = [] + real = adj.AdjointCachingSolver.set_score_row + + def spy(self, cb): + real(self, cb) + counts.append(self.solve_count) + + import pytest as _pytest + with _pytest.MonkeyPatch.context() as mp: + mp.setattr(adj.AdjointCachingSolver, "set_score_row", spy) + _tlca(persistent=True).build_timeline(adjoint_scoring=True) # miss → solve + first = list(counts) + counts.clear() + _tlca(persistent=True).build_timeline(adjoint_scoring=True) # hit → no solve + + assert any(c == 1 for c in first) # first build actually solved + assert counts and all(c == 0 for c in counts) # second build reused from disk + + +def test_clear_persistent_cache_empties_dir(temporal_grouping_db_monthly): + from bw_timex.persistent_cache import cache_root, clear_persistent_cache + _run_full(persistent=True) + assert any(cache_root().rglob("*.npz")) + clear_persistent_cache() + assert not cache_root().exists() +``` + +- [ ] **Step 2: Run the new tests** + +Run: `uv run pytest tests/test_persistent_cache.py -v` +Expected: all PASS. If `test_cold_vs_warm_cache_scores_identical` FAILS, the cache changed results — debug with `superpowers:systematic-debugging`; do NOT loosen the tolerance. + +- [ ] **Step 3: Add a deep-chain background-LCI reuse test** + +```python +# append to tests/test_persistent_cache.py +def test_background_unit_lci_reused_across_instances(background_td_deep_chain_db): + import bw2data as bd + from datetime import datetime + from bw_timex import TimexLCA + from bw_timex.persistent_cache import cache_root + + def build(): + fu = bd.get_node(database="foreground", code="fu") + t = TimexLCA( + demand={fu.id: 1}, + method=("GWP", "example"), + database_dates={ + "background_2020": datetime.strptime("2020", "%Y"), + "background_2030": datetime.strptime("2030", "%Y"), + "foreground": "dynamic", + }, + persistent_cache=True, + ) + t.build_timeline(adjoint_scoring=True) + t.lci(expand_technosphere=True, build_dynamic_biosphere=True) + t.static_lcia() + return t.static_score + + first = build() + # Background unit LCI triplets now on disk. + assert any((cache_root() / "background_unit_lci").rglob("*.npz")) + second = build() + np.testing.assert_allclose(second, first, rtol=1e-12) +``` + +Note: confirm `background_td_deep_chain_db`'s database names / demand code by reading `tests/fixtures/background_td_deep_chain_db_fixture.py` (it uses `background_2020`/`background_2030` and demand code `"fu"`); adapt the dict above if the fixture differs. + +- [ ] **Step 4: Run the full suite** + +Run: `uv run pytest -q` +Expected: all pre-existing tests still PASS, plus the new `tests/test_persistent_cache.py` tests; no new warnings. + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_persistent_cache.py +git commit # message: "test: end-to-end persistent-cache validation gate" +``` + +--- + +## Self-Review + +**Spec coverage:** +- platformdirs root + `v1` version + `BW_TIMEX_CACHE_DIR` override → Task 1 (`cache_root`). ✓ +- `PersistentDict` (MutableMapping, write-through, fall-through, corrupt=miss, atomic) → Task 1. ✓ +- `LambdaDiskCache` (load/save 1-D) → Task 2. ✓ +- λ skip-on-hit (`solve_count` stays 0), save-on-miss; reached via `base_lca` attributes (spec's literal "param on traversal" refined to attribute-stash because `bw_temporalis.calculate()` controls instantiation) → Task 3 + Task 5 stash. ✓ +- modified-token + method keying; background 5-tuple key; λ key form → Tasks 3/5 + Global Constraints. ✓ +- on-by-default + off switch (`persistent_cache`) gated by `use_global_lci_cache` → Task 5. ✓ +- `clear_persistent_cache()` + combined `clear_background_lci_cache()` + exports → Task 4. ✓ +- platformdirs direct dep → Task 4. ✓ +- Error handling (never load-bearing, atomic writes, swallow) → Task 1 helpers + Tasks 1/2 load paths. ✓ +- Tests use `BW_TIMEX_CACHE_DIR` tmp_path; cold/warm parity; `persistent_cache=False` no disk IO; cross-instance reuse; clear empties → Tasks 1–6. ✓ +- Out-of-scope caches (LCI_SOLVE/NODES/biosphere) untouched. ✓ + +**Placeholder scan:** No TBD/TODO/"add error handling". Two explicit "confirm against fixture/keep existing args" notes (Task 5 `TimelineBuilder` args; Task 6 deep-chain fixture) point at concrete files, not vague work. ✓ + +**Type consistency:** `PersistentDict(memory, disk_dir)`, `LambdaDiskCache(disk_dir)`, `cache_root()`, `clear_persistent_cache()`, `_build_lambda_cache_key()`, attributes `_bw_timex_lambda_cache`/`_bw_timex_lambda_key`, `persistent_cache` flag — all used identically across Tasks 1–6. Background value triplet `(bio, act, val)` consistent between serialization (Task 1) and the builder's existing `_inventory_to_triplets`. ✓ diff --git a/docs/superpowers/plans/2026-06-24-premise-temporal-annotation.md b/docs/superpowers/plans/2026-06-24-premise-temporal-annotation.md new file mode 100644 index 00000000..f6b90306 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-premise-temporal-annotation.md @@ -0,0 +1,788 @@ +# premise Temporal-Distribution Annotation — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Annotate existing premise-generated, year-specific bw2 databases with `bw_temporalis.TemporalDistribution`s, sourcing parameters and placement rules from premise's `temporal_distributions.csv`, so bw_timex can run time-explicit LCA on a premise background with no hand-defined temporal data. + +**Architecture:** A single new module `bw_timex/premise_temporal.py`. A pure converter turns premise's distribution codes into `TemporalDistribution`s; an annotation function applies premise's placement rules to a bw2 database's exchanges using an injected `TemporalSpecs` (so the core is premise-free and fully testable); a thin adapter reuses premise's own CSV loader to build `TemporalSpecs`; a public entry point ties it together. `premise` is an optional extra, imported lazily and feature-detected. + +**Tech Stack:** Python 3.13, numpy, `bw_temporalis` (core dep), `bw2data`, `premise` (optional extra), pytest, uv. + +## Global Constraints + +- Package manager: `uv` for all Python (`uv run pytest ...`). Never pip/conda. +- COMMITS ENABLED on branch `feat/premise-temporal` (off `main`). Commit per task. End every commit message body with exactly these two trailers: + `Co-Authored-By: Claude Opus 4.8 ` + `Claude-Session: https://claude.ai/code/session_01NadULkEstbs67wxr2W8DtL` +- `premise` is an OPTIONAL dependency (extra `premise`); core bw_timex must import and all existing tests must pass without premise installed. Import premise lazily inside functions only. +- Detect premise's temporal support by **feature detection** (presence of `premise.trails.TrailsDataPackage` and `premise.trails.FILEPATH_TEMPORAL_PARAMETERS`), NOT a version-number check (the loader currently ships on a premise branch numbered 2.3.7; the released version will be ≥2.5.0). Error messages may reference "premise>=2.5.0 / bw-timex[premise]". +- premise temporal distribution codes (years as the time unit): `1` discrete (mass at `loc`), `3` normal, `4` uniform (`[min,max]`), `5` triangular (mode=`loc`), `6` discrete empirical (explicit `offsets`/`weights`). +- premise placement rules to mirror EXACTLY (from `premise/trails.py::add_temporal_distributions`): + - biomass_growth: dataset `(name, reference product)` in `biomass_growth_params` → its biosphere exchange named exactly `"Carbon dioxide, in air"`. + - stock_asset: technosphere exchange whose SUPPLIER `(name, product)` is in `stock_asset_params` → supplier params. + - maintenance: technosphere exchange whose supplier is in `maintenance_suppliers` → uniform (code 4) over `[0, lifetime]` using the CALLING dataset's lifetime. + - end_of_life: technosphere exchange whose supplier is in `end_of_life_suppliers` → single pulse (code 6) at the calling dataset's lifetime. + - supplier matching >1 of stock_asset/maintenance/end_of_life → fault, skip. + - technosphere exchange with no supplier product, or maintenance/end_of_life with no dataset lifetime → fault, skip. +- premise's CSV loader returns the 5-tuple `(stock_assets, end_of_life, biomass_growth, maintenance, dataset_lifetimes)` and uses no `self`. +- Do not edit anything under `.venv/`. No unfold, no materialization, no database_dates building. + +--- + +## File Structure + +- **Create `bw_timex/premise_temporal.py`** — the entire feature (one responsibility: premise→bw_timex temporal annotation): dataclasses `TemporalSpecs`, `AnnotationReport`; pure converter `premise_params_to_td`; `annotate_database`; premise adapter `load_temporal_specs`; public `add_premise_temporal_distributions`. +- **Modify `bw_timex/__init__.py`** — export `add_premise_temporal_distributions`. +- **Modify `pyproject.toml`** — add `premise` optional-dependency extra. +- **Create `tests/test_premise_temporal.py`** — unit tests for the converter and annotation (premise-free, bw2 fixtures), plus premise-gated tests for the loader (`pytest.importorskip`). + +Reference facts (verified): +- premise CSV loader: `premise.trails.TrailsDataPackage._load_temporal_specs_from_csv(self, path)` — ignores `self`; returns `(stock_assets, end_of_life, biomass_growth, maintenance, dataset_lifetimes)`. Param dicts have keys: `temporal_distribution` (int code), `temporal_loc`, `temporal_scale`, `temporal_offsets` (list|None), `temporal_weights` (list|None), `temporal_min`, `temporal_max`, `lifetime`. +- CSV path constant: `premise.trails.FILEPATH_TEMPORAL_PARAMETERS`. +- `bw_temporalis.easy_timedelta_distribution(start: int, end: int, resolution: str, steps: int|None=50, kind: str|None="uniform", param: float|None=None)`; kinds include `"uniform"`, `"triangular"` (param=mode), `"normal"` (param=std). `bw_temporalis.TemporalDistribution(date, amount)` with `date` a `timedelta64` ndarray and `amount` a float ndarray. +- bw_timex stores TDs as `exchange["temporal_distribution"] = ; exchange.save()`. + +--- + +### Task 1: Module scaffold — dataclasses + pure converter `premise_params_to_td` + +**Files:** +- Create: `bw_timex/premise_temporal.py` +- Test: `tests/test_premise_temporal.py` + +**Interfaces:** +- Consumes: numpy, `bw_temporalis` (`TemporalDistribution`, `easy_timedelta_distribution`). +- Produces: + - `@dataclass TemporalSpecs` with fields `biomass_growth_params: dict`, `stock_asset_params: dict`, `maintenance_suppliers: set`, `end_of_life_suppliers: set`, `dataset_lifetimes: dict`. + - `@dataclass AnnotationReport` with `annotated: int = 0`, `skipped_existing: int = 0`, `faults: list = field(default_factory=list)`, and a `merge(self, other: "AnnotationReport") -> None` method. + - `premise_params_to_td(params: dict, *, max_steps: int = 200) -> bw_temporalis.TemporalDistribution`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_premise_temporal.py +import numpy as np +import pytest +from bw_temporalis import TemporalDistribution + + +def test_discrete_code_1_single_pulse_at_loc(): + from bw_timex.premise_temporal import premise_params_to_td + td = premise_params_to_td({"temporal_distribution": 1, "temporal_loc": -5.0}) + assert isinstance(td, TemporalDistribution) + assert td.date.astype("timedelta64[Y]").astype(int).tolist() == [-5] + assert np.allclose(td.amount.sum(), 1.0) + + +def test_empirical_code_6_offsets_weights_normalised(): + from bw_timex.premise_temporal import premise_params_to_td + td = premise_params_to_td( + {"temporal_distribution": 6, "temporal_offsets": [0, 10], "temporal_weights": [1.0, 3.0]} + ) + assert td.date.astype("timedelta64[Y]").astype(int).tolist() == [0, 10] + np.testing.assert_allclose(td.amount, [0.25, 0.75]) + + +def test_uniform_code_4_from_min_max(): + from bw_timex.premise_temporal import premise_params_to_td + td = premise_params_to_td({"temporal_distribution": 4, "temporal_min": 0.0, "temporal_max": 5.0}) + yrs = td.date.astype("timedelta64[Y]").astype(int) + assert yrs.min() == 0 and yrs.max() == 5 + np.testing.assert_allclose(td.amount.sum(), 1.0) + + +def test_normal_code_3_bounds_from_min_max(): + from bw_timex.premise_temporal import premise_params_to_td + td = premise_params_to_td( + {"temporal_distribution": 3, "temporal_loc": -20.0, "temporal_scale": 3.0, + "temporal_min": -40.0, "temporal_max": -1.0} + ) + yrs = td.date.astype("timedelta64[Y]").astype(int) + assert yrs.min() == -40 and yrs.max() == -1 + np.testing.assert_allclose(td.amount.sum(), 1.0) + + +def test_triangular_code_5(): + from bw_timex.premise_temporal import premise_params_to_td + td = premise_params_to_td( + {"temporal_distribution": 5, "temporal_loc": 5.0, "temporal_min": 0.0, "temporal_max": 10.0} + ) + assert td.date.astype("timedelta64[Y]").astype(int).max() == 10 + np.testing.assert_allclose(td.amount.sum(), 1.0) + + +def test_unsupported_code_raises(): + from bw_timex.premise_temporal import premise_params_to_td + with pytest.raises(ValueError): + premise_params_to_td({"temporal_distribution": 99, "temporal_loc": 1.0}) + + +def test_annotation_report_merge(): + from bw_timex.premise_temporal import AnnotationReport + a = AnnotationReport(annotated=1, skipped_existing=2, faults=[{"x": 1}]) + b = AnnotationReport(annotated=3, skipped_existing=0, faults=[{"y": 2}]) + a.merge(b) + assert a.annotated == 4 and a.skipped_existing == 2 and len(a.faults) == 2 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_premise_temporal.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'bw_timex.premise_temporal'`. + +- [ ] **Step 3: Write the implementation** + +```python +# bw_timex/premise_temporal.py +"""Annotate existing premise databases with bw_timex temporal distributions. + +premise (the trails work, released in premise >= 2.5.0) curates background +temporal data in ``temporal_distributions.csv`` and places it on exchanges via +fixed rules. This module reuses premise's CSV loader and mirrors those +placement rules to write ``bw_temporalis.TemporalDistribution`` objects onto the +exchanges of already-existing, year-specific premise bw2 databases. It does not +build, unfold, or materialize databases. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +from bw_temporalis import TemporalDistribution, easy_timedelta_distribution + +_RESOLUTION = "Y" # premise temporal values are in years + + +@dataclass +class TemporalSpecs: + """premise's categorized temporal buckets (keys are ``(name, reference product)``).""" + + biomass_growth_params: dict + stock_asset_params: dict + maintenance_suppliers: set + end_of_life_suppliers: set + dataset_lifetimes: dict + + +@dataclass +class AnnotationReport: + """Summary of an annotation pass.""" + + annotated: int = 0 + skipped_existing: int = 0 + faults: list = field(default_factory=list) + + def merge(self, other: "AnnotationReport") -> None: + self.annotated += other.annotated + self.skipped_existing += other.skipped_existing + self.faults.extend(other.faults) + + +def _single_pulse(year: float) -> TemporalDistribution: + return TemporalDistribution( + date=np.array([int(round(year))], dtype="timedelta64[Y]"), + amount=np.array([1.0], dtype=float), + ) + + +def _bounds(params: dict, loc, scale) -> tuple[int, int]: + mn = params.get("temporal_min") + mx = params.get("temporal_max") + if mn is not None and mx is not None: + start, end = int(np.floor(mn)), int(np.ceil(mx)) + elif loc is not None and scale: + start, end = int(np.floor(loc - 3 * scale)), int(np.ceil(loc + 3 * scale)) + else: + raise ValueError( + "Cannot determine distribution bounds: need temporal_min/temporal_max " + "or temporal_loc + temporal_scale." + ) + if start > end: + start, end = end, start + return start, end + + +def premise_params_to_td(params: dict, *, max_steps: int = 200) -> TemporalDistribution: + """Convert one premise temporal-parameter dict into a ``TemporalDistribution``. + + ``params`` uses premise keys: ``temporal_distribution`` (int code), + ``temporal_loc``, ``temporal_scale``, ``temporal_min``, ``temporal_max``, + ``temporal_offsets``, ``temporal_weights``. Time unit is years. + """ + code = params.get("temporal_distribution") + loc = params.get("temporal_loc") + scale = params.get("temporal_scale") + + if code == 1: # discrete: all mass at loc + if loc is None: + raise ValueError("discrete (code 1) temporal distribution requires temporal_loc") + return _single_pulse(loc) + + if code == 6: # discrete empirical: explicit offsets/weights + offsets = params.get("temporal_offsets") + weights = params.get("temporal_weights") + if not offsets or not weights or len(offsets) != len(weights): + raise ValueError("empirical (code 6) requires matching temporal_offsets/temporal_weights") + amount = np.asarray(weights, dtype=float) + total = amount.sum() + if total == 0: + raise ValueError("empirical (code 6) weights sum to zero") + amount = amount / total + return TemporalDistribution( + date=np.array([int(round(o)) for o in offsets], dtype="timedelta64[Y]"), + amount=amount, + ) + + start, end = _bounds(params, loc, scale) + steps = max(2, min(max_steps, end - start + 1)) + + if code == 3: # normal + return easy_timedelta_distribution(start, end, _RESOLUTION, steps=steps, kind="normal", param=scale) + if code == 4: # uniform + return easy_timedelta_distribution(start, end, _RESOLUTION, steps=steps, kind="uniform") + if code == 5: # triangular, mode = loc + return easy_timedelta_distribution(start, end, _RESOLUTION, steps=steps, kind="triangular", param=loc) + + raise ValueError(f"Unsupported premise temporal_distribution code: {code!r}") +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_premise_temporal.py -v` +Expected: all 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add bw_timex/premise_temporal.py tests/test_premise_temporal.py +git commit # "feat: premise_temporal converter + dataclasses" +``` + +--- + +### Task 2: `annotate_database` — apply premise placement rules to a bw2 database + +**Files:** +- Modify: `bw_timex/premise_temporal.py` +- Test: `tests/test_premise_temporal.py` + +**Interfaces:** +- Consumes: `TemporalSpecs`, `AnnotationReport`, `premise_params_to_td` (Task 1); `bw2data` (lazy import). +- Produces: `annotate_database(db_name: str, specs: TemporalSpecs, *, overwrite: bool = False) -> AnnotationReport`. Premise-free (operates on an injected `TemporalSpecs`). + +- [ ] **Step 1: Write the failing tests** + +```python +# append to tests/test_premise_temporal.py +from bw2data.tests import bw2test + + +def _write_synthetic_dbs(): + import bw2data as bd + bd.Database("bio").write({ + ("bio", "co2"): {"name": "Carbon dioxide, in air", "type": "emission", "categories": ("air",)}, + }) + bd.Database("ei").write({ + # biomass-growth dataset: has the CO2-in-air biosphere exchange + ("ei", "forest"): { + "name": "forestry", "reference product": "wood", "location": "GLO", "unit": "kg", + "exchanges": [ + {"input": ("ei", "forest"), "amount": 1.0, "type": "production"}, + {"input": ("bio", "co2"), "amount": -2.0, "type": "biosphere"}, + ], + }, + # supplier used as stock_asset, maintenance, and end_of_life by consumers below + ("ei", "machine"): { + "name": "machine", "reference product": "machine", "location": "GLO", "unit": "unit", + "exchanges": [{"input": ("ei", "machine"), "amount": 1.0, "type": "production"}], + }, + # consumer with a 50-year lifetime that buys the machine (tagged maintenance/eol per specs) + ("ei", "plant"): { + "name": "plant", "reference product": "power", "location": "GLO", "unit": "kWh", + "exchanges": [ + {"input": ("ei", "plant"), "amount": 1.0, "type": "production"}, + {"input": ("ei", "machine"), "amount": 0.1, "type": "technosphere"}, + ], + }, + }) + + +@bw2test +def test_biomass_growth_lands_on_co2_exchange(): + import bw2data as bd + from bw_timex.premise_temporal import annotate_database, TemporalSpecs + _write_synthetic_dbs() + specs = TemporalSpecs( + biomass_growth_params={("forestry", "wood"): { + "temporal_distribution": 3, "temporal_loc": -20.0, "temporal_scale": 3.0, + "temporal_min": -40.0, "temporal_max": -1.0}}, + stock_asset_params={}, maintenance_suppliers=set(), + end_of_life_suppliers=set(), dataset_lifetimes={}, + ) + report = annotate_database("ei", specs) + forest = bd.get_node(database="ei", code="forest") + bio_exc = [e for e in forest.exchanges() if e["type"] == "biosphere"][0] + assert bio_exc.get("temporal_distribution") is not None + assert report.annotated == 1 + + +@bw2test +def test_maintenance_uniform_over_lifetime(): + import bw2data as bd + from bw_timex.premise_temporal import annotate_database, TemporalSpecs + _write_synthetic_dbs() + specs = TemporalSpecs( + biomass_growth_params={}, stock_asset_params={}, + maintenance_suppliers={("machine", "machine")}, end_of_life_suppliers=set(), + dataset_lifetimes={("plant", "power"): 50.0}, + ) + report = annotate_database("ei", specs) + plant = bd.get_node(database="ei", code="plant") + tech_exc = [e for e in plant.exchanges() if e["type"] == "technosphere"][0] + td = tech_exc.get("temporal_distribution") + assert td is not None + yrs = td.date.astype("timedelta64[Y]").astype(int) + assert yrs.min() == 0 and yrs.max() == 50 + assert report.annotated == 1 + + +@bw2test +def test_ambiguous_supplier_is_faulted_not_applied(): + import bw2data as bd + from bw_timex.premise_temporal import annotate_database, TemporalSpecs + _write_synthetic_dbs() + specs = TemporalSpecs( + biomass_growth_params={}, stock_asset_params={}, + maintenance_suppliers={("machine", "machine")}, + end_of_life_suppliers={("machine", "machine")}, + dataset_lifetimes={("plant", "power"): 50.0}, + ) + report = annotate_database("ei", specs) + plant = bd.get_node(database="ei", code="plant") + tech_exc = [e for e in plant.exchanges() if e["type"] == "technosphere"][0] + assert tech_exc.get("temporal_distribution") is None + assert report.annotated == 0 and len(report.faults) == 1 + + +@bw2test +def test_idempotent_skip_then_overwrite(): + import bw2data as bd + from bw_timex.premise_temporal import annotate_database, TemporalSpecs + _write_synthetic_dbs() + specs = TemporalSpecs( + biomass_growth_params={("forestry", "wood"): { + "temporal_distribution": 1, "temporal_loc": -5.0}}, + stock_asset_params={}, maintenance_suppliers=set(), + end_of_life_suppliers=set(), dataset_lifetimes={}, + ) + annotate_database("ei", specs) + again = annotate_database("ei", specs) + assert again.annotated == 0 and again.skipped_existing >= 1 + forced = annotate_database("ei", specs, overwrite=True) + assert forced.annotated == 1 + + +@bw2test +def test_unknown_database_raises(): + from bw_timex.premise_temporal import annotate_database, TemporalSpecs + specs = TemporalSpecs({}, {}, set(), set(), {}) + with pytest.raises(ValueError): + annotate_database("does-not-exist", specs) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_premise_temporal.py -k "biomass_growth_lands or maintenance_uniform or ambiguous or idempotent or unknown_database" -v` +Expected: FAIL (`annotate_database` not defined). + +- [ ] **Step 3: Write the implementation** + +```python +# append to bw_timex/premise_temporal.py +def _clean(value) -> str: + return (value or "").strip() + + +def _supplier_key(exchange) -> tuple[str, str]: + supplier = exchange.input + return _clean(supplier.get("name")), _clean( + supplier.get("reference product") or supplier.get("product") + ) + + +def annotate_database(db_name, specs: TemporalSpecs, *, overwrite: bool = False) -> AnnotationReport: + """Write temporal distributions onto an existing premise bw2 database. + + Mirrors premise's ``add_temporal_distributions`` placement rules using the + buckets in ``specs``. Returns an :class:`AnnotationReport`; never raises out + of a single bad exchange (records a fault and continues). + """ + import bw2data as bd + + if db_name not in bd.databases: + raise ValueError(f"Database {db_name!r} not found in the current project.") + + report = AnnotationReport() + + def _fault(ds, exc, reason): + report.faults.append({ + "database": db_name, + "dataset": f"{_clean(ds.get('name'))} | {_clean(ds.get('reference product'))}", + "exchange": _clean(exc.get("name")), + "reason": reason, + }) + + def _apply(exc, td): + exc["temporal_distribution"] = td + exc.save() + report.annotated += 1 + + for ds in bd.Database(db_name): + ds_key = (_clean(ds.get("name")), _clean(ds.get("reference product"))) + bg = specs.biomass_growth_params.get(ds_key) + ds_lifetime = specs.dataset_lifetimes.get(ds_key) + + for exc in ds.exchanges(): + if not overwrite and exc.get("temporal_distribution") is not None: + report.skipped_existing += 1 + continue + + etype = exc.get("type") + + if etype == "biosphere": + if ( + bg is not None + and _clean(exc.input.get("name")) == "Carbon dioxide, in air" + and bg.get("temporal_distribution") is not None + ): + _apply(exc, premise_params_to_td(bg)) + continue + + if etype != "technosphere": + continue + + sup_name, sup_ref = _supplier_key(exc) + if not sup_ref: + _fault(ds, exc, "Missing supplier product on technosphere exchange.") + continue + key = (sup_name, sup_ref) + + params = specs.stock_asset_params.get(key) + is_maintenance = key in specs.maintenance_suppliers + is_end_of_life = key in specs.end_of_life_suppliers + matched = int(params is not None) + int(is_maintenance) + int(is_end_of_life) + + if matched == 0: + continue + if matched > 1: + _fault(ds, exc, f"Ambiguous temporal tags for supplier {key}.") + continue + + if params is not None: + _apply(exc, premise_params_to_td(params)) + continue + + if ds_lifetime is None: + _fault(ds, exc, "Missing dataset lifetime for maintenance/end_of_life.") + continue + + if is_maintenance: + _apply(exc, premise_params_to_td( + {"temporal_distribution": 4, "temporal_min": 0.0, "temporal_max": ds_lifetime})) + else: # end_of_life + _apply(exc, premise_params_to_td( + {"temporal_distribution": 6, "temporal_offsets": [ds_lifetime], "temporal_weights": [1.0]})) + + return report +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_premise_temporal.py -v` +Expected: all PASS (Task 1 + Task 2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add bw_timex/premise_temporal.py tests/test_premise_temporal.py +git commit # "feat: annotate_database applying premise placement rules" +``` + +--- + +### Task 3: `load_temporal_specs` — reuse premise's CSV loader (optional dep) + +**Files:** +- Modify: `bw_timex/premise_temporal.py` +- Test: `tests/test_premise_temporal.py` + +**Interfaces:** +- Consumes: `TemporalSpecs` (Task 1); `premise.trails` (lazy, optional). +- Produces: + - `load_temporal_specs(path=None) -> TemporalSpecs`. + - `_import_premise_trails()` helper raising a clear error when premise lacks temporal support. + +- [ ] **Step 1: Write the failing tests** + +```python +# append to tests/test_premise_temporal.py +def test_import_error_when_premise_missing(monkeypatch): + import builtins + from bw_timex import premise_temporal + real_import = builtins.__import__ + + def fake_import(name, *a, **k): + if name == "premise" or name.startswith("premise."): + raise ImportError("no premise") + return real_import(name, *a, **k) + + monkeypatch.setattr(builtins, "__import__", fake_import) + with pytest.raises(ImportError, match="bw-timex\\[premise\\]"): + premise_temporal.load_temporal_specs() + + +def test_load_temporal_specs_reads_premise_csv(): + pytest.importorskip("premise") + from bw_timex.premise_temporal import load_temporal_specs, TemporalSpecs + try: + specs = load_temporal_specs() + except RuntimeError: + pytest.skip("installed premise lacks TrailsDataPackage temporal support") + assert isinstance(specs, TemporalSpecs) + # premise's bundled CSV is non-empty across at least one bucket + assert ( + specs.biomass_growth_params + or specs.stock_asset_params + or specs.maintenance_suppliers + or specs.end_of_life_suppliers + ) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_premise_temporal.py::test_import_error_when_premise_missing -v` +Expected: FAIL (`load_temporal_specs` not defined). + +- [ ] **Step 3: Write the implementation** + +```python +# append to bw_timex/premise_temporal.py +def _import_premise_trails(): + """Import premise's trails module, or raise a clear, actionable error.""" + try: + from premise import trails as premise_trails + except ImportError as exc: + raise ImportError( + "premise temporal annotation requires premise (>=2.5.0). " + "Install it with: pip install bw-timex[premise]" + ) from exc + if not hasattr(premise_trails, "TrailsDataPackage") or not hasattr( + premise_trails, "FILEPATH_TEMPORAL_PARAMETERS" + ): + raise RuntimeError( + "The installed premise lacks temporal-distribution support " + "(TrailsDataPackage / temporal_distributions.csv). Upgrade to premise>=2.5.0." + ) + return premise_trails + + +class _DummySelf: + """Stand-in for the unused ``self`` of premise's CSV loader method.""" + + +def load_temporal_specs(path=None) -> TemporalSpecs: + """Load premise's curated temporal specs into a :class:`TemporalSpecs`. + + Reuses premise's own ``_load_temporal_specs_from_csv`` (which ignores + ``self``) so parsing/categorization stays in premise. ``path`` defaults to + premise's bundled ``temporal_distributions.csv``. + """ + premise_trails = _import_premise_trails() + csv_path = path if path is not None else premise_trails.FILEPATH_TEMPORAL_PARAMETERS + loader = premise_trails.TrailsDataPackage._load_temporal_specs_from_csv + stock_assets, end_of_life, biomass_growth, maintenance, dataset_lifetimes = loader( + _DummySelf(), csv_path + ) + return TemporalSpecs( + biomass_growth_params=biomass_growth, + stock_asset_params=stock_assets, + maintenance_suppliers=maintenance, + end_of_life_suppliers=end_of_life, + dataset_lifetimes=dataset_lifetimes, + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_premise_temporal.py -v` +Expected: PASS (`test_import_error_when_premise_missing` passes; `test_load_temporal_specs_reads_premise_csv` passes if premise installed, else skips). + +- [ ] **Step 5: Commit** + +```bash +git add bw_timex/premise_temporal.py tests/test_premise_temporal.py +git commit # "feat: load_temporal_specs reusing premise CSV loader" +``` + +--- + +### Task 4: Public API + export + optional extra + +**Files:** +- Modify: `bw_timex/premise_temporal.py` (add `add_premise_temporal_distributions`) +- Modify: `bw_timex/__init__.py` (export) +- Modify: `pyproject.toml` (premise extra) +- Test: `tests/test_premise_temporal.py` + +**Interfaces:** +- Consumes: `load_temporal_specs`, `annotate_database`, `AnnotationReport` (Tasks 2–3). +- Produces: `add_premise_temporal_distributions(databases, *, overwrite=False) -> AnnotationReport`, exported as `bw_timex.add_premise_temporal_distributions`. + +- [ ] **Step 1: Write the failing tests** + +```python +# append to tests/test_premise_temporal.py +def test_public_export(): + import bw_timex + assert hasattr(bw_timex, "add_premise_temporal_distributions") + + +@bw2test +def test_add_premise_temporal_distributions_uses_injected_specs(monkeypatch): + import bw2data as bd + from bw_timex import premise_temporal + from bw_timex.premise_temporal import TemporalSpecs, add_premise_temporal_distributions + _write_synthetic_dbs() + specs = TemporalSpecs( + biomass_growth_params={("forestry", "wood"): {"temporal_distribution": 1, "temporal_loc": -5.0}}, + stock_asset_params={}, maintenance_suppliers=set(), + end_of_life_suppliers=set(), dataset_lifetimes={}, + ) + monkeypatch.setattr(premise_temporal, "load_temporal_specs", lambda *a, **k: specs) + report = add_premise_temporal_distributions(["ei"]) + assert report.annotated == 1 + forest = bd.get_node(database="ei", code="forest") + assert [e for e in forest.exchanges() if e["type"] == "biosphere"][0].get("temporal_distribution") is not None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_premise_temporal.py::test_public_export -v` +Expected: FAIL (`AttributeError`). + +- [ ] **Step 3: Implement** + +Add to `bw_timex/premise_temporal.py`: + +```python +def add_premise_temporal_distributions(databases, *, overwrite: bool = False) -> AnnotationReport: + """Annotate existing premise databases with temporal distributions. + + ``databases`` is an iterable of database names (or a mapping whose keys are + database names; values are ignored). Loads premise's temporal specs once and + annotates each database. Returns an aggregated :class:`AnnotationReport`. + """ + names = list(databases.keys()) if isinstance(databases, dict) else list(databases) + specs = load_temporal_specs() + report = AnnotationReport() + for name in names: + report.merge(annotate_database(name, specs, overwrite=overwrite)) + return report +``` + +Add to `bw_timex/__init__.py` after the existing imports: + +```python +from .premise_temporal import add_premise_temporal_distributions +``` + +In `pyproject.toml`, under `[project.optional-dependencies]`, add: + +```toml +premise = [ + "premise>=2.5.0", +] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_premise_temporal.py -v` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add bw_timex/premise_temporal.py bw_timex/__init__.py pyproject.toml tests/test_premise_temporal.py +git commit # "feat: public add_premise_temporal_distributions + premise extra" +``` + +--- + +### Task 5: Validation gate — full suite + premise-free import check + +**Files:** +- Test: `tests/test_premise_temporal.py` + +**Interfaces:** +- Consumes: the whole module (Tasks 1–4). +- Produces: a guard that `bw_timex` imports and the suite passes with premise NOT required. + +- [ ] **Step 1: Add a premise-free import guard test** + +```python +# append to tests/test_premise_temporal.py +def test_core_import_does_not_require_premise(monkeypatch): + # Importing bw_timex and using the converter must not require premise. + import builtins + real_import = builtins.__import__ + + def fake_import(name, *a, **k): + if name == "premise" or name.startswith("premise."): + raise ImportError("premise blocked") + return real_import(name, *a, **k) + + monkeypatch.setattr(builtins, "__import__", fake_import) + import importlib + import bw_timex.premise_temporal as pt + importlib.reload(pt) + td = pt.premise_params_to_td({"temporal_distribution": 1, "temporal_loc": 0.0}) + assert td is not None +``` + +- [ ] **Step 2: Run the new test** + +Run: `uv run pytest tests/test_premise_temporal.py::test_core_import_does_not_require_premise -v` +Expected: PASS (module import + converter work without premise). + +- [ ] **Step 3: Run the full suite** + +Run: `uv run pytest -q` +Expected: all pre-existing tests still PASS, plus `tests/test_premise_temporal.py`; no new warnings. + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_premise_temporal.py +git commit # "test: premise_temporal validation gate" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Reuse premise CSV loader → Task 3 (`load_temporal_specs` calls premise's `_load_temporal_specs_from_csv`). ✓ +- premise→TD converter for all codes (1/3/4/5/6), years → Task 1. ✓ +- placement rules (biomass_growth/stock_asset/maintenance/end_of_life, ambiguity, missing-lifetime/supplier faults) → Task 2. ✓ +- idempotency / overwrite → Task 2. ✓ +- public API + export → Task 4. ✓ +- premise optional extra + lazy import + feature-detect guard → Tasks 3–4. ✓ +- error handling (premise missing, unknown db, per-exchange faults non-fatal) → Tasks 2–3. ✓ +- testing incl. premise-free core → Tasks 1–5. ✓ +- **Deviation from spec:** the spec described a "reference test comparing placement to premise's own `add_temporal_distributions` output." premise's assignment is not standalone-callable (it is a method over a `TrailsDataPackage` with scenario plumbing), so the drift guard here is instead (a) reusing premise's loader verbatim for the parsing/categorization and (b) rule-encoded annotation tests (Task 2) that assert each premise rule explicitly. This is the practical equivalent; noted for the reviewer. +- Out of scope (unfold/materialize/database_dates) → not present. ✓ + +**Placeholder scan:** No TBD/TODO/"add error handling". All code steps contain complete code. ✓ + +**Type consistency:** `TemporalSpecs` field names (`biomass_growth_params`, `stock_asset_params`, `maintenance_suppliers`, `end_of_life_suppliers`, `dataset_lifetimes`) consistent across Tasks 1–4; premise loader 5-tuple order `(stock_assets, end_of_life, biomass_growth, maintenance, dataset_lifetimes)` mapped correctly in Task 3; `premise_params_to_td` / `annotate_database` / `add_premise_temporal_distributions` / `AnnotationReport.merge` signatures consistent across tasks and tests. ✓ diff --git a/docs/superpowers/plans/2026-07-06-background-production-td-conservation.md b/docs/superpowers/plans/2026-07-06-background-production-td-conservation.md new file mode 100644 index 00000000..aee98b95 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-background-production-td-conservation.md @@ -0,0 +1,729 @@ +# Background production-edge TD conservation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `traverse_background=True` conserve impact (and not raise `KeyError`) when a descended background node carries a production-edge temporal distribution, by registering the node at the same production-TD-weighted cohorts it is consumed at. + +**Architecture:** In the shared `VariantBackgroundMixin` proxy-descent, fold a background producer's own production-edge TD into the *effective producer TD of the edge that produces it* (outer-product convolution — dates sum, amounts multiply), instead of applying it only to the node's child expansion. This makes the producer's registered cohort-years equal its consumed cohort-years (kills the `KeyError`) and carries `exchange_weight × prodTD_weight` per cohort (conserves). Foreground / explicit product-process modelling and the matrix traversal are untouched. + +**Tech Stack:** Python, `bw_timex`, `bw_temporalis` (`TemporalDistribution`), `numpy`, `pytest`, `bw2data` test fixtures (`@bw2test`). + +## Global Constraints + +- Change lives ONLY in `VariantBackgroundMixin` (background proxy-descent) — do not modify `build_edge_timeline` FU-seed logic, the matrix traversal, or `_join_datetime_and_timedelta_distributions`'s global behaviour. +- Both engines (`EdgeExtractor` priority, `EdgeExtractorBFS`) must pass every test — parametrize `graph_traversal` over `["priority", "bfs"]`. +- Conservation assertion: time-explicit `static_score == base_lca.score` within `rel=1e-6`. +- Keep the existing nearest-registered-year snap in `TimelineBuilder.get_time_mapping_key` (defense-in-depth); do not remove it. +- Preserve array alignment: the emitted `Edge`'s `td_producer`, `abs_td_producer`, and `distribution` must stay index-aligned (same length, same ravel order) so `extract_edge_data` explodes them consistently. +- TDD: failing test first, watch it fail, minimal fix, watch it pass, commit. No Claude attribution in commit messages. + +--- + +## File Structure + +- `bw_timex/edge_extractor.py` — add one helper to `VariantBackgroundMixin`; modify `_emit_variant_split_for_consumer_date` and `_descend_variant_subtree`. +- `tests/fixtures/background_prod_td_db_fixture.py` — new fixtures (single-chain and convergent) with production-edge TDs. +- `tests/conftest.py` — register the new fixtures. +- `tests/test_background_production_td.py` — new conservation tests. +- `tests/test_repro_variant_mismatch.py` — throwaway; delete at the end (its scenarios are superseded by the new named tests). + +--- + +### Task 1: Failing conservation test — production TD on a first-level descended background node + +**Files:** +- Create: `tests/fixtures/background_prod_td_db_fixture.py` +- Modify: `tests/conftest.py` +- Test: `tests/test_background_production_td.py` + +**Interfaces:** +- Produces: fixture `background_prod_td_db` returning `{db_name: {"bg_A":node, "bg_B":node, "bg_C":node}}`; two dated variants `background_2020`, `background_2030`; `bg_A->bg_B` carries a technosphere TD; `bg_B` carries a production-edge TD. + +- [ ] **Step 1: Write the fixture** + +Create `tests/fixtures/background_prod_td_db_fixture.py`: + +```python +import bw2data as bd +import numpy as np +import pytest +from bw2data.tests import bw2test +from bw_temporalis import TemporalDistribution + + +@pytest.fixture +@bw2test +def background_prod_td_db(): + """fu -> bg_A -> bg_B -> bg_C -> CO2, two dated variants. + + bg_A->bg_B carries a technosphere TD (triggers the variant-split descent). + bg_B carries a PRODUCTION-edge TD spread over several years, so the descent + must register bg_B at the same production-TD-weighted cohorts it consumes + bg_C at. All coefficients are 1, so the total impact must equal 1.0. + """ + biosphere = bd.Database("biosphere") + biosphere.write( + {("biosphere", "CO2"): {"type": "emission", "name": "carbon dioxide"}} + ) + co2 = biosphere.get("CO2") + + foreground = bd.Database("foreground") + foreground.register() + bg20 = bd.Database("background_2020") + bg20.register() + bg30 = bd.Database("background_2030") + bg30.register() + + fu = foreground.new_node("fu", name="fu", unit="unit") + fu["reference product"] = "fu" + fu.save() + fu.new_edge(input=fu, amount=1, type="production").save() + + td_a_to_b = TemporalDistribution( + date=np.array([0, 10], dtype="timedelta64[Y]"), + amount=np.array([0.6, 0.4]), + ) + prod_td_b = TemporalDistribution( + date=np.array([0, 3, 6], dtype="timedelta64[Y]"), + amount=np.array([0.5, 0.3, 0.2]), + ) + + variants = {} + for db in (bg20, bg30): + bg_a = db.new_node("bg_A", name="bg_A", unit="k"); bg_a["reference product"] = "bg_A"; bg_a.save() + bg_b = db.new_node("bg_B", name="bg_B", unit="k"); bg_b["reference product"] = "bg_B"; bg_b.save() + bg_c = db.new_node("bg_C", name="bg_C", unit="k"); bg_c["reference product"] = "bg_C"; bg_c.save() + + bg_a.new_edge(input=bg_a, amount=1, type="production").save() + pb = bg_b.new_edge(input=bg_b, amount=1, type="production") + pb["temporal_distribution"] = prod_td_b + pb.save() + bg_c.new_edge(input=bg_c, amount=1, type="production").save() + + e = bg_a.new_edge(input=bg_b, amount=1, type="technosphere") + e["temporal_distribution"] = td_a_to_b + e.save() + bg_b.new_edge(input=bg_c, amount=1, type="technosphere").save() + bg_c.new_edge(input=co2, amount=1, type="biosphere").save() + variants[db.name] = {"bg_A": bg_a, "bg_B": bg_b, "bg_C": bg_c} + + fu.new_edge(input=variants["background_2020"]["bg_A"], amount=1, type="technosphere").save() + + bd.Method(("GWP", "example")).write([(("biosphere", "CO2"), 1)]) + for dbn in bd.databases: + bd.Database(dbn).process() + return variants +``` + +- [ ] **Step 2: Register the fixture in conftest** + +In `tests/conftest.py`, add alongside the other fixture imports: + +```python +from .fixtures.background_prod_td_db_fixture import background_prod_td_db +``` + +- [ ] **Step 3: Write the failing conservation test** + +Create `tests/test_background_production_td.py`: + +```python +from datetime import datetime + +import pytest + +from bw_timex import TimexLCA + +METHOD = ("GWP", "example") +DATABASE_DATES = { + "background_2020": datetime(2020, 1, 1), + "background_2030": datetime(2030, 1, 1), + "foreground": "dynamic", +} + + +@pytest.mark.parametrize("graph_traversal", ["priority", "bfs"]) +def test_first_level_production_td_conserves(background_prod_td_db, graph_traversal): + t = TimexLCA({("foreground", "fu"): 1}, METHOD, DATABASE_DATES) + t.build_timeline( + starting_datetime="2020-01-01", + temporal_grouping="year", + graph_traversal=graph_traversal, + traverse_background=True, + cutoff=1e-9, + max_calc=2000, + ) + t.lci() + t.static_lcia() + assert t.static_score == pytest.approx(t.base_lca.score, rel=1e-6) +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_background_production_td.py -q --no-cov` +Expected: FAIL — `assert 3.0 == 1.0 ± 1.0e-06` (both `priority` and `bfs`). The production-TD cohorts are each counted in full. + +- [ ] **Step 5: Commit the failing test** + +```bash +git add tests/fixtures/background_prod_td_db_fixture.py tests/conftest.py tests/test_background_production_td.py +git commit -m "test: failing conservation test for background production-edge TD" +``` + +--- + +### Task 2: Add the outer-product convolution helper + +**Files:** +- Modify: `bw_timex/edge_extractor.py` (add method to `VariantBackgroundMixin`, near `_normalized_production_edge_td_from_proxy` around line 208) +- Test: `tests/test_edge_extractor.py` + +**Interfaces:** +- Produces: `VariantBackgroundMixin._fold_production_td(base_td: TemporalDistribution, prod_td: TemporalDistribution) -> TemporalDistribution` — returns a TD whose dates are the outer sum `base.date[i] + prod.date[j]` and amounts are the outer product `base.amount[i] * prod.amount[j]`, raveled in row-major (`i`-major) order. Works for `base` datetime or timedelta. + +- [ ] **Step 1: Write the failing unit test** + +Add to `tests/test_edge_extractor.py`: + +```python +import numpy as np +from bw_temporalis import TemporalDistribution +from bw_timex.edge_extractor import VariantBackgroundMixin + + +def test_fold_production_td_outer_product(): + base = TemporalDistribution( + date=np.array([0, 10], dtype="timedelta64[Y]"), + amount=np.array([0.6, 0.4]), + ) + prod = TemporalDistribution( + date=np.array([0, 3], dtype="timedelta64[Y]"), + amount=np.array([0.5, 0.5]), + ) + out = VariantBackgroundMixin._fold_production_td(base, prod) + # dates: 0+0, 0+3, 10+0, 10+3 (i-major) + assert list(out.date.astype("timedelta64[Y]").astype(int)) == [0, 3, 10, 13] + # amounts: 0.6*0.5, 0.6*0.5, 0.4*0.5, 0.4*0.5 + np.testing.assert_allclose(out.amount, [0.3, 0.3, 0.2, 0.2]) + # total weight preserved (prod is normalized) + assert out.amount.sum() == pytest.approx(base.amount.sum()) +``` + +Add `import pytest` at the top of the file if not already present. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_edge_extractor.py::test_fold_production_td_outer_product -q --no-cov` +Expected: FAIL — `AttributeError: ... has no attribute '_fold_production_td'`. + +- [ ] **Step 3: Implement the helper** + +In `bw_timex/edge_extractor.py`, inside `class VariantBackgroundMixin`, immediately after `_normalized_production_edge_td_from_proxy` (ends ~line 221), add: + +```python + @staticmethod + def _fold_production_td(base_td, prod_td): + """Convolve ``base_td`` with a producer's normalized production-edge TD. + + Unlike ``_join_datetime_and_timedelta_distributions`` (which tiles the + producer amounts and drops the consumer-side amounts), this takes the + outer product of amounts and the outer sum of dates, so the cohort + weights carried in ``base_td`` are preserved. Ravel is ``base``-major so + the result stays index-aligned with a sibling ``base_td`` folded the same + way. Used to register a descended background producer at its + production-TD-weighted cohorts. + """ + date = ( + base_td.date.reshape(-1, 1) + prod_td.date.reshape(1, -1) + ).ravel() + amount = ( + base_td.amount.reshape(-1, 1) * prod_td.amount.reshape(1, -1) + ).ravel() + return TemporalDistribution(date=date, amount=amount) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_edge_extractor.py::test_fold_production_td_outer_product -q --no-cov` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add bw_timex/edge_extractor.py tests/test_edge_extractor.py +git commit -m "feat: add production-TD outer-product fold helper" +``` + +--- + +### Task 3: Fold production TD into the first-level variant-split edge + +**Files:** +- Modify: `bw_timex/edge_extractor.py` — `_emit_variant_split_for_consumer_date` (the per-variant loop body, lines ~369-408) + +**Interfaces:** +- Consumes: `self._fold_production_td` (Task 2), `self._normalized_production_edge_td_from_proxy`. +- Produces: the emitted split `Edge` for `variant_id` now spans the production-TD cohorts; `_descend_variant_subtree` is entered with `td`/`abs_td` already folded (no separate re-application). + +- [ ] **Step 1: Replace the masked-arrays + child block** + +In `_emit_variant_split_for_consumer_date`, replace the block from `masked_abs_td_producer = TemporalDistribution(` (line ~370) through the end of the `if producer_production_td is not None:` child block (line ~408) with: + +```python + masked_abs_td_producer = TemporalDistribution( + date=abs_td_producer.date[keep_idx], + amount=abs_td_producer.amount[keep_idx] * weights, + ) + masked_distribution = TemporalDistribution( + date=distribution.date[keep_idx], + amount=distribution.amount[keep_idx] * weights, + ) + masked_td_producer = TemporalDistribution( + date=td_producer.date[keep_idx], + amount=td_producer.amount[keep_idx] * weights, + ) + variant_id = self._resolve_in_variant(producer_process, db_name) + self.variant_resolved_producers.add(variant_id) + + # If this background producer has a production-edge TD, spread it into + # production-TD-weighted cohorts on the PRODUCER side too, so it is + # registered at exactly the cohort-years it is later consumed at + # (bands match -> no KeyError) with weights = exchange x production + # (conserves). Fold identically into all three arrays to keep them + # index-aligned for extract_edge_data. + producer_production_td = self._normalized_production_edge_td_from_proxy( + variant_id + ) + if producer_production_td is not None: + masked_td_producer = self._fold_production_td( + masked_td_producer, producer_production_td + ) + masked_abs_td_producer = self._fold_production_td( + masked_abs_td_producer, producer_production_td + ) + masked_distribution = self._fold_production_td( + masked_distribution, producer_production_td + ) + + edges.append( + Edge( + edge_type=edge_type, + distribution=masked_distribution, + leaf=self.edge_ff(producer_process), + consumer=node_id, + producer=variant_id, + td_producer=masked_td_producer, + td_consumer=td_parent, + abs_td_producer=masked_abs_td_producer, + abs_td_consumer=abs_td, + temporal_evolution=temporal_evolution, + ) + ) + + child_td, child_abs_td = masked_distribution, masked_abs_td_producer +``` + +(This moves the `Edge` append to AFTER the fold, deletes the old post-append `child_td, child_abs_td = ...` + `producer_production_td` re-application block, and sets the child directly from the already-folded arrays. Leave the `variant_supply = ...` line and the `self._descend_variant_subtree(...)` call that follow unchanged.) + +- [ ] **Step 2: Run the Task 1 conservation test** + +Run: `.venv/bin/python -m pytest tests/test_background_production_td.py -q --no-cov` +Expected: PASS for both `priority` and `bfs` (score `== 1.0`). bg_B's production TD is applied at this first-level split site. + +- [ ] **Step 3: Run the full existing suite (no regressions)** + +Run: `.venv/bin/python -m pytest tests/ --ignore=tests/test_repro_variant_mismatch.py -q --no-cov` +Expected: all pass (baseline was 244 + the 2 new = 246). + +- [ ] **Step 4: Commit** + +```bash +git add bw_timex/edge_extractor.py +git commit -m "fix: fold production-edge TD into first-level background variant split" +``` + +--- + +### Task 4: Fold production TD into the descent edges + +**Files:** +- Modify: `bw_timex/edge_extractor.py` — `_descend_variant_subtree` inner loop (edge emit ~520-533 and child block ~535-549) +- Test: `tests/test_background_production_td.py` (add a deeper-node case) + +**Interfaces:** +- Consumes: `self._fold_production_td`, `self._normalized_production_edge_td_from_proxy`, `self._producer_process_in_variant`. +- Produces: descent edges producing `input_id` span its production-TD cohorts; the queued child uses the folded arrays. + +- [ ] **Step 1: Add a failing test — production TD on a node reached via the descent** + +Append to `tests/fixtures/background_prod_td_db_fixture.py`: + +```python +@pytest.fixture +@bw2test +def background_prod_td_deep_db(): + """fu -> bg_A -> bg_B -> bg_C -> CO2, two variants. + + bg_A->bg_B carries a technosphere TD (starts the descent); bg_C (reached + one level deeper, inside the locked-variant descent) carries the + production-edge TD. Exercises the descent emit site rather than the + first-level split. Total impact must equal 1.0. + """ + biosphere = bd.Database("biosphere") + biosphere.write( + {("biosphere", "CO2"): {"type": "emission", "name": "carbon dioxide"}} + ) + co2 = biosphere.get("CO2") + + foreground = bd.Database("foreground") + foreground.register() + bg20 = bd.Database("background_2020") + bg20.register() + bg30 = bd.Database("background_2030") + bg30.register() + + fu = foreground.new_node("fu", name="fu", unit="unit") + fu["reference product"] = "fu" + fu.save() + fu.new_edge(input=fu, amount=1, type="production").save() + + td_a_to_b = TemporalDistribution( + date=np.array([0, 10], dtype="timedelta64[Y]"), + amount=np.array([0.6, 0.4]), + ) + prod_td_c = TemporalDistribution( + date=np.array([0, 3, 6], dtype="timedelta64[Y]"), + amount=np.array([0.5, 0.3, 0.2]), + ) + + variants = {} + for db in (bg20, bg30): + bg_a = db.new_node("bg_A", name="bg_A", unit="k"); bg_a["reference product"] = "bg_A"; bg_a.save() + bg_b = db.new_node("bg_B", name="bg_B", unit="k"); bg_b["reference product"] = "bg_B"; bg_b.save() + bg_c = db.new_node("bg_C", name="bg_C", unit="k"); bg_c["reference product"] = "bg_C"; bg_c.save() + + bg_a.new_edge(input=bg_a, amount=1, type="production").save() + bg_b.new_edge(input=bg_b, amount=1, type="production").save() + pc = bg_c.new_edge(input=bg_c, amount=1, type="production") + pc["temporal_distribution"] = prod_td_c + pc.save() + + e = bg_a.new_edge(input=bg_b, amount=1, type="technosphere") + e["temporal_distribution"] = td_a_to_b + e.save() + bg_b.new_edge(input=bg_c, amount=1, type="technosphere").save() + bg_c.new_edge(input=co2, amount=1, type="biosphere").save() + variants[db.name] = {"bg_A": bg_a, "bg_B": bg_b, "bg_C": bg_c} + + fu.new_edge(input=variants["background_2020"]["bg_A"], amount=1, type="technosphere").save() + + bd.Method(("GWP", "example")).write([(("biosphere", "CO2"), 1)]) + for dbn in bd.databases: + bd.Database(dbn).process() + return variants +``` + +Register it in `tests/conftest.py`: + +```python +from .fixtures.background_prod_td_db_fixture import ( + background_prod_td_db, + background_prod_td_deep_db, +) +``` + +Add to `tests/test_background_production_td.py`: + +```python +@pytest.mark.parametrize("graph_traversal", ["priority", "bfs"]) +def test_deep_production_td_conserves(background_prod_td_deep_db, graph_traversal): + t = TimexLCA({("foreground", "fu"): 1}, METHOD, DATABASE_DATES) + t.build_timeline( + starting_datetime="2020-01-01", + temporal_grouping="year", + graph_traversal=graph_traversal, + traverse_background=True, + cutoff=1e-9, + max_calc=2000, + ) + t.lci() + t.static_lcia() + assert t.static_score == pytest.approx(t.base_lca.score, rel=1e-6) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_background_production_td.py::test_deep_production_td_conserves -q --no-cov` +Expected: FAIL — score `3.0` (or another N×) vs `1.0`. bg_C's production TD is applied at the descent site, not yet fixed. + +- [ ] **Step 3: Fix the descent emit + child block** + +In `_descend_variant_subtree`, the inner `for input_id in input_ids:` loop currently computes `distribution` and `abs_td_producer` (lines ~496-499), resolves `producer_process` (~507), emits the `Edge` (~520-533), then applies the production TD only to the child (~538-546). Replace the emit-and-child region so the production TD is folded into the emitted edge. + +Replace the block starting at `producer_process = self._producer_process_in_variant(` (line ~507) through the `queue.append((...))` at the end of the child block (line ~549) with: + +```python + producer_process = self._producer_process_in_variant( + input_id, variant_db + ) + will_descend = ( + not leaf + and new_supply >= self.cutoff * total_demand + and producer_process is not None + ) + + # Already routed to its real variant database -> temporalize it. + if self._is_static_background(input_id): + self.variant_resolved_producers.add(input_id) + + # Fold this producer's own production-edge TD into the producer + # side so it is registered at the same production-TD-weighted + # cohorts it is consumed at (bands match -> no KeyError; weights + # = exchange x production -> conserves). Fold identically into + # td_producer/abs_td_producer/distribution to keep them aligned. + producer_production_td = None + if producer_process is not None: + producer_production_td = ( + self._normalized_production_edge_td_from_proxy(producer_process) + ) + if producer_production_td is not None: + td_producer = self._fold_production_td( + td_producer, producer_production_td + ) + abs_td_producer = self._fold_production_td( + abs_td_producer, producer_production_td + ) + distribution = self._fold_production_td( + distribution, producer_production_td + ) + + edges.append( + Edge( + edge_type=edge_type, + distribution=distribution, + leaf=leaf, + consumer=cur_id, + producer=input_id, + td_producer=td_producer, + td_consumer=cur_parent, + abs_td_producer=abs_td_producer, + abs_td_consumer=cur_abs_td, + temporal_evolution=temporal_evolution, + ) + ) + + if not will_descend: + continue + + queue.append( + (producer_process, distribution, td_producer, abs_td_producer, new_supply) + ) +``` + +Note: this removes the old separate `child_td/child_abs_td` re-application (the folded `distribution`/`abs_td_producer` are now queued directly). The `leaf`, `td_producer_raw`, `edge_supply`, and `new_supply` computations earlier in the loop are unchanged (supply tracking still uses the raw exchange amount). If `producer_process is None` (pure leaf/product with no producer), `producer_production_td` stays `None` and behaviour is unchanged. + +- [ ] **Step 4: Run the deep test + first-level test** + +Run: `.venv/bin/python -m pytest tests/test_background_production_td.py -q --no-cov` +Expected: PASS for all four cases (first-level + deep, each `priority`/`bfs`), score `== 1.0`. + +- [ ] **Step 5: Run the full existing suite** + +Run: `.venv/bin/python -m pytest tests/ --ignore=tests/test_repro_variant_mismatch.py -q --no-cov` +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add bw_timex/edge_extractor.py tests/fixtures/background_prod_td_db_fixture.py tests/conftest.py tests/test_background_production_td.py +git commit -m "fix: fold production-edge TD into background descent edges" +``` + +--- + +### Task 5: Convergent + production-TD conservation + +**Files:** +- Modify: `tests/fixtures/background_prod_td_db_fixture.py` (add convergent fixture) +- Modify: `tests/conftest.py` +- Test: `tests/test_background_production_td.py` + +**Interfaces:** +- Produces: fixture `background_prod_td_convergent_db` — a background node with a production-edge TD reached via two parents. + +- [ ] **Step 1: Add the convergent fixture** + +Append to `tests/fixtures/background_prod_td_db_fixture.py`: + +```python +@pytest.fixture +@bw2test +def background_prod_td_convergent_db(): + """fu -> bg_A -> {bg_S, bg_R -> bg_S}; bg_S -> CO2, two variants. + + bg_S (production-edge TD) is reached both directly from bg_A and via bg_R, + so it appears at multiple cohorts through two paths. Total impact = 2.0 + (bg_A demands bg_S once directly and once through bg_R, coefficients 1). + """ + biosphere = bd.Database("biosphere") + biosphere.write( + {("biosphere", "CO2"): {"type": "emission", "name": "carbon dioxide"}} + ) + co2 = biosphere.get("CO2") + + foreground = bd.Database("foreground") + foreground.register() + bg20 = bd.Database("background_2020") + bg20.register() + bg30 = bd.Database("background_2030") + bg30.register() + + fu = foreground.new_node("fu", name="fu", unit="unit") + fu["reference product"] = "fu" + fu.save() + fu.new_edge(input=fu, amount=1, type="production").save() + + td = TemporalDistribution( + date=np.array([0, 8], dtype="timedelta64[Y]"), + amount=np.array([0.7, 0.3]), + ) + prod_td_s = TemporalDistribution( + date=np.array([0, 4], dtype="timedelta64[Y]"), + amount=np.array([0.6, 0.4]), + ) + + variants = {} + for db in (bg20, bg30): + bg_a = db.new_node("bg_A", name="bg_A", unit="k"); bg_a["reference product"] = "bg_A"; bg_a.save() + bg_r = db.new_node("bg_R", name="bg_R", unit="k"); bg_r["reference product"] = "bg_R"; bg_r.save() + bg_s = db.new_node("bg_S", name="bg_S", unit="k"); bg_s["reference product"] = "bg_S"; bg_s.save() + + bg_a.new_edge(input=bg_a, amount=1, type="production").save() + bg_r.new_edge(input=bg_r, amount=1, type="production").save() + ps = bg_s.new_edge(input=bg_s, amount=1, type="production") + ps["temporal_distribution"] = prod_td_s + ps.save() + + e1 = bg_a.new_edge(input=bg_s, amount=1, type="technosphere") + e1["temporal_distribution"] = td + e1.save() + e2 = bg_a.new_edge(input=bg_r, amount=1, type="technosphere") + e2["temporal_distribution"] = td + e2.save() + bg_r.new_edge(input=bg_s, amount=1, type="technosphere").save() + bg_s.new_edge(input=co2, amount=1, type="biosphere").save() + variants[db.name] = {"bg_A": bg_a, "bg_R": bg_r, "bg_S": bg_s} + + fu.new_edge(input=variants["background_2020"]["bg_A"], amount=1, type="technosphere").save() + + bd.Method(("GWP", "example")).write([(("biosphere", "CO2"), 1)]) + for dbn in bd.databases: + bd.Database(dbn).process() + return variants +``` + +Register in `tests/conftest.py`: + +```python +from .fixtures.background_prod_td_db_fixture import ( + background_prod_td_db, + background_prod_td_deep_db, + background_prod_td_convergent_db, +) +``` + +- [ ] **Step 2: Add the test** + +Add to `tests/test_background_production_td.py`: + +```python +@pytest.mark.parametrize("graph_traversal", ["priority", "bfs"]) +def test_convergent_production_td_conserves( + background_prod_td_convergent_db, graph_traversal +): + t = TimexLCA({("foreground", "fu"): 1}, METHOD, DATABASE_DATES) + t.build_timeline( + starting_datetime="2020-01-01", + temporal_grouping="year", + graph_traversal=graph_traversal, + traverse_background=True, + cutoff=1e-9, + max_calc=5000, + ) + t.lci() + t.static_lcia() + assert t.static_score == pytest.approx(t.base_lca.score, rel=1e-6) +``` + +- [ ] **Step 3: Run it** + +Run: `.venv/bin/python -m pytest tests/test_background_production_td.py::test_convergent_production_td_conserves -q --no-cov` +Expected: PASS both engines (score `== 2.0`). If it fails, STOP — the convergent case exposes a residual bug; return to systematic-debugging before proceeding. + +- [ ] **Step 4: Commit** + +```bash +git add tests/fixtures/background_prod_td_db_fixture.py tests/conftest.py tests/test_background_production_td.py +git commit -m "test: convergent background production-edge TD conserves" +``` + +--- + +### Task 6: Cleanup and full verification + +**Files:** +- Delete: `tests/test_repro_variant_mismatch.py` +- Verify: whole suite + +- [ ] **Step 1: Delete the throwaway repro module** + +```bash +git rm tests/test_repro_variant_mismatch.py +``` + +- [ ] **Step 2: Full suite green** + +Run: `.venv/bin/python -m pytest tests/ -q --no-cov` +Expected: all pass, no errors/warnings beyond the pre-existing deprecation noise. + +- [ ] **Step 3: Confirm the snap fallback is still present (defense-in-depth)** + +Run: `grep -n "_nearest_time_mapping_key" bw_timex/timeline_builder.py` +Expected: the helper and its call in `get_time_mapping_key` are present (added earlier this session). Do not remove. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "chore: remove throwaway repro after production-TD fix lands" +``` + +--- + +### Task 7: Premise integration smoke check (manual, not in CI suite) + +**Files:** none (manual verification against the `ei312_REMIND_EU` premise project, which is not available in CI). + +- [ ] **Step 1: Run the premise diesel case** + +With the `ei312_REMIND_EU` project populated (premise `dp312_SSP2_NDC_*` dbs + `add_premise_temporal_distributions(BG_DBS)`), build a foreground referencing a premise diesel transport activity and run: + +```python +t.build_timeline(starting_datetime="2050-01-01", temporal_grouping="year", + graph_traversal="bfs", traverse_background=True, + cutoff=1e-3, max_calc=2000) +t.lci(); t.static_lcia() +``` + +Expected: completes without `KeyError`/`NonsquareTechnosphere`; `static_score` is finite and stable across a re-run. (Premise backgrounds carry no production-edge TDs, so this primarily confirms the snap still guards the separate dual-path facet and nothing regressed. If it raises `Found N exchanges` in `_get_exchange`, that is an unrelated pre-existing limitation, not this fix.) + +- [ ] **Step 2: Record the observed score** in the PR description; no commit. + +--- + +## Notes for the implementer + +- After each `edge_extractor.py` edit, if the conservation test still shows N× or a new `KeyError` appears, inspect the timeline directly: build with `cutoff=1e-9`, print `t.timeline[["producer_name","date_producer","consumer_name","date_consumer","amount"]]`, and check that each production-TD producer's `date_producer` set equals its `date_consumer` set and that per-cohort amounts carry the production-TD weights (e.g. `0.6*0.5, 0.6*0.3, 0.6*0.2`). +- Do not `.simplify()` the folded `td_producer`/`abs_td_producer`/`distribution` at the emit sites — simplify can merge entries and break the index alignment `extract_edge_data` relies on. +- The premise dual-path `KeyError` is explicitly out of scope; the snap fallback handles it for now. +``` diff --git a/docs/superpowers/specs/2026-06-23-adjoint-traversal-scoring-design.md b/docs/superpowers/specs/2026-06-23-adjoint-traversal-scoring-design.md new file mode 100644 index 00000000..e8a7062b --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-adjoint-traversal-scoring-design.md @@ -0,0 +1,163 @@ +# Adjoint Static-Score Intensities for Traversal Scoring (P1) + +**Date:** 2026-06-23 +**Status:** Design — approved, pending spec review +**Scope:** First spec of a multi-cycle effort to bring `trails` performance/UX learnings +into `bw_timex` while keeping `bw_timex`'s existing logic intact. + +## Background + +`bw_timex` builds a time-explicit LCI by (1) a graph traversal that extracts a +timeline of temporally-distributed edges, then (2) expanding/solving matrices. +The **graph traversal is the dominant cost**, not the solve. + +The priority traversal path is: + +``` +TimexLCA.build_timeline() + -> TimelineBuilder -> EdgeExtractor (bw_timex) + inherits bw_temporalis.TemporalisLCA + uses bw_graph_tools.NewNodeEachVisitGraphTraversal +``` + +`NewNodeEachVisitGraphTraversal` scores each visited node with a **per-node-visit +linear solve** (a `CachingSolver`: the technosphere is factorized once, but a +back-substitution runs for every node visit; "new node each visit" means the same +activity reached via N paths is solved N times). For deep/wide supply chains this +per-visit solve count dominates traversal wall time. The heap priority and the +cutoff both derive from these solved `cumulative_score` values +(`edge_extractor.py` pushes `1 / node.cumulative_score`). + +`trails` avoids this entirely. Its `StaticActivityScores._compute_static_activity_scores` +solves the **adjoint** system `A.T x = B.T c` — one sparse solve per LCIA method — +yielding the static score intensity for **every** activity at once. Routing then +prunes/orders branches with a pure lookup (`|intensity[act]| * demand`), doing +**zero linear solves during traversal**. + +## Goal + +Eliminate the per-node-visit linear solve in `bw_timex`'s priority traversal by +replacing the **source** of node scores with a precomputed adjoint intensity +vector. The traversal structure — priority heap, `cutoff`, `max_calc`, temporal +convolution, variant/background descent — is unchanged. Only how a node's score +is obtained changes: from "solve a linear system" to "look up `intensity[act]`". + +Non-goals (deferred to later specs): relative score-potential cutoff (P2), +persistent disk cache (P3), public-API/UX changes, premise Frictionless +datapackage ingestion. + +## Core math + +For demand `d`: supply `s = A^-1 d`, inventory `g = B s`, score `= h^T g` where +`h` is the characterization (CF) vector for the method. + +Define the adjoint vector `λ` by `A^T λ = B^T h`. Then `score = λ^T d`, and +`λ[a]` is the **static downstream score per unit of activity `a`'s reference +product**. This is exactly the quantity the priority heap needs for ordering and +the cutoff needs for pruning. One sparse solve per method computes `λ` for all +activities. + +`base_lca` (already built in `TimexLCA.__init__`, includes the full background) +provides `A` (`technosphere_matrix`) and `B` (`biosphere_matrix`); the method +provides `h` (from `characterization_matrix` / CF data). + +## Components + +### 1. `StaticScoreIntensities` (new, isolated unit) + +Mirrors `trails.static_activity_scores.StaticActivityScores`, adapted to +`bw_timex`/`bw2calc` index spaces. + +- **Input:** a built `bw2calc.LCA` (the existing `base_lca`) and the method. +- **Compute:** `h` from the method's CFs; `λ = spsolve(A.T, B.T @ h)`. +- **Expose:** + - `intensity[activity_matrix_index] -> float` (signed, retained for diagnostics) + - an absolute-valued array for pruning/ordering (precomputed once; + `nan_to_num` + `abs`, per the trails optimization for hot lookups) + - a mapping helper from `bw_graph_tools`/`TemporalisLCA` node identity to the + activity index used by `λ` (the index-space bridge is the main correctness + detail — see Risks). +- **Properties:** pure and deterministic; no traversal state. Independently + unit-testable: for any single-activity demand `d = e_a`, `λ[a]` must equal the + full static LCA score of that demand to numerical tolerance. + +### 2. Integration seam — Approach A (chosen): inject adjoint scoring into the priority engine + +Provide a custom scoring object to `bw_graph_tools`'s traversal so that a node's +`cumulative_score` is computed as `λ[act] * supply_amount` (a lookup), instead of +a back-substitution. The seam: + +- `TemporalisLCA` accepts a `graph_traversal` subclass; `NewNodeEachVisitGraphTraversal` + uses a `caching_solver` and calls `set_score_row(...)`. We subclass/replace the + solver (or the traversal's scoring step) so `scores(...)` returns adjoint-based + potentials with **no per-node solve**. +- `EdgeExtractor` (bw_timex) wires the `StaticScoreIntensities` into this custom + solver/subclass at construction. Everything downstream in + `build_edge_timeline` (heap, `1 / node.cumulative_score`, cutoff vs + `cutoff_score`, convolution, variant descent) is untouched. +- Gated/opt-in initially: a flag on `build_timeline` (e.g. + `graph_traversal="priority"` keeps current behavior; a new value or a boolean + selects adjoint scoring) so the old path remains available for comparison and + fallback. Default-switch decision deferred until the validation gate passes. + +`cumulative_score` semantics shift from "per-visit solved subtree score" to +"static adjoint intensity × supply amount". For ordering and cutoff this is the +correct potential and is what `trails` uses; the guardrails below ensure pruning +stays conservative. + +### 3. Validation harness (acceptance gate) + +Because upfront profiling was intentionally skipped, the design carries its own +measurement gate (a script/notebook, not a shipped feature): + +- Run an existing real example model (from `notebooks/`) both ways: current + priority engine vs P1 adjoint scoring. +- Record: wall time, node-visit / solve count, final scores, and the timeline + DataFrame. +- Pass criteria are the Correctness guardrails plus a demonstrated reduction in + traversal time / solve count. + +## Correctness guardrails (numeric-tolerance gate, as chosen) + +1. **Score equivalence:** `λ[a] · d` matches the full static LCA score for + single-activity demands within a small `rtol` (e.g. `1e-9`), and the + end-to-end time-explicit scores from a P1 run match the current priority-engine + run within a small `rtol` on the example models. +2. **Conservative pruning:** with identical `cutoff` / `max_calc`, P1 must not + silently drop a branch that the current engine retains above the cutoff. If + adjoint potential and per-visit score diverge, prefer the more inclusive + decision (do not under-explore). Documented and tested. +3. **Timeline consistency:** the resulting timeline (edges, amounts, dates) + matches the current engine within tolerance on the example models when + `cutoff` / `max_calc` are unchanged (numeric tolerance, not byte-equality). + +## Risks / open details + +- **Index-space bridge.** `λ` is indexed by technosphere matrix columns; + `bw_graph_tools`/`TemporalisLCA` nodes carry their own ids. The mapping between + node identity and the `λ` activity index is the primary correctness-sensitive + piece and must be unit-tested directly. (`edge_extractor.py` already navigates + `lca.dicts.product.reversed` and `activity_datapackage_id`; reuse those.) +- **`bw_graph_tools` coupling.** Approach A subclasses library internals + (`caching_solver` / scoring). Pin behavior with tests; keep the override + surface minimal and the old path selectable as fallback. +- **Sign / substitution edges.** `λ` must follow the same sign conventions the + traversal already applies (production vs technosphere vs substitution). Verify + against `adjust_sign_of_amount_based_on_edge_type` semantics. +- **Multiple methods.** Initial scope targets the single configured + `TimexLCA.method`; one adjoint solve. Multi-method potential is a later concern. + +## Deliverables + +1. `StaticScoreIntensities` unit + tests (math/equivalence, index bridge, signs). +2. Custom adjoint-scoring solver/subclass wired into `EdgeExtractor`, behind an + opt-in flag on `build_timeline`. +3. Validation harness + recorded before/after results on an example model. +4. Tests asserting the three correctness guardrails on example models. + +## Follow-on specs (not this cycle) + +- P2: relative score-potential cutoff (adaptive routing) built on these intensities. +- P3: persistent, fingerprinted disk cache for the intensities. +- UX: curated public API, adaptive-by-default routing, routed-graph Sankey. +- premise: Frictionless datapackage ingestion adapter. diff --git a/docs/superpowers/specs/2026-06-24-persistent-cache-design.md b/docs/superpowers/specs/2026-06-24-persistent-cache-design.md new file mode 100644 index 00000000..4cf2440d --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-persistent-cache-design.md @@ -0,0 +1,180 @@ +# Persistent Disk Cache for Solve Results (P3) — Design + +**Date:** 2026-06-24 +**Status:** Design — approved, pending spec review +**Branch:** `feat/persistent-cache` (off `feat/adjoint-traversal-scoring`) +**Depends on:** P1 (adjoint static-score intensities — `bw_timex/adjoint_scoring.py`) +**Scope:** Second cycle of the trails-learnings effort. Persist the two +*expensive, stable, serializable* solve-result caches across Python sessions. + +## Background + +`bw_timex` currently caches only in-session, via module-level dicts in +`bw_timex/_lci_cache.py`, keyed with bw2data `modified` tokens so background +edits invalidate stale entries. Nothing survives a process restart, so every +new session repays the background linear solves. `trails` persists comparable +score/LCI data to a `platformdirs` user cache and reuses it across runs. + +Not every cache benefits from persistence — persistence pays only when an item +is expensive to recompute, stable across sessions, and cleanly serializable; +for cheap or per-run items the serialize + I/O + deserialize round-trip is +often slower than recomputing and adds correctness/versioning risk. Triage of +the existing caches: + +| Cache | Persist? | Rationale | +|---|---|---| +| **λ adjoint intensities** (P1) | yes | one linear solve to compute; tiny 1-D array; modified-token stable | +| **`BACKGROUND_UNIT_LCI_CACHE`** | yes | the expensive background `redo_lci` solves; stable per `(db, code, modified)`; numpy-serializable triplets. Largest cross-session win | +| `BIOSPHERE_EXCHANGES_CACHE` | no | cheap DB reads; benefit too small to justify the risk | +| `LCI_SOLVE_CACHE` | no | keyed per scenario (demand + timeline) → low cross-session hit rate; inventory arrays are large | +| `NODES_CACHE` | no | live bw2data `Activity` proxy objects; fragile to pickle, cheap to rebuild | + +This spec persists exactly the two `yes` rows. + +## Goal + +When adjoint scoring / background LCI runs, transparently reuse λ and +background unit LCI results from disk across sessions, keyed and invalidated by +bw2data `modified` tokens. On by default, with an off switch and a clear +helper. Results must be identical to the no-cache path. + +Non-goals: persisting the other three caches; content-hash keying; premise +Frictionless ingestion (next spec). + +## Existing shapes (verified) + +- `BACKGROUND_UNIT_LCI_CACHE` values are structure-independent triplets + `(bioflow_ids: np.int64[], activity_ids: np.int64[], values: np.float64[])` + (`dynamic_biosphere_builder._inventory_to_triplets`). The dict only ever + holds stable keys of the form `("db_code", db, code, modified)`; non-stable + identities route to a separate per-object `_instance_unit_lci_cache`, so a + persistence wrapper on this dict never sees an unpersistable key. +- λ is `AdjointCachingSolver.lambda_vector` (1-D `np.float64`), computed in + `set_score_row` from the `base_lca` matrices. The solver has no bw2data + identity context, so its persistent key is constructed one level up + (`TimexLCA`), which knows the method and the involved databases. + +## Architecture + +New module `bw_timex/persistent_cache.py`: disk I/O, keying, and atomic writes +only — no domain logic. Two consumers wire to it. + +Cache root (via `platformdirs.user_cache_path`): +`/bw_timex/v1/{background_unit_lci,adjoint_intensities}/`. +The `v1` path segment is the format version; bumping it invalidates all prior +entries instantly. A module function `cache_root() -> Path` resolves it and is +overridable for tests via an environment variable +`BW_TIMEX_CACHE_DIR` (when set, used verbatim as the root). + +## Components + +### 1. `PersistentDict(collections.abc.MutableMapping)` + +Wraps an in-memory `dict` plus a disk directory. Semantics: + +- `__contains__(key)`: true if in memory, else true if the key's file exists. +- `__getitem__(key)`: return from memory; on memory miss, load the npz from + disk, populate memory, return; on disk miss raise `KeyError`; on any + load/parse error treat as miss (`KeyError`) and best-effort delete the bad + file. +- `__setitem__(key, value)`: store in memory and write-through to disk via an + atomic temp-file + `os.replace`. +- `__delitem__`, `__iter__`, `__len__`: memory-backed (disk iteration is not + required by consumers; documented). + +Key → filename: a stable hash (blake2b hex) of the key tuple's repr. Value +serialization: the three numpy arrays via `np.savez`. This is a drop-in for +`BACKGROUND_UNIT_LCI_CACHE`, so the builder's existing +`if cache_key not in cache: … cache[cache_key] = …` logic is unchanged. + +### 2. λ persistence hook + +`AdjointScoringGraphTraversal.__init__` gains an optional `lambda_cache` +parameter exposing `load(key) -> np.ndarray | None` and `save(key, array)`. +In the solver's `set_score_row`: + +- if a cache + key are present and `load(key)` returns an array, assign it to + `lambda_vector` and **skip the adjoint solve** (so `solve_count` stays 0); +- otherwise solve as today and `save(key, lambda_vector)`. + +`TimexLCA` constructs the key +`("lambda", method_id, tuple(sorted((db, modified) for db in base_lca dbs)))` +and supplies a small `LambdaDiskCache` (backed by `persistent_cache`) plus the +key down through `build_timeline → TimelineBuilder → EdgeExtractor → +AdjointScoringGraphTraversal`. The solver stays db-agnostic; only the +controller knows identities. + +### 3. Wiring in `TimexLCA` + +- New constructor parameter `persistent_cache: bool = True`. When `True` (and + `use_global_lci_cache=True`), `_background_unit_lci_cache` is a + `PersistentDict` over the `background_unit_lci` dir backed by the existing + module dict; when `False`, behavior is exactly as today (memory-only). +- The λ key + `LambdaDiskCache` are threaded only when both + `persistent_cache=True` and `adjoint_scoring=True` at `build_timeline`. + +### 4. Clearing + +`clear_persistent_cache() -> None` removes the on-disk `bw_timex/v1` tree. +`clear_background_lci_cache()` (existing) is extended to also call it, so one +call clears both memory and disk; `clear_persistent_cache` is additionally +exported for disk-only clears. Both are exported from `bw_timex/__init__.py`. + +## Keying / invalidation + +modified-token + method. Background entries already embed `modified` in the +key. The λ key folds every involved database's `modified` token plus the method +id. Editing a database via bw2data bumps `modified`, so prior entries simply +never match again (they are left on disk, unused, until `clear_*`). This +mirrors the accepted limitation of the in-session caches: edits that bypass +bw2data (raw SQL) do not bump `modified` and are not detected. + +## Error handling + +The cache is never load-bearing. Any corrupt, unreadable, or wrong-version +file is treated as a miss: recompute, then overwrite. Writes are atomic +(temp file in the same dir + `os.replace`) so concurrent processes never read a +torn file. No cache operation raises out of the cache layer; failures degrade +to recompute. A failed disk write is swallowed (logged at debug) — the +in-memory value still stands for the session. + +## Dependencies + +Promote `platformdirs` from a transitive to a direct dependency in +`pyproject.toml` (already resolved in `uv.lock`). + +## Testing + +All tests set `BW_TIMEX_CACHE_DIR` to a pytest `tmp_path` so the real user +cache is never touched. + +- **`PersistentDict`** (unit): round-trips triplet values; a second instance + over the same dir reads what the first wrote (cross-session proxy); missing + key raises `KeyError`; a deliberately corrupted file is treated as a miss and + removed, not raised; `__setitem__` leaves no `.tmp` partial behind. +- **λ hook** (unit): with a populated `LambdaDiskCache`, `set_score_row` skips + the solve (`solve_count == 0`) and uses the stored vector; with an empty + cache it solves once (`solve_count == 1`) and writes; a changed + `modified`-token key misses and recomputes. +- **End-to-end** (fixtures, reuse `temporal_grouping_db_monthly` and + `background_td_deep_chain_db`): score + timeline identical between a cold-cache + run and a warm-cache run (exact); `persistent_cache=False` performs zero disk + I/O (assert the cache dir stays empty); `clear_persistent_cache()` empties the + dir; a second `TimexLCA` in the same session/dir reuses background unit LCI + from disk. +- Full suite passes; no new warnings. + +## Deliverables + +1. `bw_timex/persistent_cache.py`: `cache_root()`, `PersistentDict`, + `LambdaDiskCache`, `clear_persistent_cache()`, atomic-write + npz helpers. +2. λ `lambda_cache` hook in `AdjointScoringGraphTraversal` / `AdjointCachingSolver`. +3. `TimexLCA` wiring: `persistent_cache` param, λ key construction, background + dict swap. +4. `clear_background_lci_cache()` extension + exports. +5. `platformdirs` as a direct dependency. +6. Tests above. + +## Follow-on (not this cycle) + +premise Frictionless datapackage ingestion adapter (next spec). diff --git a/docs/superpowers/specs/2026-06-24-premise-temporal-annotation-design.md b/docs/superpowers/specs/2026-06-24-premise-temporal-annotation-design.md new file mode 100644 index 00000000..ca9ef194 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-premise-temporal-annotation-design.md @@ -0,0 +1,183 @@ +# premise Temporal-Distribution Annotation — Design + +**Date:** 2026-06-24 +**Status:** Design — approved, pending spec review +**Branch:** `feat/premise-temporal` (off `main`) +**Scope:** Independent feature in the trails-learnings roadmap. Annotate +pre-existing premise-generated, year-specific bw2 databases with temporal +distributions, so `bw_timex` can run time-explicit LCA on a premise background +without the user hand-defining temporal data. + +## Background + +`bw_timex` reads temporal distributions off exchanges as +`bw_temporalis.TemporalDistribution` objects stored under +`exchange["temporal_distribution"]` (see +`bw_timex.utils.add_temporal_distribution_to_exchange`). Its +`traverse_background=True` path then honours temporal distributions defined on +background-database exchanges. + +`premise` (the trails work, shipping in `premise >= 2.5.0`) curates background +temporal data in `premise/data/trails/temporal_distributions.csv` +(~9.7k rows), keyed by `(name, reference product)` plus ISIC/CPC +classification, each row carrying a `temporal_tag`, an age-distribution `type`, +`loc/scale/offsets/weights/min/max`, and `lifetime`. premise's +`TrailsDataPackage` loads that CSV into categorized buckets +(`_load_temporal_specs_from_csv`) and, in `add_temporal_distributions`, places +the temporal parameters on the correct exchanges using a fixed set of rules. + +This feature reuses premise's curated data and its placement rules to annotate +the user's existing dated bw2 databases directly. It does **not** materialize, +unfold, or otherwise build databases — the user already has them (e.g. one +ecoinvent+premise database per scenario year, registered in their bw2 project). + +## Goal + +A function `add_premise_temporal_distributions(databases)` that, for each named +existing premise database, finds the exchanges premise would tag and writes the +corresponding `bw_temporalis.TemporalDistribution` onto them, returning a +summary plus a faulty/unmatched report. Idempotent; reuses premise's CSV loader +and placement rules. + +Non-goals: building/unfolding databases, biosphere linking, constructing +`database_dates`, and any non-premise temporal source. + +## premise placement rules (the behaviour we mirror) + +From `premise/trails.py` `add_temporal_distributions` (verified against the +`trails_temporal_distributions_update` branch): + +- **biomass_growth** — for a dataset whose `(name, reference product)` is in + `biomass_growth_params`, set the temporal params on that dataset's + **biosphere** exchange named exactly `"Carbon dioxide, in air"`. +- **stock_asset** — for a **technosphere** exchange whose **supplier** + `(name, product)` is in `stock_asset_params`, set the supplier's params on + that exchange. +- **maintenance** — for a technosphere exchange whose supplier `(name, product)` + is in `maintenance_suppliers`, set a uniform distribution (premise code `4`) + over `[0, lifetime]`, where `lifetime` is the **calling dataset's** lifetime + from `dataset_lifetimes`. +- **end_of_life** — for a technosphere exchange whose supplier `(name, product)` + is in `end_of_life_suppliers`, set a one-pulse distribution (premise code `6`) + at the calling dataset's `lifetime`. +- **Ambiguity** — if a supplier matches more than one of + stock_asset/maintenance/end_of_life, record a fault and skip. +- **Missing data** — technosphere exchange without a supplier product, or a + maintenance/end_of_life match without a dataset lifetime, records a fault and + skips. + +premise temporal codes used: `1` discrete (mass at `loc`), `3` normal, `4` +uniform (`[min,max]`), `5` triangular, `6` discrete empirical (explicit +`offsets`/`weights`). All time values are in **years**. + +## Architecture + +New module `bw_timex/premise_temporal.py` (single responsibility: premise → +bw_timex temporal annotation). No changes to bw_timex's core engine. `premise` +is an **optional dependency** declared as the `premise` extra +(`pip install bw-timex[premise]`); the module imports premise lazily and raises +a clear, actionable error if it is missing or older than 2.5.0. + +## Components + +### 1. `load_temporal_specs() -> TemporalSpecs` + +Reuses premise's own loader so the parsing/categorization (the part most likely +to evolve) stays in premise. Returns a small dataclass `TemporalSpecs` holding +the five premise buckets: `biomass_growth_params`, `stock_asset_params` +(both `dict[(name, ref), params]`), `maintenance_suppliers`, +`end_of_life_suppliers` (both `set[(name, ref)]`), and `dataset_lifetimes` +(`dict[(name, ref), float]`). + +Implementation: call premise's +`TrailsDataPackage._load_temporal_specs_from_csv` against the CSV bundled in the +installed premise package (`premise.trails.FILEPATH_TEMPORAL_PARAMETERS`). If +premise exposes these only as instance methods, instantiate the minimal object +needed or call the underlying static parsing; the CSV path constant is public +enough to locate the file. A thin adapter isolates this coupling so a premise +API change touches one function. + +### 2. `premise_params_to_td(params, *, lifetime=None) -> TemporalDistribution` + +Pure converter from premise's `(code, loc, scale, min, max, offsets, weights)` +(+ optional `lifetime` for the maintenance/end_of_life synthetic forms) to a +`bw_temporalis.TemporalDistribution`, time unit years: +- code 3 normal → `easy_timedelta_distribution(..., kind="normal", loc, scale)` +- code 4 uniform → `easy_timedelta_distribution(..., kind="uniform", min, max)` + (maintenance uses `min=0, max=lifetime`) +- code 5 triangular → `easy_timedelta_distribution(..., kind="triangular", ...)` +- code 1 discrete → single pulse at `loc` +- code 6 discrete empirical → explicit `offsets`/`weights` arrays + (end_of_life uses a single pulse at `lifetime`) +Returns a `TemporalDistribution` with `date` as `timedelta64[Y]`. Independently +unit-testable with no bw2data. + +### 3. `annotate_database(db_name, specs, *, overwrite=False) -> AnnotationReport` + +Iterates the activities and exchanges of the existing bw2 database `db_name`, +applies the premise placement rules above, converts matched params via +`premise_params_to_td`, and writes the TD with the existing +`exchange["temporal_distribution"] = td; exchange.save()` path. Skips an +exchange that already has a temporal distribution unless `overwrite=True`. +Collects counts and a list of faulty/unmatched exchanges into an +`AnnotationReport`. + +### 4. `add_premise_temporal_distributions(databases, *, overwrite=False) -> AnnotationReport` + +Public entry point. `databases` is an iterable of database names (or a mapping +whose keys are database names — values, e.g. years, are ignored here). Loads +specs once, annotates each database, aggregates the reports. Exported from +`bw_timex/__init__.py`. + +## Error handling + +- premise missing or `< 2.5.0`: raise `ImportError`/`RuntimeError` with + "install bw-timex[premise] (needs premise >= 2.5.0)". +- A named database not present in the project: raise a clear `KeyError`-style + error naming the database before any writes. +- Unmatched rows / ambiguous tags / missing lifetimes: recorded in the report + (mirroring premise's `temporal_distribution_faulty_exchanges` behaviour), not + fatal. +- Annotation never raises out of a single bad exchange; it records and + continues. + +## Drift guard + +The CSV parsing/categorization is reused from premise (not copied). The +placement loop is a faithful port of premise's `add_temporal_distributions` +rules; a reference test compares this module's placement decisions to premise's +own output on a small synthetic dataset so the port cannot silently drift. + +## Testing + +- `premise_params_to_td` (unit, no bw2data): each premise code → expected TD + shape (normal/uniform/triangular/discrete/empirical), years resolution, + maintenance `[0,lifetime]`, end_of_life pulse at lifetime. +- `annotate_database` (bw2 fixture): a synthetic database with + (a) a dataset carrying a `"Carbon dioxide, in air"` biosphere exchange whose + `(name,ref)` is in `biomass_growth_params`, + (b) a technosphere exchange whose supplier is a stock_asset, + (c) suppliers tagged maintenance and end_of_life with a dataset lifetime, + (d) an ambiguous supplier (two tags), + (e) an exchange already carrying a TD. + Assert TDs land only on the right exchanges with the right shapes; ambiguous + and missing-lifetime cases land in the report; idempotency (no overwrite by + default; overwrite when requested). +- Reference/drift test: build matching premise spec buckets and assert this + module tags the same exchanges premise's rules would. +- Error path: premise-missing import error message; unknown database error. +- Full suite passes; no new warnings. + +## Deliverables + +1. `bw_timex/premise_temporal.py`: `TemporalSpecs`, `AnnotationReport`, + `load_temporal_specs`, `premise_params_to_td`, `annotate_database`, + `add_premise_temporal_distributions`. +2. `premise` optional extra in `pyproject.toml`; lazy import + version guard. +3. Export `add_premise_temporal_distributions` from `bw_timex/__init__.py`. +4. Tests above. + +## Follow-on (not this cycle) + +Convenience helpers for building `database_dates` from premise database naming +conventions, if desired later. diff --git a/docs/superpowers/specs/2026-07-02-trails-vs-timex-diesel-car-comparison-design.md b/docs/superpowers/specs/2026-07-02-trails-vs-timex-diesel-car-comparison-design.md new file mode 100644 index 00000000..022db861 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-trails-vs-timex-diesel-car-comparison-design.md @@ -0,0 +1,139 @@ +# Design: trails vs. timex diesel-car score comparison notebook + +**Date:** 2026-07-02 +**Branch:** feat/premise-temporal +**Author:** Timo Diepers + +## Purpose + +Validate that `bw_timex` + `add_premise_temporal_distributions` reproduces the +temporal scores produced by **trails** on the *same* premise background, using +trails' own worked example (`examples/2.2. premise and imported lci example.ipynb`): +a diesel passenger car (`transport, passenger, car, diesel`) assessed at +reference year **2050**. + +The deliverable is a new notebook +`notebooks/example_premise_temporal_comparison_trails.ipynb` that runs both +engines end-to-end and compares their scores. + +## What actually differs between the two engines + +The premise background (fuel-market composition shifting diesel→biodiesel across +scenario years, electricity mix, etc.) is *identical* input to both engines, so +it is not a source of divergence: + +- **trails** reads year-varying background amounts straight from the premise + datapackage matrices (`temporal_amount_source=matrix`). +- **timex** interpolates the same premise background across `database_dates` + (2020 → 2030 → 2040 → 2050 → 2075 → 2100). + +The only genuine difference is **where the temporal distributions come from**: + +1. **Foreground TDs** on the diesel car's own exchanges — trails reads these from + the imported spreadsheet `lci-pass_cars.xlsx` (hand-authored): + - use-phase / wear / maintenance / road-maintenance / direct biosphere: + uniform ±8 y (stats_arrays code 4, `loc=0, min=-8, max=8`) + - road construction: uniform `loc=-20, min=-40, max=-1` + - `passenger car production, diesel`: triangular `loc=-8, min=-12, max=-1` + (code 5) — the manufacturing pulse before the use phase +2. **Background TDs** deeper in the supply chain — trails applies premise's + curated `temporal_distributions.csv` internally; timex applies the **same + file** via `add_premise_temporal_distributions`. This equivalence is the + thing the notebook is meant to demonstrate. + +Confirmed same source file: +`premise/data/trails/temporal_distributions.csv` (9658 rows) is what both +`add_premise_temporal_distributions` and trails consume. + +## Shared data + +Both engines must sit on the same premise scenario so score differences are +attributable to temporal handling, not to different backgrounds. + +- **Scenario:** model `remind-eu`, pathway `SSP2-NDC`, system model `cutoff`, + ecoinvent `3.12`, years `[2020, 2030, 2040, 2050, 2075, 2100]`. +- **timex side:** the existing bw project `ei312_REMIND_EU` already holds + `ei312_REMIND-EU_SSP2_NDC_{2020,2030,2040,2050,2075,2100}` plus source + `ecoinvent-3.12-cutoff` and `ecoinvent-3.12-biosphere`. +- **trails side:** generate a premise `TrailsDataPackage` with the parameters + above. **Requires `IAM_FILES_KEY`** (premise IAM decryption key) supplied by + the user via environment variable, and network access. Build cost ≈ hours for + 6 years. Note: the regenerated datapackage will match the existing bw dbs + *scenario-for-scenario*; if the premise version that built the bw dbs differs + from the installed `premise==2.3.7`, small numeric drift is possible and will + be reported, not hidden. + +## Method + +Primary: `('ecoinvent-3.12', 'IPCC 2021', 'climate change: total (excl. biogenic CO2)', 'global warming potential (GWP100)')` +— trails 2.2's excl-biogenic method (verified present in the project). +trails receives the equivalent dash-joined string with `ei_version="3.12"`. + +## timex model (foreground approach A) + +Rebuild the `lci-pass_cars.xlsx` diesel activity as a `foreground` bw database +activity so its **foreground TDs come from the sheet** (matching trails), while +the deeper background TDs come from `add_premise_temporal_distributions`: + +1. Delete/rebuild a `foreground` database. +2. Create activity `transport, passenger, car, diesel` (RER, unit km, + production amount 1). +3. Add each xlsx exchange, resolving its input to the matching background node in + `ei312_REMIND-EU_SSP2_NDC_2050` (technosphere) or the biosphere db + (biosphere), with the xlsx `amount`. + - Fuel rows (`diesel production…`, `esterification of rape oil`) and the two + CO2 rows use `temporal_amount_source=matrix` in the sheet → point the edge + at the background market and let timex interpolate the amount over dates. Do + **not** hard-code the year columns. +4. Attach the sheet's foreground TDs as `bw_temporalis.TemporalDistribution` + objects on the corresponding edges (uniform ±8, road-construction uniform, + production triangular). Map stats_arrays codes → discretised TD arrays at + yearly resolution. +5. `add_premise_temporal_distributions(BG_DATABASES)` on all six variants for the + deep background. + +## Run + comparison + +- **trails:** follow 2.2 — load datapackage, `import_excel_inventory`, select the + diesel activity by metadata, `lca(...)` temporal + `static_lca(year=2050)`. +- **timex:** `TimexLCA({fg_diesel: 1}, method, database_dates)`, + `build_timeline(starting_datetime="2050-01-01", temporal_grouping="year", + traverse_background=True, graph_traversal="bfs")`, `lci()`, `dynamic_lcia()`. + Align routing depth/cutoff to trails' adaptive default as closely as the timex + API allows; document any knob that can't be matched. +- **Comparison output:** + - total temporal score: trails vs timex, absolute + % difference + - static/base score cross-check + - per-year score series overlaid on one plot (both engines) + - a short table of the largest per-year contributors to any gap + +## Fidelity caveats (documented in the notebook) + +- trails uses adaptive-depth routing with a relative cutoff; timex uses BFS + background traversal with `max_calc`. These are not identical traversal + strategies, so small differences in deep-chain capture are expected. +- Foreground TD discretisation: trails samples continuous distributions; the + timex side builds discrete yearly TDs. Uniform/triangular are reproduced at + yearly steps; exact bin edges may differ by fractions of a year. +- Temporal grouping is yearly on both sides for a like-for-like series. + +## Scope / non-goals + +- No FaIR climate-emulator step (trails 2.2 §12) — out of scope; the comparison + is on characterized GWP100 scores. +- The electric-car block in the xlsx is ignored; only the diesel activity. +- Not a general bw→trails exporter; the trails datapackage comes from premise. + +## Open runtime dependencies (resolved at implementation, not design) + +- `IAM_FILES_KEY` from the user (blocks the trails datapackage build). +- `pip install -e` the local trails repo into the 3.12 `.venv`. +- Confirm premise accepts model string `remind-eu` / pathway `SSP2-NDC`. + +## Risks + +- Datapackage build is long and network/key dependent — highest-risk step; gate + it early and cache the resulting zip. +- Scenario regeneration may not be byte-identical to the existing bw dbs; report + the base-LCA cross-check so any background mismatch is visible before blaming + temporal handling. diff --git a/docs/superpowers/specs/2026-07-06-traverse-background-production-td-design.md b/docs/superpowers/specs/2026-07-06-traverse-background-production-td-design.md new file mode 100644 index 00000000..46a55ce9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-traverse-background-production-td-design.md @@ -0,0 +1,138 @@ +# Design: conserve impact for background production-edge TDs in `traverse_background` + +**Status:** approved (design), not yet implemented. +**Scope:** `bw_timex` variant-aware background descent (`traverse_background=True`). + +## Problem + +When `traverse_background=True` descends into a background node that carries a +**production-edge temporal distribution** (a TD on the node's own production +exchange), the descent mishandles it: + +- The production TD is convolved into the node's **child** (its expansion / + consumer-side date band) but **not** into the edge that **produces** the node. +- Result 1 — **`KeyError`**: the node is *consumed* at cohort years it was never + *registered as a producer* at, so `TimelineBuilder.get_time_mapping_key` misses + (`timeline_builder.py`). +- Result 2 — **N× over-count**: the production-TD cohort weights are lost + (`_join_datetime_and_timedelta_distributions` tiles the producer TD's amounts + and discards the consumer-side cohort weights), so each spread cohort emits the + full exchange coefficient instead of its weighted share. A 3-cohort production + TD inflates the score ~3×. + +Minimal reproduction (both `priority` and `bfs` engines): +`fu -> bg_A -> bg_B -> bg_C -> CO2`, two dated variants, `bg_A->bg_B` carries a +technosphere TD (triggers the variant-split descent), and `bg_B` carries a +production-edge TD spread over several years. `base_lca.score == 1.0` but the +time-explicit score comes out `= number of production-TD cohorts`. + +This is distinct from the premise `KeyError` originally reported (documented in +`docs/superpowers/bug-traverse-background-out-of-range-variant-mismatch.md`), +whose real mechanism is a **dual-path** (referenced-variant matrix traversal vs +proxy descent) date-rounding divergence — premise backgrounds carry no +production-edge TDs. That facet is **out of scope** here (see below). + +## Chosen semantics: cohort split (FU-seed style) + +A production-edge TD on a descended background node `bg_B` (weights +`[0.5, 0.3, 0.2]` at `+0/+3/+6y`, demanded by `bg_A` at 2020) splits `bg_B` into +weighted **produced** cohorts, each of which is both produced and consumes its +inputs at its own date: + +``` +bg_B@2020 (w=0.5) -> bg_C@2020 -> CO2@2020 +bg_B@2023 (w=0.3) -> bg_C@2023 -> CO2@2023 +bg_B@2026 (w=0.2) -> bg_C@2026 -> CO2@2026 + +bg_B PRODUCED at {2020, 2023, 2026} +bg_B CONSUMED at {2020, 2023, 2026} (bands match by construction) +``` + +This mirrors the existing, correct FU-seed handling in `build_edge_timeline` +(`edge_extractor.py:1187-1209`), where a functional unit's production TD spreads +it into weighted cohorts that are each registered. + +## Approach: fold the production TD into the effective producer TD (background only) + +Today each descent site emits the producer edge at the **unshifted** +`abs_td_producer`, then applies the producer's own production-edge TD only to the +child: + +```python +child_td, child_abs_td = distribution, abs_td_producer +producer_production_td = self._normalized_production_edge_td_from_proxy(producer_process) +if producer_production_td is not None: + child_td = (distribution * producer_production_td).simplify() + child_abs_td = _join_datetime_and_timedelta_distributions(producer_production_td, abs_td_producer) +``` + +Change: when `producer_production_td` is present, fold it into the **effective +producer TD of the edge itself** so the producer is registered at the same +spread, weighted cohorts it is later consumed at: + +- `td_producer_eff = (td_producer * producer_production_td)` — a proper + `TemporalDistribution` convolution, which **multiplies** weights correctly + (unlike `_join`, which tiles the producer amounts and drops the rest). +- Emit the producer edge from `td_producer_eff` (its `td_producer`, + `distribution`, and `abs_td_producer`). +- Queue the child from those same cohorts — **no** separate re-application of the + production TD. + +Each emitted cohort then carries `exchange_weight × prodTD_weight`; the producer +is registered at exactly the years it is consumed at. + +### Sites (both background-only, shared by both engines) + +- `_emit_variant_split_for_consumer_date` — `edge_extractor.py:334` (prodTD + applied at `:401`) +- `_descend_variant_subtree` — `edge_extractor.py:426` (prodTD applied at `:540`) + +Both live in `VariantBackgroundMixin` and run **only** on variant-locked +background nodes read from proxies. The priority `EdgeExtractor` (`:554`) and +`EdgeExtractorBFS` (`:919`) both reach them through the shared `_emit_variant_split` +(`:250`). The foreground/matrix path and the explicit product/process modeling +are **not** touched. + +### Array-alignment constraint + +`extract_edge_data` (`timeline_builder.py`) explodes `abs_td_producer.date` / +`abs_td_producer.amount` against `len(td_producer)` to tile consumer dates. The +fix keeps this convention, substituting `td_producer_eff`. If `.simplify()` +merging causes the distribution and absolute-date arrays to diverge in length, +derive the emitted amount from the same array used for the dates (the same +consistency the FU-seed maintains between `seed_td` and `seed_abs_td`). This is +the main implementation risk and is guarded by the conservation tests below. + +## Snap fallback: kept + +The nearest-registered-year fallback added to +`TimelineBuilder.get_time_mapping_key` is **retained** as defense-in-depth for the +still-unreproduced premise dual-path `KeyError`. After this root fix it should no +longer fire on the production-TD path (bands match), but it stays as a safety net +and is already full-suite-green. + +## Testing (TDD) + +RED (write/confirm failing first): +- Single production-TD chain conserves: `fu -> bg_A -> bg_B[prodTD] -> bg_C -> CO2`, + time-explicit score `== base_lca.score`, both `priority` and `bfs`. +- Convergent + production-TD conserves (a node reached by two parents, one path + carrying the production TD). + +GREEN / regression: +- Full existing suite stays green (currently 244 passed). +- Premise diesel smoke (integration): `build_timeline` + `lci` + `static_lcia` + runs without `KeyError` and yields a stable score. Kept out of the unit suite + if it needs the premise project; run manually otherwise. + +Housekeeping: +- Promote the throwaway `tests/test_repro_variant_mismatch.py` into a proper, + named test module; replace the currently-failing over-count test with the + conservation assertions above. + +## Out of scope + +- Premise dual-path `KeyError` root cause (matrix-vs-proxy date rounding). The + snap covers it for now; reproduce and fix separately. +- Foreground explicit product/process modeling — untouched. +- Global semantics of `_join_datetime_and_timedelta_distributions` — untouched. diff --git a/notebooks/example_electric_vehicle_premise.ipynb b/notebooks/example_electric_vehicle_premise.ipynb index 81d04f8a..c7bdb6de 100644 --- a/notebooks/example_electric_vehicle_premise.ipynb +++ b/notebooks/example_electric_vehicle_premise.ipynb @@ -1137,7 +1137,7 @@ ], "metadata": { "kernelspec": { - "display_name": "bw-timex (3.13.9.final.0)", + "display_name": "bw-timex (3.13.9)", "language": "python", "name": "python3" }, @@ -1151,7 +1151,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.9" + "version": "3.12.12" } }, "nbformat": 4, diff --git a/notebooks/example_premise_temporal_comparison_trails.ipynb b/notebooks/example_premise_temporal_comparison_trails.ipynb new file mode 100644 index 00000000..d77b41e4 --- /dev/null +++ b/notebooks/example_premise_temporal_comparison_trails.ipynb @@ -0,0 +1,286 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# `bw_timex` vs **trails** — same premise diesel-car case, side by side\n\nThis notebook recreates the worked example from **trails**\n(`examples/2.2. premise and imported lci example.ipynb`) — a diesel passenger car\n(`transport, passenger, car, diesel`) assessed at **2050** on a prospective\n*premise* background — and rebuilds the identical case in `bw_timex`, so the two\ntime-explicit LCA engines can be compared on the same data.\n\nThe companion notebook `example_premise_temporal_distributions.ipynb` shows the\n`bw_timex` background-temporalisation feature (`add_premise_temporal_distributions`)\nin isolation; **here we validate it against trails**.\n\n## Read this first — three things that shape the comparison\n\n1. **Absolute scores do not match, and cannot be made to.** trails consumes a\n premise *datapackage*; `bw_timex` consumes premise *Brightway databases*. Even\n when both are generated from a **single** premise run (as below), the two\n export paths diverge: on this case the identical foreground direct emissions\n (~33 t CO₂e, dominated by tailpipe CO₂) characterise the same, but the\n *background technosphere* differs by ~2× (trails ≈ 9.3 t vs Brightway ≈ 18.5 t).\n So we compare the **temporal effect** (time-explicit ÷ static, within each\n engine) and the **shape** of the per-year profile, which are robust to that\n offset — not the raw totals.\n2. **The two engines discretise temporal distributions differently.** trails and\n `bw_timex` (`premise_params_to_td`) turn the *same* premise TD parameters into\n yearly weights through different code paths (uniform matches; lognormal/normal\n differ). Expect small shape differences even where the inputs are identical.\n3. **The routing cutoff must be the SAME for both engines — this is the whole\n comparison.** On this case the temporal effect is carried almost entirely by\n *deep, long-lifetime* background branches (road construction, pulled decades\n *before* 2050 into high-carbon grid years). A branch only carries a temporal\n signal while it is routed **explicitly**; once a cutoff prunes it, it collapses\n to a single frontier year near 2050 and its year-spread vanishes — in *both*\n engines (trails keeps the pruned demand as a frontier solve; timex keeps it as\n a leaf temporal market). So a coarse cutoff makes the temporal effect disappear\n **for both engines equally**, and the two are only comparable at a *matched*\n cutoff. Earlier drafts compared trails at its default `1e-4` (fine) against\n timex at `1e-2` (coarse) and wrongly read the resulting ~0 timex effect as a\n `bw_timex` failure — it is a cutoff mismatch. We therefore drive both engines\n from one shared `CUTOFF` (below). `traverse_background=True` at a fine cutoff is\n expensive on the timex side (deep stacked long-lifetime TDs push the timeline to\n the 1800s and blow up the `lci()` matrix expansion — hours); a coarse `CUTOFF`\n is fast and shows both engines agreeing near ~0. See the scalability note below.\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Adjust the parameters to your environment. You need:\n", + "- a Brightway project with a source ecoinvent 3.12 (cutoff) + biosphere, and the\n", + " premise REMIND-EU SSP2-NDC variants (built below from a single premise run);\n", + "- the trails example inventory `lci-pass_cars.xlsx` (ships with the trails repo);\n", + "- `premise` on its **trails** branch (unreleased) for the datapackage build, and\n", + " `trails` installed to run the trails side.\n", + "\n", + "> **premise csv BOM gotcha:** premise's own `trails.py` reads\n", + "> `data/trails/temporal_distributions.csv` as plain UTF-8, but that file ships\n", + "> with a UTF-8 BOM, so `TrailsDataPackage` raises\n", + "> `Temporal params CSV file missing columns: ['name']`. Strip the BOM once\n", + "> (cell below) before building the datapackage.\n" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "from pathlib import Path\nfrom datetime import datetime\nimport numpy as np\nimport pandas as pd\nimport bw2data as bd\n\nPROJECT = \"ei312_REMIND_EU\"\nREF_YEAR = 2050\nWORKDIR = Path(\"premise_trails_compare\"); WORKDIR.mkdir(exist_ok=True)\n\n# trails example inventory (edit to your trails checkout)\nTRAILS_XLSX = Path(\"~/Documents/Coding/trails/examples/lci-pass_cars.xlsx\").expanduser()\n\n# Shared premise scenario (one premise run -> datapackage for trails + bw dbs for timex)\nMODEL, PATHWAY, SYSTEM_MODEL, EI_VER = \"remind-eu\", \"SSP2-NDC\", \"cutoff\", \"3.12\"\nYEARS = [2020, 2030, 2040, 2050, 2075, 2100]\nBG_DBS = [f\"dp312_SSP2_NDC_{y}\" for y in YEARS] # parity bw dbs (written below)\nBG_REF = f\"dp312_SSP2_NDC_{REF_YEAR}\" # variant the foreground references\nBIOSPHERE = \"ecoinvent-3.12-biosphere\"\nSOURCE_DB = \"ecoinvent-3.12-cutoff\"\n\nDP_ZIP = WORKDIR / \"trails_remind_eu_ssp2_ndc.zip\" # trails datapackage\nXLSX = WORKDIR / \"lci-pass_cars_static.xlsx\" # foreground with matrix rows blanked\n\nMETHOD = (\"ecoinvent-3.12\", \"IPCC 2021\",\n \"climate change: total (excl. biogenic CO2)\",\n \"global warming potential (GWP100)\") # trails 2.2 primary method\n\n# --- ONE shared routing cutoff for BOTH engines (see \"Read this first\", point 3) ---\n# The temporal effect lives in *deep, long-lifetime* background branches (road\n# construction pulled decades early). A branch only carries a temporal signal if\n# it is routed *explicitly*; once it is pruned it collapses to a single frontier\n# year near REF_YEAR and its year-spread is lost. So the two engines are only\n# comparable when they route to the *same* depth. NOTE the two cutoffs are not\n# identical in definition -- trails prunes on relative *score potential*, timex on\n# relative *supply throughput* -- so matched values give comparable, not identical,\n# depth.\n# CUTOFF=1e-2 -> fast run, both engines agree near ~0 (each prunes the deep\n# road-construction branch); CUTOFF=1e-4 -> trails-comparable ~+3% effect on\n# both, but the timex side is then compute-bound (~hours of lci()).\nCUTOFF = 1e-2\n\nbd.projects.set_current(PROJECT)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 0 — one premise run → trails datapackage **and** matching Brightway dbs\n", + "\n", + "To give both engines the *same* premise scenario we run premise once and export\n", + "it twice: a trails datapackage (`TrailsDataPackage.create_datapackage`) and the\n", + "six Brightway variants (`write_db_to_brightway`). This is the slow step\n", + "(≈15 min for the transformations, plus the datapackage export). It is guarded so\n", + "it only runs if the artifacts are missing.\n", + "\n", + "`IAM_FILES_KEY` is the premise decryption key (ask the premise maintainers).\n" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "import os\n", + "\n", + "# --- one-time BOM fix on premise's trails TD table ---\n", + "import premise\n", + "csv = Path(premise.__file__).parent / \"data\" / \"trails\" / \"temporal_distributions.csv\"\n", + "raw = csv.read_bytes()\n", + "if raw[:3] == b\"\\xef\\xbb\\xbf\":\n", + " csv.write_bytes(raw[3:]); print(\"stripped BOM from\", csv.name)\n", + "\n", + "need_dbs = any(db not in bd.databases for db in BG_DBS)\n", + "if not DP_ZIP.exists() or need_dbs:\n", + " from premise import TrailsDataPackage, clear_inventory_cache\n", + " key = os.environ[\"IAM_FILES_KEY\"].encode()\n", + " clear_inventory_cache()\n", + " dp = TrailsDataPackage(\n", + " scenario={\"model\": MODEL, \"pathway\": PATHWAY}, years=YEARS,\n", + " source_version=EI_VER, source_type=\"brightway\", source_db=SOURCE_DB,\n", + " system_model=SYSTEM_MODEL, key=key, biosphere_name=BIOSPHERE,\n", + " use_absolute_efficiency=True,\n", + " )\n", + " dp.datapackage.update() # transformations (slow)\n", + " dp.datapackage.write_db_to_brightway(name=BG_DBS) # -> Brightway (timex side)\n", + " dp.create_datapackage(name=DP_ZIP.stem) # -> datapackage zip (trails side)\n", + " # (create_datapackage writes .zip in the cwd; move it to WORKDIR if needed)\n", + "else:\n", + " print(\"datapackage + parity dbs already present\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 — the diesel foreground inventory\n", + "\n", + "`lci-pass_cars.xlsx` defines the diesel car as an explicit inventory. Four rows\n", + "(`diesel production`, `esterification of rape oil`, and the two CO₂ flows) use\n", + "`temporal_amount_source=matrix`: trails reads their *year-varying* amount from the\n", + "premise matrix (the diesel→biodiesel blend shift). `bw_timex` cannot reproduce a\n", + "**foreground** matrix-interpolated amount, so we blank that column — both engines\n", + "then use the identical base (2020, pure-diesel) amounts, isolating the temporal\n", + "behaviour. We edit the sheet XML directly to preserve the formula caches that\n", + "`bw2io`'s Excel importer relies on.\n" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "import zipfile, re, shutil\n", + "\n", + "def blank_matrix_source(src_xlsx: Path, dst_xlsx: Path):\n", + " \"\"\"Clear the 4 'matrix' cells (col Q) via XML surgery (keeps formula caches).\"\"\"\n", + " import openpyxl\n", + " wb = openpyxl.load_workbook(src_xlsx)\n", + " coords = [c.coordinate for row in wb.active.iter_rows() for c in row\n", + " if isinstance(c.value, str) and c.value.strip().lower() == \"matrix\"]\n", + " zin = zipfile.ZipFile(src_xlsx)\n", + " edits = {}\n", + " for sf in [n for n in zin.namelist() if re.match(r\"xl/worksheets/sheet\\d+\\.xml$\", n)]:\n", + " xml = zin.read(sf).decode(\"utf-8\"); before = xml\n", + " for ref in coords:\n", + " xml = re.sub(rf']*?>.*?', f'', xml, flags=re.S)\n", + " xml = re.sub(rf']*?/>', f'', xml)\n", + " if xml != before: edits[sf] = xml\n", + " with zipfile.ZipFile(dst_xlsx, \"w\", zipfile.ZIP_DEFLATED) as zout:\n", + " for item in zin.infolist():\n", + " zout.writestr(item, edits.get(item.filename, zin.read(item.filename)))\n", + " zin.close()\n", + " return coords\n", + "\n", + "print(\"blanked cells:\", blank_matrix_source(TRAILS_XLSX, XLSX))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2 — run trails (the reference)\n", + "\n", + "Standard trails 2.2 flow on our datapackage: import the (blanked) inventory,\n", + "select the diesel activity, route, solve the temporal LCA, and a single-year\n", + "static LCA at 2050.\n" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "from datapackage import Package\nfrom trails import Trails, get_lcia_method_names, search_activity\n\nnames = get_lcia_method_names(\"3.12\")\nexcl = next(n for n in names if \"IPCC 2021\" in n and \"total (excl. biogenic CO2)\" in n\n and \"GWP100\" in n and \"no LT\" not in n and \"SLCFs\" not in n)\n\ntrails = Trails(package=Package(str(DP_ZIP)), interpolate_annual=True,\n methods=[excl], ei_version=\"3.12\")\ntrails.import_excel_inventory(str(XLSX))\n\nmdf = pd.DataFrame(*[getattr(search_activity(trails, \"transport, passenger, car,\"), a)\n for a in (\"rows\",)], columns=search_activity(trails, \"transport, passenger, car,\").field_names)\nidx = int(mdf.loc[mdf[\"name\"] == \"transport, passenger, car, diesel\", \"index\"].item())\n\n# Route at the SAME cutoff timex uses (CUTOFF), NOT trails' default 1e-4, so both\n# engines prune the deep road-construction branch identically -> apples-to-apples.\ntrails.temporal_routing(start_year=REF_YEAR, start_act_idx=idx, amount=1.0,\n show_progress=True, attribute_to_roots=True,\n adaptive_relative_score_cutoff=CUTOFF)\ntrails.lca(show_progress=True, compute_score=True, store_inventory=True)\ntrails.static_lca(year=REF_YEAR, act_idx=idx)\n\n# collapse to one number per method and a per-year series\nsc = trails.scores\ntrails_temporal = float(sc.isel(method=0).sum())\ntr_y = sc.isel(method=0)\nfor d in [d for d in tr_y.dims if d != \"year\"]:\n tr_y = tr_y.sum(dim=d)\ntrails_series = pd.Series(tr_y.to_numpy().ravel(),\n index=[int(y) for y in sc[\"year\"].values]).sort_index()\ntrails_static = float(trails.static_score[0] if isinstance(trails.static_score, list)\n else trails.static_score)\nprint(f\"trails temporal = {trails_temporal:,.0f} | static(2050) = {trails_static:,.0f} \"\n f\"| effect = {trails_temporal/trails_static-1:+.2%}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 3 — recreate the case in `bw_timex`\n", + "\n", + "We rebuild the same diesel activity as a `foreground` database pointing at the\n", + "premise 2050 background, attach the sheet's **foreground** TDs via\n", + "`premise_params_to_td` (uniform wear/use spread, triangular production pulse), and\n", + "apply the **background** TDs with `add_premise_temporal_distributions` — the same\n", + "premise `temporal_distributions.csv` trails uses internally.\n", + "\n", + "> Requires the `timeline_builder` consumer-registration fix on this branch:\n", + "> deep stacked long-lifetime background TDs push some consumers to out-of-range\n", + "> dates whose producer role resolved to a different variant, which previously\n", + "> raised `KeyError` in `get_time_mapping_key`.\n" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "import json\n", + "from bw_timex.premise_temporal import premise_params_to_td\n", + "from bw_timex import add_premise_temporal_distributions\n", + "\n", + "# read the diesel exchange block straight from the (blanked) sheet\n", + "from bw2io.importers.excel import ExcelImporter\n", + "imp = ExcelImporter(str(XLSX)); imp.apply_strategies()\n", + "diesel = next(d for d in imp.data if d[\"name\"] == \"transport, passenger, car, diesel\")\n", + "\n", + "bg, bio = bd.Database(BG_REF), bd.Database(BIOSPHERE)\n", + "def resolve(exc):\n", + " if exc.get(\"type\") == \"biosphere\":\n", + " ct = tuple(exc[\"categories\"]) if isinstance(exc.get(\"categories\"), (list, tuple)) else exc.get(\"categories\")\n", + " m = [f for f in bio if f[\"name\"] == exc[\"name\"] and tuple(f.get(\"categories\", ())) == ct]\n", + " else:\n", + " m = [a for a in bg if a[\"name\"] == exc[\"name\"]\n", + " and a.get(\"reference product\") == exc.get(\"reference product\")\n", + " and a.get(\"location\") == exc.get(\"location\")]\n", + " return m[0]\n", + "\n", + "if \"foreground\" in bd.databases: del bd.databases[\"foreground\"]\n", + "fg = bd.Database(\"foreground\"); fg.register()\n", + "car = fg.new_node(\"diesel_car\", name=\"transport, passenger, car, diesel\",\n", + " unit=\"kilometer\", location=\"RER\"); car[\"reference product\"] = \"transport, passenger, car\"; car.save()\n", + "car.new_edge(input=car, amount=1.0, type=\"production\").save()\n", + "for exc in diesel[\"exchanges\"]:\n", + " if exc.get(\"type\") == \"production\": continue\n", + " amt = float(exc.get(\"amount\") or 0.0)\n", + " if amt == 0.0: continue # pure-diesel base recipe: FAME + non-fossil CO2 are 0\n", + " e = car.new_edge(input=resolve(exc), amount=amt, type=exc[\"type\"])\n", + " if exc.get(\"temporal_distribution\") is not None:\n", + " e[\"temporal_distribution\"] = premise_params_to_td({\n", + " \"temporal_distribution\": int(exc[\"temporal_distribution\"]),\n", + " \"temporal_loc\": exc.get(\"temporal_loc\"), \"temporal_scale\": exc.get(\"temporal_scale\"),\n", + " \"temporal_min\": exc.get(\"temporal_min\"), \"temporal_max\": exc.get(\"temporal_max\")})\n", + " e.save()\n", + "\n", + "add_premise_temporal_distributions(BG_DBS) # background TDs (same csv trails uses)\n", + "print(\"foreground + background TDs ready\")" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "from bw_timex import TimexLCA\n\ndatabase_dates = {db: datetime(int(db[-4:]), 1, 1) for db in BG_DBS}\ndatabase_dates[\"foreground\"] = \"dynamic\"\n\ntlca = TimexLCA({car: 1}, METHOD, database_dates)\ntimex_base = float(tlca.base_lca.score)\n\n# Route at the shared CUTOFF (same value as the trails cell). traverse_background\n# on this full foreground is expensive: build_timeline is quick, but the deep\n# stacked long-lifetime stock-asset TDs push the timeline back to the 1800s and\n# blow up the lci() matrix expansion (CUTOFF=1e-4 -> ~hours of lci(); CUTOFF=1e-2\n# still minutes-to-hours). See the scalability note at the bottom.\ntlca.build_timeline(starting_datetime=f\"{REF_YEAR}-01-01\", temporal_grouping=\"year\",\n graph_traversal=\"bfs\", traverse_background=True,\n cutoff=CUTOFF, max_calc=20000)\ntlca.lci()\ntlca.static_lcia()\ntimex_static = float(tlca.static_score)\n\n# per-year series: static CFs on the time-explicit (dynamic) inventory\ncfs = {}\nfor k, v in bd.Method(METHOD).load():\n cfs[k if isinstance(k, int) else bd.get_node(database=k[0], code=k[1]).id] = v\ndyn = tlca.dynamic_inventory_df.copy()\ndyn[\"contrib\"] = dyn[\"amount\"] * dyn[\"flow\"].map(cfs).fillna(0.0)\ndyn[\"year\"] = pd.to_datetime(dyn[\"date\"]).dt.year\ntimex_series = dyn.groupby(\"year\")[\"contrib\"].sum().sort_index()\nprint(f\"timex base(2050) = {timex_base:,.0f} | time-explicit = {timex_static:,.0f} \"\n f\"| effect = {timex_static/timex_base-1:+.2%}\")\n\n# How much dynamic mass reaches the *deep, early* years (the road-construction\n# driver of the temporal effect)? At a coarse CUTOFF this share is ~0, which is\n# exactly why a coarse run shows almost no temporal effect (see notes below).\n_pre = timex_series[timex_series.index < REF_YEAR - 10].sum()\nprint(f\"share of dynamic mass before {REF_YEAR-10} = {_pre/timex_series.sum():.2%}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 4 — compare" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "summary = pd.DataFrame({\n", + " \"engine\": [\"trails\", \"bw_timex\"],\n", + " \"static\": [trails_static, timex_base], # single-year 2050 baseline\n", + " \"time_explicit\": [trails_temporal, timex_static],\n", + "})\n", + "summary[\"temporal_effect\"] = summary[\"time_explicit\"] / summary[\"static\"] - 1\n", + "summary" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "fig, ax = plt.subplots(1, 2, figsize=(12, 4.2))\n", + "# absolute per-year GWP\n", + "tr = trails_series[(trails_series.index >= 2000) & (trails_series.index <= 2100)]\n", + "tx = timex_series[(timex_series.index >= 2000) & (timex_series.index <= 2100)]\n", + "ax[0].bar(tr.index, tr.values, width=0.9, alpha=0.6, label=\"trails\")\n", + "ax[0].bar(tx.index, tx.values, width=0.9, alpha=0.6, label=\"bw_timex\")\n", + "ax[0].axvline(REF_YEAR, ls=\":\", c=\"grey\"); ax[0].set_title(\"per-year GWP100 (excl. biogenic)\")\n", + "ax[0].set_ylabel(\"kg CO$_2$-eq / yr\"); ax[0].legend()\n", + "# normalised shape (robust to the absolute background offset)\n", + "ax[1].plot(tr.index, (tr / tr.sum()).cumsum(), label=\"trails\")\n", + "ax[1].plot(tx.index, (tx / tx.sum()).cumsum(), label=\"bw_timex\")\n", + "ax[1].axvline(REF_YEAR, ls=\":\", c=\"grey\"); ax[1].set_title(\"normalised cumulative share\")\n", + "ax[1].set_ylabel(\"fraction of total\"); ax[1].legend()\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## What we learn\n\n**The temporal effect is a *matched-cutoff* phenomenon — that is the headline.**\n\n- **trails (fine cutoff `1e-4`)** puts the manufacturing pulse a few years *before*\n 2050, spreads use-phase/tailpipe emissions roughly uniformly across ±8 y around\n 2050, and — crucially — pushes **road construction decades earlier** into\n high-carbon grid years: trails temporal 43.6 t vs static(2050) 42.3 t, a\n **+3.0%** effect. That +3.0% is almost entirely the deep road-construction\n branch landing in dirty early-year backgrounds.\n\n- **`bw_timex` reproduces the *same physics* — the effect appears or vanishes with\n the cutoff, not with the engine.** Measured on the timex side (`traverse_background`,\n `graph_traversal=\"bfs\"`):\n - at the **coarse** `cutoff=1e-2` the diesel case gives base(2050) **51,553** →\n time-explicit **51,078**, a temporal effect of **−0.9%** — i.e. ~nothing. The\n reason is measured directly: only **0.17%** of the dynamic emission mass reaches\n the years *before 2039*. The deep road-construction branch — trails' entire\n +3% driver — is pruned to a near-2050 frontier, so its early high-carbon years\n never enter the inventory. The mass that *does* move stays in the flat\n 2039–2058 decarbonisation tail (≈50% in 2039–2049, ≈37% after 2050), which\n nets slightly *negative*.\n - This is **not** a `bw_timex` bug. A controlled single-flow check\n (1 kWh of a market whose grid intensity falls 0.38 → 0.016 kg CO₂e/kWh over\n 2020→2050, its demand spread back toward 2020) routes background demand to the\n correct year-specific premise variant every time: **+681%** as a leaf market,\n **+581%** deep at `cutoff=1e-2`, **+1217%** deep at `cutoff=1e-4`. The\n machinery works at every cutoff; the full-vehicle case only *looks* flat\n because a coarse cutoff throws away the very branch that carries the signal.\n\n- **So compare like with like: one shared `CUTOFF` drives both engines** (trails\n via `adaptive_relative_score_cutoff`, timex via `cutoff`). Set `CUTOFF=1e-2` for a\n fast run where **both engines agree near ~0** (each prunes the road-construction\n branch); set `CUTOFF=1e-4` to recover the **~+3%** effect on both — at which\n point the timex side is compute-bound (below). The two cutoffs are defined\n differently (trails on relative *score potential*, timex on relative *supply\n throughput*), so a matched value gives *comparable*, not bit-identical, depth —\n expect the two effects to converge, not coincide exactly.\n\n- **Absolute totals still cannot be matched** (data-export artefact): trails'\n datapackage export and premise's `write_db_to_brightway` disagree on background\n intensities even from one premise run (~9 t vs ~18 t background technosphere),\n so the *temporal effect* and *normalised shape* remain the meaningful axes — not\n raw totals. And the two engines discretise TDs differently\n (uniform matches; lognormal code-2 / normal code-3 differ), a smaller extra\n source of shape difference.\n\n**Caveat — scalability.** `traverse_background=True` on a *full-vehicle* foreground\nwith deep premise chains is expensive at a fine cutoff. Stacked long-lifetime\nstock-asset TDs push the timeline back to the ~1860s, which blows up the `lci()`\nmatrix expansion: the trails-comparable `CUTOFF=1e-4` costs ~hours of `lci()`\n(measured: ~3 h here) on top of the traversal. `build_timeline` itself is quick;\nthe cost is in the solve. For a fast turnaround use the coarse `CUTOFF=1e-2`\n(both engines near ~0), or run the fine cutoff on a reduced foreground. Treat the\nfine-cutoff timex total as **compute-bound** on this case.\n" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/notebooks/example_premise_temporal_distributions.ipynb b/notebooks/example_premise_temporal_distributions.ipynb index 27f4ea21..4e4b992a 100644 --- a/notebooks/example_premise_temporal_distributions.ipynb +++ b/notebooks/example_premise_temporal_distributions.ipynb @@ -3,7 +3,35 @@ { "cell_type": "markdown", "metadata": {}, - "source": "# `bw_timex` — Background temporal distributions straight from **premise** (trails)\n\n`premise`'s **trails** work ships a curated table of *background* temporal\ndata in `temporal_distributions.csv`: how long stock assets sit in use, when maintenance\nhappens, when end-of-life treatment kicks in, how biomass grows before harvest, etc. Each\nrow is placed onto matching exchanges by fixed rules.\n\n`bw_timex` can mirror that curated data onto your **already-existing, year-specific premise\ndatabases** in a single call:\n\n```python\nfrom bw_timex import add_premise_temporal_distributions\nadd_premise_temporal_distributions([\"my_premise_db_2020\", \"my_premise_db_2030\"])\n```\n\nThis writes `bw_temporalis.TemporalDistribution` objects onto the matching background\nexchanges. They are **ignored** by a normal run and by `traverse_background=False`, and only\ntake effect once you descend into the background with `traverse_background=True`.\n\n> **Install note:** the annotation step needs premise's **trails** work, which is currently\n> *unreleased*. Install bw_timex and premise together in a **Python 3.12** environment:\n>\n> ```\n> pip install bw_timex \"premise @ git+https://github.com/polca/premise.git@trails_temporal_distributions_update\"\n> ```\n>\n> Once the databases are annotated, running the `TimexLCA` itself does **not** need premise." + "source": [ + "# `bw_timex` — Background temporal distributions straight from **premise** (trails)\n", + "\n", + "`premise`'s **trails** work ships a curated table of *background* temporal\n", + "data in `temporal_distributions.csv`: how long stock assets sit in use, when maintenance\n", + "happens, when end-of-life treatment kicks in, how biomass grows before harvest, etc. Each\n", + "row is placed onto matching exchanges by fixed rules.\n", + "\n", + "`bw_timex` can mirror that curated data onto your **already-existing, year-specific premise\n", + "databases** in a single call:\n", + "\n", + "```python\n", + "from bw_timex import add_premise_temporal_distributions\n", + "add_premise_temporal_distributions([\"my_premise_db_2020\", \"my_premise_db_2030\"])\n", + "```\n", + "\n", + "This writes `bw_temporalis.TemporalDistribution` objects onto the matching background\n", + "exchanges. They are **ignored** by a normal run and by `traverse_background=False`, and only\n", + "take effect once you descend into the background with `traverse_background=True`.\n", + "\n", + "> **Install note:** the annotation step needs premise's **trails** work, which is currently\n", + "> *unreleased*. Install bw_timex and premise together in a **Python 3.12** environment:\n", + ">\n", + "> ```\n", + "> pip install bw_timex \"premise @ git+https://github.com/polca/premise.git@trails_temporal_distributions_update\"\n", + "> ```\n", + ">\n", + "> Once the databases are annotated, running the `TimexLCA` itself does **not** need premise." + ] }, { "cell_type": "markdown", @@ -17,7 +45,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -35,7 +63,7 @@ " \"ei312_REMIND-EU_SSP2_NDC_2020\",\n", " \"ei312_REMIND-EU_SSP2_NDC_2030\",\n", " \"ei312_REMIND-EU_SSP2_NDC_2040\",\n", - "]\n", + "] \n", "BG_DATABASE = BG_DATABASES[0] # the variant the foreground references\n", "\n", "if \"foreground\" in bd.databases:\n", @@ -63,7 +91,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 40, "metadata": {}, "outputs": [ { @@ -127,7 +155,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 41, "metadata": {}, "outputs": [ { @@ -137,11 +165,11 @@ "annotated : 0\n", "skipped_existing : 61098\n", "faults : 4581\n", - " {'database': 'ei312_REMIND-EU_SSP2_NDC_2020', 'dataset': 'transport, passenger car, gasoline, Medium, EURO-2 | transport, passenger car, EURO-2', 'exchange': 'Passenger car, gasoline, Medium, EURO-2', 'reason': 'Temporal distribution conversion failed: Unsupported premise temporal_distribution code: 2'}\n", - " {'database': 'ei312_REMIND-EU_SSP2_NDC_2020', 'dataset': 'transport, freight, lorry, compressed gas, 26 metric ton | transport, freight, lorry', 'exchange': 'lorry production, compressed gas, 26 metric ton', 'reason': 'Temporal distribution conversion failed: Unsupported premise temporal_distribution code: 2'}\n", - " {'database': 'ei312_REMIND-EU_SSP2_NDC_2020', 'dataset': 'transport, freight, lorry, battery electric, 7.5 metric ton | transport, freight, lorry', 'exchange': 'lorry production, battery electric, 7.5 metric ton', 'reason': 'Temporal distribution conversion failed: Unsupported premise temporal_distribution code: 2'}\n", - " {'database': 'ei312_REMIND-EU_SSP2_NDC_2020', 'dataset': 'transport, passenger car, gasoline, Large, EURO-6 | transport, passenger car, EURO-6', 'exchange': 'Passenger car, gasoline, Large, EURO-6d', 'reason': 'Temporal distribution conversion failed: Unsupported premise temporal_distribution code: 2'}\n", - " {'database': 'ei312_REMIND-EU_SSP2_NDC_2020', 'dataset': 'transport, freight, lorry, fuel cell electric, 7.5 metric ton | transport, freight, lorry', 'exchange': 'lorry production, fuel cell electric, 7.5 metric ton', 'reason': 'Temporal distribution conversion failed: Unsupported premise temporal_distribution code: 2'}\n" + " {'database': 'ei312_REMIND-EU_SSP2_NDC_2020', 'dataset': 'transport, freight, lorry, battery electric, 26 metric ton | transport, freight, lorry', 'exchange': 'lorry production, battery electric, 26 metric ton', 'reason': 'Temporal distribution conversion failed: Unsupported premise temporal_distribution code: 2'}\n", + " {'database': 'ei312_REMIND-EU_SSP2_NDC_2020', 'dataset': 'transport, freight, lorry, compressed gas, 3.5 metric ton | transport, freight, lorry', 'exchange': 'lorry production, compressed gas, 3.5 metric ton', 'reason': 'Temporal distribution conversion failed: Unsupported premise temporal_distribution code: 2'}\n", + " {'database': 'ei312_REMIND-EU_SSP2_NDC_2020', 'dataset': 'transport, freight, lorry, diesel, 26 metric ton | transport, freight, lorry', 'exchange': 'lorry production, diesel, 26 metric ton', 'reason': 'Temporal distribution conversion failed: Unsupported premise temporal_distribution code: 2'}\n", + " {'database': 'ei312_REMIND-EU_SSP2_NDC_2020', 'dataset': 'transport, passenger car, gasoline, Medium, EURO-6 | transport, passenger car, EURO-6', 'exchange': 'Passenger car, gasoline, Medium, EURO-6d', 'reason': 'Temporal distribution conversion failed: Unsupported premise temporal_distribution code: 2'}\n", + " {'database': 'ei312_REMIND-EU_SSP2_NDC_2020', 'dataset': 'transport, passenger bus, fuel cell electric, 13m double deck urban bus | transport, passenger bus', 'exchange': 'passenger bus, fuel cell electric, 13m double deck urban bus', 'reason': 'Temporal distribution conversion failed: Unsupported premise temporal_distribution code: 2'}\n" ] } ], @@ -170,39 +198,33 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 42, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "photovoltaic flat-roof installation, 156 kWp, multi-Si, on roof -> electricity production, photovoltaic, commercial\n", - " date (years): [-30 -29 -28 -27 -26 -25 -24 -23 -22 -21 -20 -19 -18 -17 -16 -15 -14 -13\n", - " -12 -11 -10 -9 -8 -7 -6 -5 -4 -3 -2 -1]\n", - " amount : [0. 0.0041 0.0081 0.0122 0.0162 0.0203 0.0243 0.0284 0.0325 0.0365\n", - " 0.0406 0.0446 0.0487 0.0527 0.0568 0.0609 0.0649 0.069 0.0632 0.0575\n", - " 0.0517 0.046 0.0402 0.0345 0.0287 0.023 0.0172 0.0115 0.0057 0. ]\n", + "maintenance, passenger car -> transport, passenger car, battery electric, Small\n", + " date (years): [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15]\n", + " amount : [0.0625 0.0625 0.0625 0.0625 0.0625 0.0625 0.0625 0.0625 0.0625 0.0625\n", + " 0.0625 0.0625 0.0625 0.0625 0.0625 0.0625]\n", "\n", - "photovoltaic flat-roof installation, 156 kWp, single-Si, on roof -> electricity production, photovoltaic, commercial\n", - " date (years): [-30 -29 -28 -27 -26 -25 -24 -23 -22 -21 -20 -19 -18 -17 -16 -15 -14 -13\n", - " -12 -11 -10 -9 -8 -7 -6 -5 -4 -3 -2 -1]\n", - " amount : [0. 0.0041 0.0081 0.0122 0.0162 0.0203 0.0243 0.0284 0.0325 0.0365\n", - " 0.0406 0.0446 0.0487 0.0527 0.0568 0.0609 0.0649 0.069 0.0632 0.0575\n", - " 0.0517 0.046 0.0402 0.0345 0.0287 0.023 0.0172 0.0115 0.0057 0. ]\n", + "fuel tank assembly, compressed natural gas, 200 bar -> Passenger bus, compressed gas, 13m double deck urban bus, EURO-VI\n", + " date (years): [-15 -14 -13 -12 -11 -10 -9 -8 -7 -6 -5 -4 -3 -2 -1]\n", + " amount : [0. 0.0204 0.0408 0.0612 0.0816 0.102 0.1224 0.1429 0.1224 0.102\n", + " 0.0816 0.0612 0.0408 0.0204 0. ]\n", "\n", - "photovoltaic open ground installation, 570 kWp, CIS, on open ground -> electricity production, photovoltaic, commercial\n", - " date (years): [-30 -29 -28 -27 -26 -25 -24 -23 -22 -21 -20 -19 -18 -17 -16 -15 -14 -13\n", - " -12 -11 -10 -9 -8 -7 -6 -5 -4 -3 -2 -1]\n", - " amount : [0. 0.0041 0.0081 0.0122 0.0162 0.0203 0.0243 0.0284 0.0325 0.0365\n", - " 0.0406 0.0446 0.0487 0.0527 0.0568 0.0609 0.0649 0.069 0.0632 0.0575\n", - " 0.0517 0.046 0.0402 0.0345 0.0287 0.023 0.0172 0.0115 0.0057 0. ]\n", + "gearbox, for lorry -> Passenger bus, compressed gas, 13m double deck urban bus, EURO-VI\n", + " date (years): [-12 -11 -10 -9 -8 -7 -6 -5 -4 -3 -2 -1]\n", + " amount : [0. 0.0303 0.0606 0.0909 0.1212 0.1515 0.1818 0.1455 0.1091 0.0727\n", + " 0.0364 0. ]\n", "\n" ] }, { "data": { - "image/png": "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CxcutDp16tgBBxxgnTp1qsCzAYC/sE4mgJgP1HJzc+3kk0+2mTNnFrp+wIAB9vzzz1uNGjUitp93333XLrvsMlu1alWh67WfJ598MqTnAwDCOpkA4iJQO+ecc1xw1bx5czv++ONt69at9uqrr9pHH31k119/vd17770R2c/HH39sZ5xxhsvC7bfffta3b19LSkqyL7/80qZOnRqhZwsgsdfJHOzKXQBATARqCoi++uorS0tLc9v69eu76y+44AI7+OCDbezYsXbVVVfZHnvsEdb9KIi79NJLXZB200032ciRIwvt77vvvovYcwaQGOtkpnhBmic5ydZlZxKoAYidyQQffPCB21500UUFwZW0atXKTjnlFBdIKfMV7v28//77tnz5cuvZs2exIE169epV4ecGIHGxTiaAuAjU5syZ47a9e/cudpu6IkUTA8K9H2Xd5B//+IetX7/e/u///s/uuusumzBhgq1Zsybk5wMAwjqZAOKi6/P33393W3VZFuVdp0kC4d7PokWL3LZ27dpulueyZcsKbtN1jz76qA0aNGiXj7t58+Zd3geRoy7s4C38g7YxSxtwua1aOtA2/DbXUvbqaGl7tffNZwbt41+0Tey1TVknPcZkoLZlyxa3rV69erHbvCdelg+28u5n48aNbnvzzTfb9u3b7dxzz7VatWrZ119/bdOnT7fzzjvP2rVrZ+3bt9/p4+bk5LhuVUTXihUraAKfSvi2SUqx5OY9bJOZZWdnm98kfPv4GG0TG21TpUoVa926dfwGal4QpUBLgVKwTZs2lTlSLe9+vIBOtdamTJlSaFzbJZdcYuPGjbNnn33WdYfuTGpq6i6PDZGjsxq9YZo2bWrVqlXjpfYR2sbfaB//om0Sq218H6hpFuZPP/1kS5cutQYNGhS6TdeJXpBw78eb/Tl06NBCQZqcf/75LlCbN2/eLh+3IulOhI/eMLSFP9E2/kb7+Bdtkxht4/vJBN4KAOpyLMob8N+5c+ew76dLly5um5+fX+z+3nWqqQYAABApvg/UBg4c6LaPP/54odUBFixYYC+99JLrmtTKAuHez9FHH23JycnFZnkqSHvssccKBXMAAACR4PuuT9UxO+yww9xam3369LFjjz3Wtm3bZm+++aZbg3P48OHWpEmTQpMA7rvvPqtXr56NGDEi5P20bNnSTjvtNLe0VI8ePVyg500mUKmPlJQUt9IBAABApCTl5eUFzOdWr17titJ+//33ha4/7rjj7Omnny40YE/3bdOmjSu5UbS+Wnn2480CVQA3ceLEQtc3btzY3T8jIyOMzxKRoDbUTDr9PzBGzV9oG3+jffyLtkmstvF9Rk0aNmxoH374octmKfjSVFdlyLT+ZlHKemndTmXUKrIf0YusjNqsWbPcklFqAK1k0K9fv2IzRwEAABIyowaEijNP/6Jt/I328S/aJrHaxveTCQAAABJVTHR9AgDiV+6iubY2e7ZbqF5roAL4G4EaACBqFkwcY6kpkywlOcksK2ALZmdY+qBRtAjwP3R9AgCilklTkGYK0tw3UpK7rOsB/IVADQBCpIBi4eQJBBYhUndnQZDmSU6yddmFSysBiYyuTwAIAV12FacxaeruLBSs5QesTtqulwUEEgUZNQAoJ7rswkMTB3I2ZLjgzMkPuMtMKAD+RkYNAELosnOD30vosiPIKB9NHMhdNNi9dsqkpTPrEyiEQA0Ayokuu/BScEuAC5SMrk8AKCe67ABUFjJqABACuuwAVAYCNQAIEV12ACKNrk8AAACfIlADAADwKQI1AAAAnyJQAwAA8CkCNQAAAJ8iUAMAAPApAjUAAACfIlADAADwKQI1AAAAnyJQAwAA8CkCNQAAAJ8iUAMAAPApFmUHkLByF821tdmzrW5aF7fAOgD4DYEagIS0YOIYS02ZZCnJSWZZAVswO8PSB42K9mEBQCF0fQJIyEyagjRTkCbJSe6yrgcAPyFQA5Bw1N1ZEKR5kpNsXXZmtA4JAEpEoAYg4WhMmuUHCl+ZH7A6aZ2jdUgAUCICNQAJRxMHcjZk/B2s5QfcZSYUxCZ1WS+cPIGua8QlJhMASEiaOJC7aLDr7lQmLZ1ZnzGJSSGIdwRqABKWMmhk0eJxUshg2hVxg65PAEBMYlIIEkFMZdTy8vLs559/tqpVq1rHjh0tJSUlYvvZvn27ffnll6Xuo1u3btaoUaOQHh8AEKZJIVmBwjN4mRSCOBMTgdq2bdts1KhRNnbsWPe71KpVy6666iobOXJkRPazfv16O+mkk0rd14QJE+zII48M+TkBACpG3dYqVFzQ/fm/SSGMN0Q8iYlA7dprr7VnnnnGqlSpYj169LCtW7farFmz7Oabb7aaNWvaBRdcELH9NG3a1Dp16lTs+saNG4fluQEAQsekEMS7pLy8vCLFhPxl4cKF1rNnT5f5evfdd12Xo3z44Yc2bNgwq127ts2dO9dtw7kfdY+2bNnSTjjhBHv22Wcr4ZkiEjZv3mzZ2dmWlpZmNWrU4EX2EdrG32gf/6JtEqttfD+Z4K233rL8/HwbPnx4QXAl6nYcPHiwrV271j799NNK2w8AAEBl8X2gNmPGDLft379/sduOOOKIQveJ1H7mzZtnkydPtl9++cUCAV8nIAEAQBzx/Ri1ZcuWuW2bNm2K3eZdpzRjpPajTNwbb7xRcDk1NdWuu+46O+2008qcBkX0aBxi8Bb+Qdv4G+3jX7RN7LVNRbpBfR+obdy40W1LKqHhjSfbsGFDxPajF7ddu3aWnJxsWVlZlpOTYyNGjHD3LcskBt1/x44du7wfImvFihW8xD5F2/gb7eNftE1stI0mMLZu3Tp+A7XddtvNbb1yGsG867z7hHM/+v2BBx6wU045xapXr+6u27Jliz366KN2yy232G233Wann366m5ywM8rAIXp0VqM3jGbvVqtWjabwEdrG32gf/6JtEqttfB+oNWjQwG1zc3OtXr16JUasDRs2DPt+lHk788wzC91PAdvll19ukyZNcj8a09a7d++dPi4zDf1Bbxjawp9oG3+jffyLtkmMtvH9ZIL27du77bRp04rd9sMPP7ituiYraz+iSFk2bdpUpvsDAADEZaB22GGHue1TTz1VaKyXymmMHz/e/X744YeHfT/KvJVEEw4+++yzUicmAKjcRbkXTp7gtgAQj3zf9TlgwABr27atTZ8+3U488UQ3Lkx9wI8//rgbqK/SGnvvvXeh8WbqllTKsU+fPiHvZ/To0fbrr7/awIEDXeFbmTNnjlt+avXq1da3b19r1apVJb8aADwLJo5xSwelaOmgrIBbSkhV6gEgnvg+UNPC6VoZQEVptUh68ELpCpQeeuihQvdXhkxrdKoqcGZmZsj7UffmK6+8UmJXaZcuXVxmDkB0KINWsL6jJCe5y7mLBrv1HwEgXvg+UPMCo2+//dZlsxR8eWt1nnHGGcWWjtJszX79+pW4Fmd59nPrrbfa0KFD7Z133nHLT2k82p577mkHH3ywHXXUUS7wAxAda7Nn/5VJC5acZOuyMwnUAMSVmIk2mjRpYldfffUu71e3bl17/fXXK7wf0WLsJS3IDiC66qZ1cd2dBRk1yQ9YnbTO0TwsAEi8yQQAUJS6N3M2ZLjgzMkPuMt0ewKINzGTUQOAYJo4oDFp6u5UJi2dsWkA4hCBGoCYpQwaWTQA8YyuTwAAAJ8iUAMAAPApAjUAAACfIlADAADwKQI1AAAAn2LWJwDAEn1JMq12oULKzCKG3xCoAQAS1oKJY9w6sW5JsqyALZid4Wr0AX5B1ycAIGEzaQrSCpYiS05yl3U94BcEagCAhKTuzkLrxUpyklvtAvALAjUAQELSmLSC9WI9+QG3JBngFwRqAICEpIkDORsy/g7W8gPuMhMK4CdMJgAAJCxNHMhdNNh1dyqTlt6qY7QPCSiEQA1A1KxfudiWZH9vDVrtSxYDUaMMGlk0+BWBGoCoWPLBPbZ3na/+GsydNY6yCABQAsaoAah0Kn/Q3AvS3CcRZREAoCQEagAqHWURAKBsCNQAVDrKIgBA2RCoAah0Gri9bF1fyiIAwC4wmQBAVLQ46kr7eXovq7VlpTVo042yCAAQzkDtjz/+sFmzZlmDBg2sa9euYbsvgMRRu3FLS0vrazVq1Ij2oQBAfHV9KvAaPHiw3XjjjWG9LwAAAKIwRi0pqcjitwAAAIhuoPbnn3+6bfXq1Svj4QAAABJrjFogELAdO3YUXM7Pzy+4fvv27aX+zYoVK2z8+PHucosWLSp+xAAAAAmizIHapEmT3DizoiZPnmyNGjUqU7fniSeeWP4jBAAASFAR7/qsWrWqdenSxZ566inr1atXpB8OAAAg8TJqffr0scWLFxdcnjJlip166qnWu3dve+GFF0r8mypVqlhKSoolJ1NXFwAAIGKBmjJj9evXL7i8995723nnnWfp6emFrgcAAECUC94qQLv77rvDdBgAAAAoij5JAACAeF7rc+XKlfbDDz+47ZYtW0q935577mlHH310hR5r27Ztbsybxr9V9n703DZt2uRmsNarV69Cjw8AABDRQE1By1VXXWUTJkwotZZasIyMjJADtVdeecXuv/9+mzdvnguU9ttvP7vhhhvcPitjP6oJd9xxx9l3331ndevWtaVLl4b0PAAAAColULviiivsxRdfLChm26FDBxfElEa3h+KJJ56wa665xv2+2267uWK706ZNsxNOOMFefvllO/zwwyO+n2effdatWcrqCsBfchfNtbXZs61uWhdr0qojLwsAREBSXl5eIJQ//OOPP6xNmzYu2Ln++utd0BaJMhzqTu3WrZvL3t1777122mmnuW5L/X7PPfe4APHHH390s1IjtZ/ff//devbsaZdddpk98sgjLntIRi02bN682bKzsy0tLc1q1KgR7cOJGwsmjrHUlElmyUlm+QHL2ZBh6YNGlWsftI2/0T7+PXGhbfwrEm0TcmQ1f/58F6SlpqbalVdeGbFaaW+99ZZt2LDBhg0bZmeddZYLpGrWrGmjR4+2gw46yJYsWWJff/11RPej7t299trLLrnkkgg8QyD2vpAKgjRJTnKXdT2QqCcuKVkjrdn2sW6ry0C4hBxdeYPwmzdv7sZ6RYrGhMnxxx9f7DZvSaqpU6dGbD8ffPCBvf/++/bwww/vMmsHJAJlDQqCNE9ykq3LzozWIQFRw4kLfBuoqY5atWrVCq1WEAmLFi1y2/bt2xe7zbvOu0+497N+/XqXTbvwwgtt3333DfEZAPFFXTvq7iwkP2B10jpH65CAqOHEBZEWcopI5SlOPvlkGz9+vL3++usRW3BdwZKUtPpBgwYN3Hbt2rUR2c8tt9ziMoejRpVv7E3R/mpEz9atWwttUXF1m7W2JTP7WvM6XxWMUVu2rq+1aNa6XP/vtI2/0T5lU2OP9mZLA4WzzPkBq75Hu4h9/tM2/lVa21RkvFqF+vL+85//uLFql156qRtwr25FjVmLBJXHKEpj5KQ84+PKuh9NLHjmmWdcEFqrVq0Qj9osJyfHduzYEfLfIzxWrFjBSxlGyZ2G2vyVvSyw6ldLatTaardo6QbQ0jbxh/fOLiSl2JrVPa1Nw+8LTlyyVve0mi1SQn5P0Dbx9b6pUqWKtW7duvIDtcmTJ9uQIUNckKPZk5r5qR+VvSgtcDr44IPt1VdfLdfjeIVlV69ebbVr1y5025o1a9x2ZyVBQtmPZnUq+Dz22GPdTNG8vLxigZ6u0/Pc1WNHKnBF2eisRm+Ypk2buq56hFFampn1DfnPaRt/o33KIe16W7V0nm34ba6l7NXR9u5TfIhNONE2/hWJtgk5UFOAVtIqBAraShNK95NKgHz//ff2008/uRIawTIz/xq83LZt27Du5+eff3b3049mi5akZcuW1qRJE5dR3BlKQviD3jC0hT/RNv5G+5RN8727memnEtE2/hXOtgk5UDvwwANtzpw55fqbUA66d+/e9tJLL7nVD4JXNVCgqOukb9++Yd3PzjJl69atc1k13V6WTB4AAEClF7ytLBrg37VrV1dgd+TIka4GmrJ2KlKrwKtjx442ZcqUghIhCqL+/PPPYsFWefdTGvUzU/A2dlAY0r9oG3+jffyLtvEvXxW8rSwKtrQ2pwIobbt06WL777+/C640yF8rBQQHVxpvpm5JZdAqsh8AAIBoi4kKroMHD7bGjRvbgw8+6MaTaQZFjx497Oqrry62fqiXSatTp06F9lMa7bssC9ADAABELVBT5mr69Onl+puGDRuGXDhWGbKiWbKSqCbaztbhLOt+SjNz5syQ/xYAAKBSArXZs2fbSSedVK6/ycjIsIkTJ4b6kAAAAAkl5EBNXYDdu3cv9XYN6NeSTOom1IC6Tp06Wbt27UJ9OAAAgIQTcqC233772aeffrrT+yhYUxFcLTN11FFH2RVXXBHqwwEAACSciM761GoADz/8sKu5puWm1F0KAAAAn5TnUMkLLdiuwrIvv/xypB8OAAAgblRKHbVmzZq5bVZWVmU8HAAAQFyolDpqc+fOdVst2A7AH3IXzbW12bOtbloXa9KqY7QPBwAQjUBNEw4eeugh9/vOZokCqDwLJo6x1JRJlpKcZJYVsAWzMyx90CiaAADiJVCbNm2aXXbZZaXerjFpOTk5lpeX5y43b97czjjjjFAfDkAYM2kK0kxBmiQnucu5iwaTWQOAeAnU1q9fbz/99NMu76dlmlSa4/bbb7f69euH+nAAwkTdnS6TFiw5ydZlZxKoAUC8BGrdunWzd955p9TbteZm7dq1rW3btpaSkhLqwwAIM41JU3dnQUZN8gNWJ60zrzUAxEugpuxY3759w3s0ACJOEwc0Jq2g+zM/YDkbMiydCQUAkJizPgH4iyYOaEyaujuVSSNIA4A4D9RmzZplX3/9tf3222+2ZcsWa9CggXXp0sX69etH1yfg08waZTkAIM4DtV9//dUuvvhi++abb0pdRuqGG26wc889t6IPBQAAkFAqFKgtXrzYBgwYYCtXrnSXO3To4CYP1K1b15YuXWozZsxwC7NfeeWVtmrVKrv22mvDddwAAABxr0KB2qhRo1yQ1rJlS3vwwQctIyOj0O2rV6+22267zZ577jm7++677fjjj7d27dpV9JgBAIgrrBSCsK/1qUK2H330kVt0/aWXXioWpEnDhg3t/vvvd1m3HTt22GuvvRbqwwEAELcrhaRkjbRm28e6rS4DFQ7UtMC6gq9OnTq5Ls+dGTJkiNvOmzcv1IcDACCBVgr5a41sIORAbevWrW5blmK23n28vwEAAH+tFFKo+HTQSiGA+3cI9WVITU11Wy0jtW7dup3e99tvvy1Y7xMAAAStFJIfKPxysFIIwhGotWjRwtq3b+/W/Lz00ktt8+bNJd5PtdWefPJJ93v//v1DfTgAAOKOahlqZZCCYO1/K4VQ4xBhmfV544032qmnnmpvvPGGTZ061c444wzbe++93Rqfy5Yts88++8zef/99CwQCdtBBB7lJBQAA4G+sFIKIBWoDBw60++67z9VHW758ud1xxx0l3q937942fvx4N0MUAAAUxkohiNjKBGeddZYddthh9vzzz7tuTmXStITU7rvv7paQGjRokB1zzDGWnBxyLysAAEBCCstanxqvpm5QAAAA+HBRdgCVi0rmABD/whKoabLAkiVLLDc313V7lqZ+/frWuXPncDwkkNBUuVxFMVNUfykrYAtmZ7gByQCA+FK1ogHaY489Zo8++qjl5OTs8v5aZmrixIkVeUgg4ZVeyXwwU/oBIM5UKFC7+eab7YEHHvhrR1Wr2h577GF16tTZ6Vg2ABWvZO4yaSVUMqf2EgDEl6oVWZT9kUcecb+fe+65dsMNN1i9evXCeWwASqtknhUovOwMlcwBIC6FXDNDC6xv377dZdHuvPNOgjSgklDJHAASR8gZtRo1arjtXnvtZVWqVAnnMQHYBSqZA0BiCDlQS09Pt5SUFPv111/De0QAyoRK5gAQ/0IO1BSknX322fbwww+7VQm0zmck/fzzz26GaWZmppu40L17d7vkkkssNTU1YvtZvHixvfLKK/bjjz/ab7/95iZK9OjRw43JUyYRAAAgkpLy8vICof7xtm3b7Mwzz7SPPvrIzjvvPDv22GMtLS2t1OWi1F3asGHDcj/Ol19+aUOHDi1Wo03LVH344YduIfhw7+eHH36wI444wpUgKUqLziuA00Lz8LfNmzdbdna2+7/0uuvhD7SNv9E+/kXbJFbbVGgBzt12282t5anM1OOPP+4WaVdB23322afEH2XgymvTpk124YUXuuBq2LBh9umnn9r777/vgqg1a9bYxRdfHJH96MVu3769m8366quv2uTJk+2FF16wPn362Pr1623EiBHlfi4AAACVVkdN3Z4KZDxJ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QA0oB00cyNmQ8Xewlh9wl5lQAACIBCYTAOWkiQO5iwa77k5l0tKZ9QkgDjCb3Z8I1IAQKINGFg1AvGA2u3/R9QkAQAJjNru/EagBAJDAmM3ubwRqAAAkMGaz+xuBGgAACYzZ7P7GZAIAABIcs9n9i0ANAAAwm92n6PoEAADwKQI1AAAAnyJQAwAA8KmYGaO2fv16e+ONNywzM9OqVKliPXr0sOOOO8522223iO1n7dq19vHHH9u8efNs5cqV1qRJE8vIyLA+ffqE8ZkBAADEcKC2aNEiGzRokC1durTguv/+97+277772ptvvmn169cP+34eeughGzNmjG3evLnQPu6++247/PDDbezYsVa7du2wPD8AAICYDNQCgYCdccYZLrjq3LmzDRs2zLZt22bPPvuszZgxw6644gp75plnwr6fn3/+2apVq2bHHnuspaen2+67724//fSTvfjii/bpp5/aTTfdZPfee2+Enz0AAEhkSXl5eQHzsc8//9xOOOEEa9++vX355ZdWo0YNd726Ig844ABbs2aNC7RatmwZ1v2ou1O/e/fzKEg76aSTrGHDhpaVlRWx543wUEY0Ozvb0tLSCrWl1rbTsimqyM3i6v5qG/gD7eNftE1itY3vJxNojJhceOGFhZ5048aN7bTTTnOZsk8++STs+1FAV9KLrG7PBg0a2MaNGyv83BAdCyaOsZSskdZs+1i31WUAAPzI94GauiClZ8+exW7r1atXoftUxn6UgVu3bl3B3yC2KJOWmjLJLDnpryuSk9xlXQ8AgN/4foyaAiNp1qxZsdtSU1PddtWqVZW2n2uvvdZl30aNGlXmNCiiZ+vWrYW2fyyaYSlekOZJTrI/Fs+0us1aR+MQE1bRtoG/0D7+RdvEXttUpBvU94Ga92Q1sL+o6tWru+2WLVsqZT+33Xabvf766242aEmZuZLk5OTYjh07ynRfRM6KFSvcdmONJmYbA39n1CQ/YBtrNXbjChC9toE/0T7+RdvERtuoFFjr1q3jN1CrVauW227atKngd8+GDRsK3SeS+xk9erQ98sgjbrbnRRddVObj97J1iA4F6HrDNG3a9K8gPS3NlnzwnTWv89VfwVp+wJat62sd+vSliaLdNvAV2se/aJvEahvfB2rqqpw9e7YtXrzYzbQMpuvKGgyFuh9lwy699FIbP3683XLLLXbJJZeU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IeaoC0BdAeoSUNeAig4qxa9UvwqjIro0Q1Nn9EuXLnWXVXpDmYHgNQkRHdOmTXNBgLZeaRR1QQeXc0B06P2iSU3q9vdmFOpEVJNpkNj+KoIDxFi2RhMGFKRpYoAGODOGyT+efPJJ96WjQc3KBKhcAPxBs9AUpNEF7T+aSasgTZNstNyd1oosWkYIiYmMGmKSBj6rC81bfgj+oRlnWgVCZRzogvYXDXJWbS11QwcvCQV/UC+BxqLtueee0T4U+AiBGgAAgE8x4hoAAMCnCNQAAAB8ikANAADApwjUAAAAfIpADQAAwKcI1AAAAHyKQA0AAMCnCNQAAAB8ikANAADApwjUAAAAfIpADQAAwKcI1AAAAHyKQA0AAMCnCNQAAEBc27Rpk3300Ud22WWXWadOnWzPPfe0W2+9NayPMXv2bDvrrLOsZ8+e1rZtWzvssMPsP//5j61du7ZC+60atiMEEJOWLVtmd955Z7n/rkqVKvbAAw/Ys88+azNmzLBhw4bZgQceaLHu6aefdh+4N910kzVs2DDahxOzvv76a3v55Zdt3333tbPPPrtC+7rtttvcl93tt9/u/u+A8rr00kvtlVdeKXTdli1bLFxee+01O++88ywQCBRct2rVKps+fbp73M8//zzkzxMCNSDBrVmzxv7v//4v5EDtyy+/tLffftt69OgR84Har7/+atddd5316dOHIK2C5s+f7/6v/vzzzwoHavXq1bN77rnHOnTo4DIWQHnVrFnTBgwYYEceeaTt2LHDrrzySguX/Px8u/76612QdsYZZ9i//vUv23333e2nn36yq6++2r0XHnvsMbvhhhtC2j+BGpDg0tLS7KGHHioxaFEgJnfffbdVr1690O1JSUkWb/7973/btm3b7Nprr432oSDIOeec4/5H77jjDjv55JMtJSWF1wfl8uCDDxb8/u6771o4rVy50lasWGEtWrRwn5neZ+Mhhxxi9957rx177LEuaAsVgRqQ4Bo0aGCnn356seunTp1aEKidcsopVrt27VK/RA8//PCYz6YtWLDAZQa7detmvXr1ivbhIEitWrXc/6i+9JSlu+CCC3h9EHEzZ860Rx55xL7//nvX9Z6ammpHH320G+cWfLKgz9CqVatatWrVip3Aeie4jRs3Dvk4CNQAVEhWVpYbo5aenu5+PMqAKPhRINelSxf77LPPXPC3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", + "image/png": "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iqAGAADgUwQ1AAAAnyKoAQAA+BRBDQAAwKcIagAAAD5FUAMAAPApghoAAIBPEdQAAAB8iqAGAADgUwQ1AAAAnyKoAQAA+BRBDQAAwKcIagAAAD5FUAMAAPApghoAAIBPJVqUyMjIsHfffdcWLFhgCQkJ1rlzZzvuuOMsEAiU6Dhbt261jz/+2L788kvbvXu3/etf/7IGDRoUes5Zs2bZkiVLbN26dVa/fn3r3r27dejQId++O3bssFtvvbXQ8w4ePNjatm1bomsFAADxLSqC2urVq+3MM8+0H374Idf2rl272iuvvGLVqlXb4zGysrLsrLPOsk8//dR27tyZvX3gwIEFBrWnnnrKbr/9dtu0aVO++8444wx77LHHrHLlytnbdMzHH3+80PP36NGDoAYAAGIvqA0dOtSFtBYtWlj//v1t165d9tJLL7nQde2119ojjzxSrKD24Ycf2l577WXHH3+8LVu2zH788cdC9//2228tPT3dTjzxRGvVqpXVrl3bFi5caFOmTLHJkydbw4YN7c4778z3uI4dO9o555yTbzu1aQAAIOaCmsLYf//7X2vevLl98skntvfee7vtQ4YMsaOOOsomTpxoN954ozVq1KjI46i59PXXX3e1cKoJO++884oMapdccondddddVrNmzVzbFdQGDRrkjlVQUGvZsqUNGzas1D8vAABA1AwmmDFjhru97LLLskOaNG7c2AYMGOBqyt5///09Hkd92VSTlrO5sigHHXRQvpAmp556qtWoUcM2b95cop8DAAAg5mrUFi9e7G6PPPLIfPepRu3RRx+1RYsWldv1bNy40bZv3+4GMxREgw4efvjhXIMP2rdvX27XBwAAYofvg9ratWuza9Dy8po7vX3Kw+jRo91o0euvv77Qplp95XTaaae5wQdVqlTZ4/HVLy6WaSRtzltQDvGM14M/UA7+EC/lkJycHFtBzRuhmZSUVOgPm3MUZySNHz/eXnzxRdcnrlu3bvnuT0xMdFOGaPqOYDDoagM1pchbb73lrrWoUaGe1NRUy8zMtFi3Zs2air4EUA6+wevBHygHf4jlckhISHB97mMqqHlhTDVNVatWzTd3mRSnpqqsxowZY3fccYddeeWVbqRpXrq2r776yvbbb798o0dPPvlkmzRpkt1yyy1utGhRUlJSLJbpk5JehJoSpaDwDcohnvB68AfK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b3/22Weta9eu9u2337qvgw8+uMjjJCYm2vLly93cb95xw3necF0nKo6a/byw4glkZbntmTHcFBivPzcAxHRQ27BhgwseJVGnTh076KCDSvQY1UzJ+eefn2u7RpeqmXH06NE2a9asYgUgL6RF4rzhvE5UDNc3KxDMFVpCwaDbHsvi9ecGgJgOat9//72deeaZJXpM9+7dbcqUKSV6zLJly9xtp06d8t3nbVu6dGmJjhmJ81bUdSJ81IFefbNcs19WlgsrOwePjPmO9fH6cwNATAe16tWr26GHHlro/Zs2bbJffvnFjazUaM/27dtbmzZtSnyetLS07Nq4vGrXrp19rnAr6XnDdZ3p6ekWy7zBG76dquXw42x7646WsDbVMuunWFateioUi/lyiJOf2298/3qIE5SDP8RLOSSXcAaMUgc1NeF9+OGHRe6jYKJJcCdMmGAnnXSSjRw5slTztUlBk+h6C8MrDIZbSc8brutMTU11I1Rjnfrz+VrV2mZb0822rrK4Koc4+bn9xvevhzhBOfhDLJdDQkKCNW/e3D+DCbQe6EMPPeQ68Wu5KXW079ixY4mOsddee7lbjZysVq1arvu8GirV7oVbSc8bruuM9SW49ElJL8IGDRqUqM8gKIdYxOvBHygHf6AcKmjUZyAQsH79+tnnn39ukyZNKnFQ00SxGrSgNUX32WefXPdpm7dPuJX0vOG6zrJMChxNFNLi5Wf1M8rBHygHf6Ac/IFyqIB51Lzgopq1kjrssMPc7dtvv53vPm9gwuGHH17mayzreSvqOgEAQOwql6C2ePHi7KkqSuq0005zE9U+//zzNnnyZLdNfbjUpKoJZdVUqNGk4VbS81bUdQIAgNgV8aCmAQfjx493/y9qlGhRtXHXXXed64ivGf+bNWvmvkaNGuWaVe++++5cfZ00+rJDhw7Wq1evfMfS43WfvmbOnOm2qVnW26Z1Okt73pLuDwAAELE+al9//bVbbLwwGgWpEYzetBWNGze2QYMGlepcV111leusP27cOHdM0ULqmkT2lFNOybWvarFWrSp4xJo6see97/fff8/+f94hwSU5b2n2BwAAKEogLS0tZKXwySefuOa+4gxF1dQcd911lzVp0sTKQut1rl+/3h2zVq1ahQZEhTEtGZV30XgFtaLmKWvYsKFrvizNecuyfzzR86/y0e8Cgwkoh3jH68EfKAd/oBzCXKN24IEH2jvvvFPo/ZpPTLVLqlHKO11FaakJsW7dukXuo/MWNrpSU0JE6rxl2R8AACCsQa1mzZpusXEAAABE8ahPAAAAVOCEtxox+dlnn9nq1att586drm+WJrc97rjjwtb0CQAAEE/KHNR+/vlnu/zyy23u3LmFLiOlKSouuuiisp4KAAAgrpQpqK1YscJ69uxp69atc9+3a9fODR7QmpYrV660efPmuXUur776avvzzz/t+uuvD9d1AwAAxLwyBbUbb7zRhTRN7Prggw/mm3lfU1Tcfvvt9uyzz9qYMWPs9NNPtzZt2pT1mgFfy0pfZ6EdqRaokmLB5HoVfTkAgHgcTKCJbN977z03FcXEiRMLXB6pTp069sADD7haN01E+/rrr5f1egFf25U6w3bMHWjp865zt/oeAIByD2paYF3hq3379q7Jsyj9+/d3t0uWLCnt6YCoqEnLWPKgpjz+a0vIMpaMd9sBACjXoOYtt1ScEZ3ePnmXaAJiiZo7/w5pnqy/tgMAUI5BLSUlxd0uXLjQtmzZUuS+X3zxRfZ6n0CsUp80s0CercG/tgMAUI5BTcs0tW3b1rZu3WojRowodA1Nza325JNPuv+fcMIJpT0d4HsaOJDUdkSOl1XQktoOZ0ABAKBiRn2OHj3aBgwYYJMnT7bPP//cBg0aZK1bt3ZrfP7222/20Ucf2fTp090i5V26dHGDCoBYVimllyXUPoRRnwCAig9qvXv3tvvvv9/Nj/b777/b3XffXeB+Rx11lE2YMMGNEAVinZuSg2k5AAB+WJlgyJAhduyxx9rzzz/vmjlVk6YlpGrXru2WkDr11FOtb9++FgyyrCgAAEC5r/Wp/mpqBgUAAED4UM0FAAAQyzVqGizw66+/2tq1a12zZ2Fq1qxpHTp0CMcpAQAAYl5iWQPao48+ao888oilpu55Uk8tMzVlypSynBIAACBulCmo3XrrrTZu3Lj/HSgx0Ro2bGh77713kX3ZAAAAEOGgpkXZH374Yff/iy66yEaNGmU1atQo7eEAAAAQrqCmBdZ3797tatHuueceS0hIKO2hAAAAEM5Rn8nJye62adOmhDQAAAA/BbVWrVpZtWrV7Oeffw7vFQFhENiw1hJ+mOduUT54zgHAR0FNIW3o0KH2559/ulUJAL9InDXNql51jlW5+1/uVt+D5xwA4nJRdtWojRw50vVZO/nkk61JkyaFLhel5tI6deqU5ZTAHmt1Kj871gKhrP99H8qyys+NtcwOnS1Uuz7PXgTwnAOAT4NapUqV3FqeH330kT322GPuqyjMo4ZIC65ZnR3SPIGsLLc9k6DGcw4A8RTUHnroITcthycQCFhSUlKh+xd1HxAOWQ0aWSgQzBXWQsGg247I4DkHAB8Gta1bt9q9997r/t+zZ0/XDKoBBoQxVCQ1b+4cMtI1d6omTSFt5+CRNHvynANAfAW1pUuX2pYtW6xWrVr23HPPWZUqVcJ7ZUAp7e7ex/VJU3Onq+2hyTPieM4BwGdBTZPdimrRCGnwG4Uz+qTxnANA3Aa1li1buvU9f/vtNysvO3futF9//dWdV+uGlnY1hOIcZ/Xq1bZ2bdFzcDVr1szVKEpmZqZ9//33he7bvHlzltgCAADlE9QUUDTi84033rBp06ZZnz59LFKysrJcf7hHH33UNm/e7LbVq1fPDWQYOHBgRI6jEazeWqaF0fxxeg5EzcDHHHNMofu+8sor1qtXr2JfKwAAQJlGfY4ZM8Z++eUXu+yyy+zmm29286jts88+YX9W//3vf9u4ceOya6YyMjJcTd7w4cNdbdiAAQPCfpxGjRrZgQceWOBxVHNWuXLlAoNZzZo1XU1bXixYDwAASiqQlpYWKvGjzGz27NnWv39/V0u1a9euXHOrFTbhbbdu3ey1114r0XnURHnIIYe4IDVp0iTr0aOH2/7yyy/bsGHD3AS6Cxcu3GM/uXAdZ8GCBda1a1c766yz7Mknn8zenpaW5gLaGWecYc8880yJfsZ4kp6ebqtWrXITI3vrxYJyiFe8HvyBcvAHyiHMS0gpoKmvV86QJvpe2wv6Ug1WSU2ZMsUNXLjooouyw5Wcd9551rt3b1u/fr19/PHH5XacF154wd2ef/75Jf5ZAAAAyqXp88gjj7RFixaV6DGlqUH5+uuv3e1JJ52U7z4FrOnTp9s333yzxz5y4TiO0v6rr77qBiCoVq0wGoSwbt06q1+/vusDBwAAUK5BTX201I8r0lauXJk9DUhe3jY1a5bHcd566y3btGmTayrVKgwFUeCbPHly9vf777+/67+nMFgcCoOxzKtVLU3tKiiHWMPrwR8oB3+Il3JILmGlVZkGE5REKBSy5cuXu2k9SmLbtm3udu+99853X/Xq1bNXSSiP42iUp/rfqbm0MGribdq0qdsvNTXVFi9e7PbXSNOiHufRYzTVR6xbs2ZNRV8CKAff4PXgD5SDP8RyOSQkJLjBjL4KaitWrLCJEye6r/3228/1FSsJzXWWc4LdnLxt3j6RPM6yZcvs888/dyM91RG+oONrmo8LL7zQjfz0BhhoZOwjjzxiN954o/Xr18/VRBYlJSXFYpk+KelF2KBBA5YboxziHq8Hf6Ac/IFyKMegppopBTKNqJw7d66rTRMFtZLyQo/6fHk1Xx5tE2/S2UgeR7VpUthUIHvttZeNHDky3znvuOMO1/ftiy++sHnz5tkRRxxR5HXGy0hIrQkbLz+rn8VCOWSlr7PQjlQLVEmxYHJ09gmNhXKIBZSDP1AOEQpqCmNz5sxx4eztt9/O1YyoaSvU7Fecpr+8Wrdu7WqyvvvuO2vRokWu+7799lt326ZNm4geRyNZNWGt5kLr27dviX8G/fwKapoUF0D47EqdYRlLHtRfIM02ZEltR1ilFCaWBhA7Sj09h0cd8O+55x43OaxCjIKaF9IUUBTaVJN07bXXWuPGjUt8fG8qDc1N5tXMeZ3uJ0yYkGufSB1HAwRU66Z54wr71FtY/7Y///zTZs2a5f6v0aIAwleT9ndIk5BlLBnvtgNAXNeoqWO+AphC2WeffZYdfFRdedxxx7kBAw899JALJprktiw0WlIBT7V1Q4YMcV9qxx4/frzr/9alSxfr0KFDrv5mmpRW13LAAQeU+jglafYUNXuqT5rO461MoAl0n3jiCfv999/dZLuq1QMQHmru/DukebL+tz1Km0ABoExBTf3NFM40TYVXg6RpKjSnmmqbTj/9dNfP65NPPnFBLRzU+V4rAOj4Oq++POqQrtGUOWn6DK/DvwJbaY+Tc1oP/TyaZuOggw4q9DqrVavmVjx477338t2nvnlPP/10iX92AIVTnzQ1d+YOa8G/tgNAnAU1Nd95C5BL27ZtXejRl6ajiCTVdqnm7vHHH3fhS8NbDzvsMLfGaN26dfONvlQzbMOGDct0HI/279ixo1vRoChjx451z8/UqVNt6dKlrklVIzi7d+/unqOqVauW8VkAkJMGDqhPmpo7VZOmkJbUdnjUDigAgDIFtZz9ujR7vxY4z9spP5JUK6W+cHuiDv+qASvrcTzFHQShmkWFMn0BKB8aOJBQ+5CoH/UJAGUeTKClkLxaqmnTprk+V8cee6w99thjbskkAKgICmcJtToR0gDEd1BTx3yt7am1LtXEpz5fmtbihhtusHbt2tkZZ5zhJrVlCgoAAIAKGEygPl0nnnii+9IIx9dee80NLtD0Gx9//LH7uuqqq3KNtgQAAEA5z6OmWfcvvvhimzlzpptI9vLLL3ejJ3fs2GFff/212+err76yK6+80t0CAACgnCe8FTV93n777a5pVDP4n3LKKW4es+3bt9tzzz1nJ5xwghtd+dJLL4XjdAAAAHEhLEEt59QYvXr1shdeeMGWLFniRld26tTJ3ffTTz+5/m0AAACogKCWU+3ate3SSy91869pNYBhw4ZZ/fr1I3U6AACAmBO2RdmLosEFd955Z3mcCgAAIGZErEYNAAAAZUNQAwAA8CmCGgAAgE8R1AAAAHyKoAYAFSR1e6Z9nRZ0tyW1elumzf59p7stjbI8nnNH13NOeWWW+DkLx/MWVaM+AQC5vfDTNrtyTpplWbIFF6bZuKPMBrauVvzHzk2zrJBZMGA2rkvNYj+2rI/n3NH1nFNeVu7lFW6BtLS0UIWcGXEpPT3dVq1aZU2aNLHk5ORC9wtsWGvBNastq0EjC9Vm/r2KKofyEm/lrU/oHV77w70JeBICZt/3b2iNqiVE7LGcu+DnrU7CriJfD9H6nEfbdef8u7Q+s1JU/tyRQNMnfCdx1jSretU5VuXuf7lbfV+estLXWebG+e4WsV/eFWH55t253gQkM2T28+bdEX0s546v5zxarzuazx0JBDX4impWKj871gKhrP99H8qyys+NddvLw67UGbZj7kBLn3edu9X3iN3yrigtqie65pSc9Im9efXEiD6Wc8fXcx6t1x3N544Eghp8Rc1f3pu2J5CV5bZHmmrQMpY8aGbeR6mQZSwZT81ajJZ3RVLzifq86I+/6PaBLjWL1axSlsdy7vh6zqP1uqP53JFAHzX4qm+UalLU/JXzzTsUDNr2sa9EvO+SmjtVk5ZX8kH3WEKtThZL/NJHrSLL2w9+3rDNvlz+hx3WoqE1r12yjsrqR6OmGH3KL80bSFkeH2vnLu7rIVp/7mi57oLKYXWU/tzhxKhP+IrenHcOGfm/5q+sLPemvXPwyHJ50w5USdG/OWrUJPjXdsRaeftBStUEO6RmlrstKb1xlOXNoyyP59zR9ZxTXlYhz1u4ENTgO7u797HMDp3LfRRgMLmeJbUd4Zo7zVTDE7SktsPddsReeQNANCCowZf0Zp1ZAW/YlVJ6WULtQyy0I9XVpBHSYru8AcDvCGpAHi6cUYsGAPABRn0CAAD4FEENAADApwhqAAAAPkVQAwAA8CmCGgAAgE8R1AAAAHyKoAYAAOBTBDUAAACfipoJb3fv3m0ffvihLViwwBITE+3QQw+1rl27Ruw4Whz27rvvLvQ45513nrVu3Tpi1wkAABAVQW3NmjXWv39/+/7773NtP/744+3FF1+0KlWqhP04Cmrjxo0r9FhHHHFEvqAWrusEAACImqA2dOhQF36aNm1q/fr1s4yMDJs0aZKrubrhhhuKDFRlPc4BBxxgZ555Zr7tbdq0idh1AgAASCAtLS3k56dizpw51qdPH9t3331t1qxZVrNmTbd9xYoV1q1bN9u2bZstXLjQ9tlnn7AeJy0tzZo1a2ZnnHGGPfPMM+V2nbFONZWrVq2yJk2aWHJyckVfTtyiHPyBcvAHysEfKIcoHUwwY8YMdzts2LDs8CMKUeeee65lZmba+++/X27HqajjAwCA+OP7oLZo0SJ326VLl3z3HX300e5WNVWROs6GDRvsP//5j91555329NNP29KlSyN6nQAAAFHTR00d9KVx48b57lPzmaxduzZix/nkk0/cV05nn322jR8/3ipXrhz261TVbyxTv72ct6Ac4hmvB3+gHPwhXsohuYTdfnwf1Hbu3Oluc4aivD9sccJNaY4TDAbd1BodO3Z0/1+8eLF99NFHboBAUlKSPfTQQ2G/ztTUVNdMGuu8YIuKRTn4A+XgD5SDP8RyOSQkJFjz5s1jK6h5IUdBqGrVqrnu27Fjh7stzrQXJT2O/v/555/nG935xRdf2Omnn24TJkywUaNGWf369cN6nSkpKRbL9ElJL8IGDRq4sAvKIZ7xevAHysEfKIcoDWoaJam+Xb/++qvVqlUr130rV650t3rTD/dxVDNW0BQcmj/txBNPtClTprh+aV5QC9d1xstISIW0ePlZ/Yxy8AfKwR8oB3+gHKJsMEH79u3d7aeffprvvtmzZ7vbDh06lNtxvNUHRM2hkTg+AACAyxp+fxp69+7tbh977DFbt25d9vYff/zRJk6caJUqVbKePXuG/TgKXBs3bsx3nA8++MB9KaR54Syc1wkAABA1TZ+dO3d2SzBpdv+jjjrK+vbt69qx3377bdu+fbtddtllVq9evez9te3ee+91c5ldeeWVpT6OlnyaOnWqa+rUXGiips7//ve/7v8XXXSR1alTp9THBwAAiPqVCby5zAYMGOA69+ekVQOeeOIJV1vlWb9+vbVo0cJNiaGF0Ut7HAW12267Ld+UGtpn8ODBbl61nPuX9Pjxipmn/YFy8AfKwR8oB3+gHKK0Rk1q165t06dPdwFI4UvDW1WD1alTp3z7asTl//3f/1n16tXLdJwLLrjAzjnnHDfKc9myZW7kZqNGjdyEtoXVjJXk+AAAADFRo4bYwScmf6Ac/IFy8AfKwR8ohygdTAAAABCvCGoAAAA+RVADAADwKYIaAACATxHUAAAAfIqghogJbFhrCT/Mc7cAv2cAEKPzqCH6JM6aZpWfHWuBUJaFAkHbOWSk7e7ep6IvCzGG3zMAsY4aNYSdatC8kOa+D2VZ5efGUrMGfs8AoIQIagi74JrV2SHNE8jKctsBfs8AoPgIagi7rAaNXHNnTqFg0G0H+D0DgOIjqCHsQrXruz5pCmfu+2DQdg4e6bYD/J4BQPExmAARoYEDmR06u+ZOV8NGSAO/ZwBQYgQ1RIzCWSYBDRHG7xmAWEbTJwAAgE8R1ADEtaz0dZa5cb67BQC/oekTQNzalTrDMpY8qAZUTSJjSW1HWKWUXhV9WQCQjRo1AHFJNWh/hzQJWcaS8dSsAfAVghqAuBTakZojpHmy/toOAP5AUAMQlwJVUlxzZ27Bv7YDgD8Q1ADEpWByPdcn7e8/g0FLajvcbQcAv2AwAYC4pYEDCbUPcc2dqkkjpAHwG4IagLjmwhm1aAB8iqZPAAAAnyKoAQAA+BRBDQAAwKcIaohJLAsEAIgFDCZAzGFZIABArKBGDTGFZYEAALGEoIaYwrJAAIBYQlBDTGFZIABALCGoIaawLBAAIJZEzWCChQsX2iOPPGILFiywxMRE69y5s40YMcIaN24ckeNkZWXZZ599Zm+//bYtWbLE1q1bZ/Xr17fu3bvbRRddZDVr1sy1/5YtW6xbt26FnvfBBx8s8n6ED8sCAQBiRVQEtY8//tjOOeccy8jIyN723Xff2WuvvWYzZsywtm3bhv04I0eOtGeffTbX43/88Uf79NNP3fZp06ZZs2bNsu/LzMy0X375pdBzb9++vdg/L8qOZYEAALHA902fCjjDhg1z4WrgwIH2ySef2Pvvv28nnXSSpaWl2eWXXx6R4+zatcvVgI0dO9bVqql27fHHH7emTZva6tWr7brrrivwPD179rRvv/023xe1aQAAIOZq1KZOnWp//PGHHXfccTZ+/Pjs7S+88IIdddRR9vXXX9u8efPsoIMOCutxFNAqV66c6xjt27e3Qw891H0puBWkWrVq1rx58zL+1AAAAFFQozZ79mx3q1qwnCpVqmQDBgxw/581a1bYj5M3pHlatmxpderUcY8DAACI6xq1ZcuWudtOnTrlu8/btnTp0nI7zqJFi2z9+vV29tlnF3i/BimcfPLJuQYfXHjhhfkGHwAAAER9UFP/MVEtVl7eNm+fSB9H/dz++c9/utB18803F7iPwp4X+DRaVDV5Tz/9tE2ZMsVatWq1x+tMT0+3WOYN5Mg5oAOUQ7zi9eAPlIM/xEs5JCcnx1ZQ0zQZEgzmb6VNSEhwt7t37474cRSgBg0a5MLXpEmTrEmTJvn2UR+2oUOHWseOHd15VPumqUD0GNWqec2vRUlNTXUjSGPdmjVrKvoSQDn4Bq8Hf6Ac/CGWyyEhIaHE/dh9H9TUOV82b96c/X+Ptkn16tUjehzNkXbuuefaN998YxMnTnTNmXnVqFHDBbGcQfDggw92zaBHH320ff/99/bDDz9Yu3btirzOlJQUi2X6pKQXYYMGDSwpKamiLyduUQ7+QDn4A+XgD5RDlAa1fffd143G/Omnn2yfffbJdZ+2eftE6jh//vmnnXnmmW6fV155pcCQJoFAwH3lpWZSTc3x8ssv22+//bbHoFbSKtFopZAWLz+rn1EO/kA5+APl4A+UQ5SN+tTKAd70GnlpfrOc+4T7OKtWrXLzrKnPmZo7CwtpRQmFQm6iXK/WDQAAIGaC2mmnneamytBqAOqQ7/U3e+yxx+zDDz+0hg0bWo8ePcJ+HIUzhTTNvfb6669b165dizy+5mZTmPOaUeX333+34cOHuybT2rVr24EHHliGZwIAAMQb3zd9NmrUyK6++mq74447XGf+unXrulUDNm3a5O6/++67c815ppGbxxxzjOvrpWWeSnucm266yTVVqt+aVjQoyMyZM7On3dAaoqNHj841ilTTeIiaRMeMGRN1fbICG9ZacM1qy2rQyEK161f05QARwe85AD/zfVCTa665xg0A0MLm3miQ/fbbzwUj1ZTl5K25WdAIzpIeR1RDlrOWrKB95Morr7QqVarYO++8kx3QNCnukUce6c67pxo5v0mcNc0qPzvWAqEsCwWCtnPISNvdvU9FXxYQVvyeA/C7QFpaWsiihJoq165d64a31qtXr9B9VqxYYYmJiW5dztIeR02ee1pIXYuyFzTdx4YNG2zHjh3u2NFWi+bVMFS96hwX0jyhYNC2j32lzDVrmuZEff80vQmDCSoO5RDZ33PKIbrwevAHyiGKa9Q8CkXqS7anffY0R0lxjrOn+4ui/mjRTM2dOd+8JJCV5bZn0gSKGMHvOYBo4PvBBCh/rk9aIPevhmoatB2IFfyeA4gGBDXko2Yf9UlTOHPfB4O2c/BIBhQgpvB7DiAaRFXTJ8qPBg5kdujMqE/ENH7PAfgdQQ1F1jjQJw2xjt9zAH5G0ycAAIBPEdQAAAB8iqAGAADgUwQ1AAAAnyKoAQAA+BRBDQAAwKcIagAAAD5FUAMAAPApghoAAIBPEdQAAAB8iqAGAADgUwQ1AAAAnyKoAQAA+BRBDQAAwKcIagAAAD5FUAMAAPApghoAAIBPEdQAAAB8iqAGAADgUwQ1AAAAnyKoAQAA+BRBDQAAwKcIagAAAD5FUAMAAPApghoAAIBPEdQAAAB8KtGiyO7du2316tWWmJhoKSkpFggEyuU4kd4fAAAgamvUQqGQjRs3zlq3bm2dOnWyAw44wNq3b2+TJk2K6HEivT8AAEDU16jdeeedNmbMGPf/Ro0a2a5du1yN1aWXXmrBYND69+8fkeNEen8AAICorlFbtWqVq6VKSkpyNVOLFi2yH3/80R544AF3/0033WTp6elhP06k9wcAAIj6oPbWW2+5mqmhQ4daz5493Tb1+RoyZIj7fu3atTZz5sywHyfS+5eXwIa1lvDDPHcbTbLS11nmxvnuFvCr0B9LLPTd6+4WAOIyqH399dfutnfv3vnu69u3b659wnmcSO9fHhJnTbOqV51jVe7+l7vV99FgV+oM2zF3oKXPu87d6nvAb7Jm3mvbF46w7Rv+4271PQDEXR+1lStXult10M+rVatW7vbXX38N+3EivX9hwtU8Gty4zqo9e58FQiH3fSCUZZWfG2vbW3e0rFr1rKJkZGTkus0rtPNPy1ryoP7nbbGMJeNtd7X2FqhctxyvNLbtqRxQtMDaH2135kdmwb9GdAcDtiPzI0tc2dNC9dtQDlGG14M/xEs5JCcnx1ZQ27p1q7utXr16vvtq1Kjhbrds2RL240R6/8KkpqZaZmamldVeK5ZY7b9CmieQlWXrF863rc2K/0YSKWvWrClwe1L6T1Y3O6R5smztyu8tI/l/gReRLwcUrcaKuVYtIc+0O8GAbfrxc9u0syrlEKV4PfhDLJdDQkKCNW/ePLaCmuYi8+Ymy8vb5u0TzuNEev/CaN61cAjulWyhQCC7Rk1CwaDVad/JalVwjZpehA0aNHADL/IK7axiWev0BpgzrAWtftOO1KiVYzmgaIHKXWz3T2/9XaMmWSGr0eZIq16/CeUQZXg9+APlEKVBrWbNmu52/fr1+Wqr1q1bl2ufcB4n0vuHq0q0UPs0sZ1DrnbNnapJU0jbOXikJe1T/DeRSFI4KPBnTW5su9qOcM2dqklTSEtqO9wq1WhcEZcZ8wotBxStaSertPw419zpwlpWyKokHGfBpp0ohyjG68EfKIcoC2rq3/X555/bd999Z/vtt1+u++bPn19ov7CyHifS+5eH3d37WGaHzhZcs9qyGjSyUO36Fg0qpfSyhNqHWGhHqgWqpFgwueJqAIHCBI+51qr+cYrZH4vMGh5ggYZtebIAxN+oz+7du7vb5557Ltf2nTt32oQJE9z/e/ToEfbjRHr/8qJwltnuoKgJaR6Fs4RanQhp8DWFs8CB/QhpAOI3qGm6C/XbmjVrlpvh/4svvrDZs2fbeeedZ8uXL7fDDz/cLdfkUUf8xYsX27Jly8p0nEjvDwAAsCeBtLS0vEPsfEfh5+yzz843dUWdOnXsvffes5YtW2ZvUx+xFi1aWJMmTWzBggWlPk557B+P9NxoFQeVD32jKId4x+vBHygHf6AcorSPmtesqNqpRx991IUvDW/t3LmzXXHFFdawYcNc++q+du3a2T777FOm45TH/gAAAFFfo4bYwScmf6Ac/IFy8AfKwR8ohyjtowYAABCvCGoAAAA+RVADAADwKYIaAACATxHUAAAAfIqghnKnaUtQ8SgHf6Ac/IFy8AfKIT+m5wAAAPApatQAAAB8iqAGAADgUwQ1AAAAnyKoAQAA+BRBDQAAwKcIagAAAD5FUAMAAPApghoAAIBPEdRQ4d555x37888/K/oyAAAV4P3337fVq1fz3BcisbA7gEjbuXOnnXXWWTZr1iwbPHiwjRs3jie9AqxatcrefPNNW7p0qdWtW9fOPvtsa9u2LWURAb/++qs9//zz9sMPP1iDBg3snHPOsSOOOILnupz98ccf9sYbb9iPP/5oNWvWtH79+lmnTp0oh3KWlZVl5557rr333nvWv39/e+qppyiDArCEFCrM77//bu3atbPExET3glVg69ChAyVSTnbt2mW33nqrPfHEE+7/nqSkJJswYYKdeOKJlEUYTZw40f71r39Zenp6ru0XX3yx3X333axxWA70d0bP9YMPPug+KOZcX1Ih4YwzziiPy8BfNm/ebE2bNnXvAbt373aB7fDDD+f5yYOmT1SYHTt2uNt//vOf7g/o9ddfT2mUk4yMDPdJ9uGHH3bPfe/eve3aa6+1Hj16uPuuuOIKd4vwmDdvnl1++eUupKnm4IUXXnAhuVatWi4gKMAhsjIzM23o0KF27733ut/tE044wa677jrr2bOnu+/KK6+0LVu2UAwV9B4geg8IhUKUQR4ENVSY7du3u9vLLrvM1aTNmTPHpkyZQomUA4WGDz/80Bo3buxuX375ZbvxxhvtrbfestNOO83WrFlj3377LWURJs8884wLA5dccokLZqeccoqNGDHCZs6caY0aNXLB7aWXXuL5jiCFAP1+169f36ZNm2avvfaa3XDDDTZp0iQbOHCgq92ZO3cuZVABQU1dX9QFQB9o9LcIuRHUUOFBbe+993bNETJq1Kh8TUMIL9WgqY+UQtqMGTPsoIMOynW/1/yzbds2nvow+fnnn92t+mTm1KxZM3vuuedc09tNN93EoJoIWrx4sdWrV8/effdd69KlS6771EdN+J0vX97zXa1aNbvrrrssEAjYv//9b2o28yCooUI/TemFWbVqVTvqqKNcTc7KlSvtoYceolQiKBgM2pgxY1yfKYW1vLz+aqrpQXhUr14914eTnDp37mwXXXSRpaWlMaAmghQEXnzxRWvRokW++/idr9gaNQU1fWA877zzXG3+2LFjK+iK/Imghgr9NKUXqMKa6JNUcnKye7P67bff3B9VNVEg/NTMUNjAjUWLFrm+U61bt+apDxOFMdGAmYJcc8017gOLateo1YmMjh07FjrCVr/z+tvDyM/ypd9178O6jB492rWwPPbYY/bLL7+4ZunJkydbvCOooUI/TSmoeTT6R32n9OLVH1R1aFdnX+9TF8qHwkT37t1dzRvCQ83JekPSh4+Cfp81LYr6rW3dutXNKYXy/50/8sgjXVhD+b8HeB/WNWXNyJEj3Yjcrl272qWXXureA9R/MJ7xlxgVRs1AOYOaatE0l5foDUtNb7fccotVqVKFUion3iCCY489luc8jNQX7eSTT7a1a9fao48+WuA+3bp1c7cM4ihfGun52Wef8Tvvgw/rmt9OfQm99wAFt1tuucXVssUzghoqPKjpxarBBIcddpgb9ak5daRhw4bZnXxRPtTRWsPjjznmmOxtKp977rnHli9fTjGUwc033+zmqLvvvvvsp59+ynd/5cqVs9+gUH406lnTdeT8ndf348ePt/nz51MU5dD9RTVoDzzwgB166KH26quvZr8H1KlTx00K7dW4xSuCGiqMAoBqGNR/R0FNwe300093NQoKbd98843r8I7yM336dGvVqpU1adLEfa/+ISofdcTWqESUnvr8XX311e733us0nXMkrjctQUGd3RHZ33nV3BxwwAHZ36vrhfpLMbdjZOm1sGnTJjfJreYV1IcUzen41VdfuTkdVbv2zDPPWLwjqKFcfPfddy6EaVSnR5+W9GalJk914lVtzrPPPuv6qim46VPU22+/TQlFuBw8+iOpvjqqWdB+vXr1chOEqnzUEXv48OGURRkpqGmC1WXLlrmmTg2c0RxqenP66KOP3HJGqkFA+dBs+OoTqFCwZMkSN/JcIVrTqWgZNQ3yQOSob+b69ettxYoVtv/++7sWFX1g2W+//dyHQ01b8zbvASwhhchSjdltt93mJvNUrYEmllSTgvdHUqFBM7Wff/75+TqvawJcdfClU3tky8GjP5KDBg1yfzD1pqX9NDmomuwKKh+Ujpp5hg0b5taazEm1OloHlLU/y48+mJx66qluKTs1R2tSYo14Vk3ahRdemN0Eh8hQN4szzzzTTjrpJBsyZEi+ZdQ0AfHhhx8e98ursdYnIkJ9PLSGpObr0ogd9c3RrOxapsibUwr+KgfN0q5h8aL9NOJKNQqUV+RCgmoL1Jn94IMPdjU5PNflSzX33mTbCgmqQdYKHQprgF8Q1BB2asJUfyZvNnY19dx55530vfF5OWi1Ag2JP/74491+zZs3L+crBsqXugCoP6xq7vU7r5o1wG8IaggbNZepVkbrF0qbNm1cPwOmeoieclDfqZYtW5bDVQL+wO88/I6ghjLbuHGj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", "text/plain": [ "
" ] @@ -242,7 +264,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 43, "metadata": {}, "outputs": [ { @@ -283,7 +305,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 44, "metadata": {}, "outputs": [], "source": [ @@ -306,16 +328,16 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 45, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "\u001b[32m2026-06-29 17:00:21.912\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timex_lca\u001b[0m:\u001b[36m__init__\u001b[0m:\u001b[36m136\u001b[0m - \u001b[1mInitializing TimexLCA object...\u001b[0m\n", - "\u001b[32m2026-06-29 17:00:21.913\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timex_lca\u001b[0m:\u001b[36m__init__\u001b[0m:\u001b[36m153\u001b[0m - \u001b[1mCalculating base LCA...\u001b[0m\n", - "\u001b[32m2026-06-29 17:00:34.983\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timex_lca\u001b[0m:\u001b[36m__init__\u001b[0m:\u001b[36m170\u001b[0m - \u001b[1mCollecting node infos...\u001b[0m\n" + "\u001b[32m2026-06-30 16:12:21.142\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timex_lca\u001b[0m:\u001b[36m__init__\u001b[0m:\u001b[36m136\u001b[0m - \u001b[1mInitializing TimexLCA object...\u001b[0m\n", + "\u001b[32m2026-06-30 16:12:21.154\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timex_lca\u001b[0m:\u001b[36m__init__\u001b[0m:\u001b[36m153\u001b[0m - \u001b[1mCalculating base LCA...\u001b[0m\n", + "\u001b[32m2026-06-30 16:12:37.548\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timex_lca\u001b[0m:\u001b[36m__init__\u001b[0m:\u001b[36m170\u001b[0m - \u001b[1mCollecting node infos...\u001b[0m\n" ] } ], @@ -340,16 +362,16 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": 46, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "\u001b[32m2026-06-30 09:10:42.128\u001b[0m | \u001b[33m\u001b[1mWARNING \u001b[0m | \u001b[36mbw_timex.timex_lca\u001b[0m:\u001b[36mbuild_timeline\u001b[0m:\u001b[36m309\u001b[0m - \u001b[33m\u001b[1mtraverse_background=True with graph_traversal='priority': non-referenced background variants are not placed on the priority heap; each variant subtree is walked in full via proxy reads when its parent edge is reached. The referenced-system heap exploration order is unchanged and explored amounts are exact (identical to graph_traversal='bfs' for these subtrees).\u001b[0m\n", - "\u001b[32m2026-06-30 09:10:42.131\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timex_lca\u001b[0m:\u001b[36mbuild_timeline\u001b[0m:\u001b[36m363\u001b[0m - \u001b[1mCreating activity time mapping...\u001b[0m\n", - "\u001b[32m2026-06-30 09:10:43.376\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timeline_builder\u001b[0m:\u001b[36m__init__\u001b[0m:\u001b[36m112\u001b[0m - \u001b[1mTraversing supply chain graph...\u001b[0m\n" + "\u001b[32m2026-06-30 16:12:40.501\u001b[0m | \u001b[33m\u001b[1mWARNING \u001b[0m | \u001b[36mbw_timex.timex_lca\u001b[0m:\u001b[36mbuild_timeline\u001b[0m:\u001b[36m309\u001b[0m - \u001b[33m\u001b[1mtraverse_background=True with graph_traversal='priority': non-referenced background variants are not placed on the priority heap; each variant subtree is walked in full via proxy reads when its parent edge is reached. The referenced-system heap exploration order is unchanged and explored amounts are exact (identical to graph_traversal='bfs' for these subtrees).\u001b[0m\n", + "\u001b[32m2026-06-30 16:12:40.502\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timex_lca\u001b[0m:\u001b[36mbuild_timeline\u001b[0m:\u001b[36m363\u001b[0m - \u001b[1mCreating activity time mapping...\u001b[0m\n", + "\u001b[32m2026-06-30 16:12:40.925\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timeline_builder\u001b[0m:\u001b[36m__init__\u001b[0m:\u001b[36m112\u001b[0m - \u001b[1mTraversing supply chain graph...\u001b[0m\n" ] }, { @@ -380,9 +402,9 @@ "name": "stderr", "output_type": "stream", "text": [ - "\u001b[32m2026-06-30 09:27:32.713\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timeline_builder\u001b[0m:\u001b[36mbuild_timeline\u001b[0m:\u001b[36m186\u001b[0m - \u001b[1mBuilding timeline...\u001b[0m\n", - "\u001b[32m2026-06-30 09:27:33.201\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timeline_builder\u001b[0m:\u001b[36mget_weights_for_interpolation_between_nearest_years\u001b[0m:\u001b[36m623\u001b[0m - \u001b[1mReference date 1881-01-01 00:00:00 is lower than all provided dates. Data will be taken from the closest higher year.\u001b[0m\n", - "\u001b[32m2026-06-30 09:27:33.204\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timeline_builder\u001b[0m:\u001b[36mget_weights_for_interpolation_between_nearest_years\u001b[0m:\u001b[36m630\u001b[0m - \u001b[1mReference date 2041-01-01 00:00:00 is higher than all provided dates. Data will be taken from the closest lower year.\u001b[0m\n" + "\u001b[32m2026-06-30 16:42:30.811\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timeline_builder\u001b[0m:\u001b[36mbuild_timeline\u001b[0m:\u001b[36m186\u001b[0m - \u001b[1mBuilding timeline...\u001b[0m\n", + "\u001b[32m2026-06-30 16:42:31.403\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timeline_builder\u001b[0m:\u001b[36mget_weights_for_interpolation_between_nearest_years\u001b[0m:\u001b[36m623\u001b[0m - \u001b[1mReference date 1881-01-01 00:00:00 is lower than all provided dates. Data will be taken from the closest higher year.\u001b[0m\n", + "\u001b[32m2026-06-30 16:42:31.419\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timeline_builder\u001b[0m:\u001b[36mget_weights_for_interpolation_between_nearest_years\u001b[0m:\u001b[36m630\u001b[0m - \u001b[1mReference date 2041-01-01 00:00:00 is higher than all provided dates. Data will be taken from the closest lower year.\u001b[0m\n" ] }, { @@ -562,7 +584,7 @@ "[6478 rows x 6 columns]" ] }, - "execution_count": 29, + "execution_count": 46, "metadata": {}, "output_type": "execute_result" } @@ -579,15 +601,15 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": 47, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "\u001b[32m2026-06-30 09:27:43.002\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timex_lca\u001b[0m:\u001b[36mlci\u001b[0m:\u001b[36m513\u001b[0m - \u001b[1mExpanding matrices...\u001b[0m\n", - "\u001b[32m2026-06-30 09:28:07.251\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timex_lca\u001b[0m:\u001b[36mlci\u001b[0m:\u001b[36m532\u001b[0m - \u001b[1mCalculating dynamic inventory...\u001b[0m\n" + "\u001b[32m2026-06-30 16:42:32.486\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timex_lca\u001b[0m:\u001b[36mlci\u001b[0m:\u001b[36m513\u001b[0m - \u001b[1mExpanding matrices...\u001b[0m\n", + "\u001b[32m2026-06-30 16:52:10.902\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mbw_timex.timex_lca\u001b[0m:\u001b[36mlci\u001b[0m:\u001b[36m532\u001b[0m - \u001b[1mCalculating dynamic inventory...\u001b[0m\n" ] } ], @@ -598,7 +620,7 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 48, "metadata": {}, "outputs": [ { @@ -607,7 +629,7 @@ "6.89061727369748" ] }, - "execution_count": 31, + "execution_count": 48, "metadata": {}, "output_type": "execute_result" } @@ -618,7 +640,7 @@ }, { "cell_type": "code", - "execution_count": 32, + "execution_count": 49, "metadata": {}, "outputs": [ { @@ -627,7 +649,7 @@ "4.953254842324384" ] }, - "execution_count": 32, + "execution_count": 49, "metadata": {}, "output_type": "execute_result" } @@ -638,7 +660,7 @@ }, { "cell_type": "code", - "execution_count": 35, + "execution_count": 50, "metadata": {}, "outputs": [], "source": [ @@ -647,14 +669,14 @@ }, { "cell_type": "code", - "execution_count": 37, + "execution_count": 57, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "\u001b[32m2026-06-30 09:33:52.123\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mdynamic_characterization.dynamic_characterization\u001b[0m:\u001b[36mcharacterize\u001b[0m:\u001b[36m126\u001b[0m - \u001b[1mNo custom dynamic characterization functions provided. Using default dynamic characterization functions. The flows that are characterized are based on the selection of the initially chosen impact category.\u001b[0m\n" + "\u001b[32m2026-07-01 16:02:46.134\u001b[0m | \u001b[1mINFO \u001b[0m | \u001b[36mdynamic_characterization.dynamic_characterization\u001b[0m:\u001b[36mcharacterize\u001b[0m:\u001b[36m126\u001b[0m - \u001b[1mNo custom dynamic characterization functions provided. Using default dynamic characterization functions. The flows that are characterized are based on the selection of the initially chosen impact category.\u001b[0m\n" ] }, { @@ -690,35 +712,35 @@ " 1991-01-01\n", " 2.502334e-11\n", " 267655084047331383\n", - " 329999541917872830\n", + " 330349846773789374\n", " \n", " \n", " 1\n", " 1991-01-01\n", " 3.910796e-09\n", " 267655084047331383\n", - " 329999541917872831\n", + " 330349846773789375\n", " \n", " \n", " 2\n", " 1991-01-01\n", " 2.297956e-07\n", " 267655084047331383\n", - " 329999541917872842\n", + " 330349846773789386\n", " \n", " \n", " 3\n", " 1992-01-01\n", " 5.004668e-11\n", " 267655084047331383\n", - " 329999541917872857\n", + " 330349846773789401\n", " \n", " \n", " 4\n", " 1992-01-01\n", " 7.821593e-09\n", " 267655084047331383\n", - " 329999541917872858\n", + " 330349846773789402\n", " \n", " \n", " ...\n", @@ -769,11 +791,11 @@ ], "text/plain": [ " date amount flow activity\n", - "0 1991-01-01 2.502334e-11 267655084047331383 329999541917872830\n", - "1 1991-01-01 3.910796e-09 267655084047331383 329999541917872831\n", - "2 1991-01-01 2.297956e-07 267655084047331383 329999541917872842\n", - "3 1992-01-01 5.004668e-11 267655084047331383 329999541917872857\n", - "4 1992-01-01 7.821593e-09 267655084047331383 329999541917872858\n", + "0 1991-01-01 2.502334e-11 267655084047331383 330349846773789374\n", + "1 1991-01-01 3.910796e-09 267655084047331383 330349846773789375\n", + "2 1991-01-01 2.297956e-07 267655084047331383 330349846773789386\n", + "3 1992-01-01 5.004668e-11 267655084047331383 330349846773789401\n", + "4 1992-01-01 7.821593e-09 267655084047331383 330349846773789402\n", "... ... ... ... ...\n", "2106 2030-01-01 4.897550e-04 267655084861026358 267663074611687428\n", "2107 2030-01-01 6.897742e-04 267655084890386433 267662799448567809\n", @@ -784,7 +806,7 @@ "[2111 rows x 4 columns]" ] }, - "execution_count": 37, + "execution_count": 57, "metadata": {}, "output_type": "execute_result" } @@ -795,7 +817,7 @@ }, { "cell_type": "code", - "execution_count": 38, + "execution_count": 52, "metadata": {}, "outputs": [ { @@ -837,7 +859,7 @@ ], "metadata": { "kernelspec": { - "display_name": "bw-timex (3.13.9)", + "display_name": ".venv (3.12.12)", "language": "python", "name": "python3" }, @@ -856,4 +878,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} \ No newline at end of file +}