Skip to content
81 changes: 62 additions & 19 deletions bw_timex/edge_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -382,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,
Expand All @@ -398,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.
Expand Down Expand Up @@ -507,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,
Expand All @@ -525,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

Expand Down
45 changes: 40 additions & 5 deletions bw_timex/timeline_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,12 +446,47 @@ 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:
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:
"""Producers that are leaves (never traversed into) and live in a static
Expand Down
Original file line number Diff line number Diff line change
@@ -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', '<code>'), 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.
Loading