Skip to content

Commit 502ed22

Browse files
authored
Add optional reference (production) exchange column to datapackage schema (#108)
Adds an optional per-exchange `reference` boolean column, stored as its own side-resource (`kind="reference"`) exactly like `flip`/`rescale`: additive, backward compatible, and written only when at least one entry is flagged. Modellers can now record which technosphere exchange is the reference (production) exchange, so consumers such as bw_graph_tools can read it directly instead of relying on structural heuristics, which cannot disambiguate co-production columns (an activity with multiple same-sign outputs whose products also appear in other columns). - MatrixEntry.reference / ArrayEntry.reference - reference_array kwarg on all add_* methods + _add_reference_array_resource - threaded through dictionary_formatter / resolve_dict_iterator - "reference" added to Parquet kind mapping and merging suffix whitelist - README section, CHANGES entry, version bump 1.5 -> 1.6 - tests: dataclass defaults/validation, low-level validation, skip-when-empty, write->reload round-trip Refs cauldron/brightway-api#739
1 parent d0a742d commit 502ed22

10 files changed

Lines changed: 376 additions & 18 deletions

File tree

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# `bw_processing` Changelog
22

3+
## [1.6] - 2026-07-10
4+
5+
* Add optional `reference` boolean column marking reference (production) exchanges. Stored as a `reference_array` side-resource (`kind="reference"`) analogous to `flip`, and exposed on `MatrixEntry`/`ArrayEntry` and all `add_*` methods. Lets modellers record the reference exchange explicitly instead of relying on `bw_graph_tools`' structural heuristics, which cannot disambiguate co-production columns. See cauldron/brightway-api#739.
6+
37
## [1.5] - 2026-06-04
48

59
* [PR #100: Deduplicate chunked bucket-fill logic; fix #95 and #97](https://github.com/brightway-lca/bw_processing/pull/100)

README.md

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,12 @@ print(data_obj.url)
168168

169169
### Scale arrays
170170

171-
Any resource group (persistent or dynamic, vector or array) can carry an optional `scale_array`: a one-dimensional float array of the same length as `indices_array`. Each element is a multiplicative factor applied to the corresponding data value before it is inserted into the matrix. The factor is applied to both static and stochastically-sampled values. A value of `1.0` leaves the data unchanged.
171+
Any resource group (persistent or dynamic, vector or array) can carry an optional `rescale_array`: a one-dimensional float array of the same length as `indices_array`. Each element is a multiplicative factor applied to the corresponding data value before it is inserted into the matrix. The factor is applied to both static and stochastically-sampled values. A value of `1.0` leaves the data unchanged.
172172

173173
Typical use cases:
174174

175-
* **Allocation factors** — when a process produces multiple products, the exchange amounts must be partitioned between them. Storing the allocation coefficients as a `scale_array` keeps them alongside the data they modify without requiring a separate processing step.
176-
* **Unit conversions** — when source data is expressed in a unit that differs from the matrix convention, a constant conversion factor can be stored as a `scale_array` rather than baked into every data value.
175+
* **Allocation factors** — when a process produces multiple products, the exchange amounts must be partitioned between them. Storing the allocation coefficients as a `rescale_array` keeps them alongside the data they modify without requiring a separate processing step.
176+
* **Unit conversions** — when source data is expressed in a unit that differs from the matrix convention, a constant conversion factor can be stored as a `rescale_array` rather than baked into every data value.
177177

178178
```python
179179
import numpy as np
@@ -183,18 +183,45 @@ from bw_processing.constants import INDICES_DTYPE
183183
dp = create_datapackage()
184184
indices_array = np.array([(1, 4), (2, 5), (3, 6)], dtype=INDICES_DTYPE)
185185
data_array = np.array([100.0, 200.0, 300.0])
186-
scale_array = np.array([0.6, 1.0, 0.4]) # e.g. allocation factors
186+
rescale_array = np.array([0.6, 1.0, 0.4]) # e.g. allocation factors
187187

188188
dp.add_persistent_vector(
189189
matrix="technosphere",
190190
name="my-process",
191191
indices_array=indices_array,
192192
data_array=data_array,
193-
scale_array=scale_array,
193+
rescale_array=rescale_array,
194194
)
195195
```
196196

197-
The stored resource has `kind="scale"` and can be retrieved via `dp.get_resource("my-process.scale")`. The `scale_array` must be a float dtype (`float32` or `float64`); passing an integer array raises `WrongDatatype`.
197+
The stored resource has `kind="rescale"` and can be retrieved via `dp.get_resource("my-process.rescale")`. The `rescale_array` must be a float dtype (`float32` or `float64`); passing an integer array raises `WrongDatatype`.
198+
199+
### Reference (production) exchanges
200+
201+
Any resource group can also carry an optional `reference_array`: a one-dimensional boolean array of the same length as `indices_array`. Where an element is `True`, that exchange is the **reference (production) exchange** for its activity/column.
202+
203+
The five structural heuristics in `bw_graph_tools` (matching ids, single non-flipped entry, single positive, single negative, unique product) cannot always identify the reference exchange — whenever an activity has more than one same-sign exchange and the products also appear in other columns, the choice is genuinely ambiguous. Only the modeller knows the answer. `reference_array` records it directly so downstream tools can read it instead of guessing.
204+
205+
```python
206+
import numpy as np
207+
from bw_processing import create_datapackage
208+
from bw_processing.constants import INDICES_DTYPE
209+
210+
dp = create_datapackage()
211+
indices_array = np.array([(1, 4), (2, 5), (3, 6)], dtype=INDICES_DTYPE)
212+
data_array = np.array([1.0, 0.5, 2.0])
213+
reference_array = np.array([True, False, False]) # first exchange is the reference
214+
215+
dp.add_persistent_vector(
216+
matrix="technosphere",
217+
name="my-process",
218+
indices_array=indices_array,
219+
data_array=data_array,
220+
reference_array=reference_array,
221+
)
222+
```
223+
224+
The stored resource has `kind="reference"` and can be retrieved via `dp.get_resource("my-process.reference")`. It must be a boolean array; passing a non-boolean array raises `WrongDatatype`. To keep the common case cheap, the resource is written only when at least one entry is `True` — a group with no reference flags carries no `reference` resource. Using the high-level `MatrixEntry`/`ArrayEntry` API, set `reference=True` (or a boolean `reference` array) on the entries you want flagged.
198225

199226
### Parameter arrays for sensitivity analysis
200227

src/bw_processing/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"UndefinedInterface",
3636
)
3737

38-
__version__ = "1.5"
38+
__version__ = "1.6"
3939

4040

4141
from bw_processing.array_creation import create_array, create_structured_array

src/bw_processing/datapackage.py

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,7 @@ def add_persistent_vector_from_iterator(
512512
distributions_array,
513513
flip_array,
514514
rescale_array,
515+
reference_array,
515516
) = resolve_dict_iterator(dict_iterator, nrows)
516517
self.add_persistent_vector(
517518
matrix=matrix,
@@ -522,6 +523,7 @@ def add_persistent_vector_from_iterator(
522523
flip_array=flip_array,
523524
distributions_array=distributions_array,
524525
rescale_array=rescale_array,
526+
reference_array=reference_array,
525527
matrix_serialize_format_type=matrix_serialize_format_type,
526528
**kwargs,
527529
)
@@ -538,6 +540,8 @@ def add_entries(
538540
High-level convenience method that does not require working directly
539541
with NumPy arrays. If any entry has a ``rescale`` value other than
540542
``1.0``, the rescale values are stored as a ``rescale_array`` resource.
543+
If any entry has ``reference=True``, the reference flags are stored as
544+
a ``reference_array`` resource (``kind="reference"``).
541545
542546
Args:
543547
matrix: Name of the target matrix (e.g. ``"technosphere"``).
@@ -562,6 +566,8 @@ def add_array_entries(
562566
Each :class:`.ArrayEntry` becomes one persistent-array resource group.
563567
Resource group names are auto-generated. If an entry has a ``rescale``
564568
array it is stored as a ``rescale_array`` resource (``kind="rescale"``).
569+
If an entry has a ``reference`` array with any ``True`` value it is
570+
stored as a ``reference_array`` resource (``kind="reference"``).
565571
566572
Args:
567573
matrix: Name of the target matrix (e.g. ``"technosphere"``).
@@ -577,6 +583,7 @@ def add_array_entries(
577583
data_array=entry.data,
578584
flip_array=entry.flip,
579585
rescale_array=entry.rescale,
586+
reference_array=entry.reference,
580587
)
581588

582589
def add_persistent_vector(
@@ -589,6 +596,7 @@ def add_persistent_vector(
589596
flip_array: Optional[np.ndarray] = None,
590597
distributions_array: Optional[np.ndarray] = None,
591598
rescale_array: Optional[np.ndarray] = None,
599+
reference_array: Optional[np.ndarray] = None,
592600
params_array: Optional[np.ndarray] = None,
593601
param_labels: Optional[list] = None,
594602
param_label_schema: Optional[AnyLabelSchema] = None,
@@ -605,6 +613,12 @@ def add_persistent_vector(
605613
factors and unit conversions. A value of ``1.0`` leaves the data
606614
unchanged.
607615
616+
``reference_array`` is an optional 1-D boolean array of the same length
617+
as ``indices_array``. Where ``True``, that entry is the reference
618+
(production) exchange for its column. It is stored as a
619+
``reference_array`` resource (``kind="reference"``) only when at least
620+
one entry is flagged.
621+
608622
``params_array`` is an optional 1-D float array recording the values of
609623
independent variables (e.g. model parameters) used to generate this
610624
resource group. ``param_labels`` is an optional list of label objects
@@ -713,6 +727,15 @@ def add_persistent_vector(
713727
matrix_serialize_format_type=matrix_serialize_format_type,
714728
**kwargs,
715729
)
730+
if reference_array is not None:
731+
self._add_reference_array_resource(
732+
reference_array=reference_array,
733+
indices_array=indices_array,
734+
name=name,
735+
keep_proxy=keep_proxy,
736+
matrix_serialize_format_type=matrix_serialize_format_type,
737+
**kwargs,
738+
)
716739
if params_array is not None:
717740
params_array = load_bytes(params_array)
718741
if params_array.ndim != 1:
@@ -751,6 +774,7 @@ def add_persistent_array(
751774
name: Optional[str] = None,
752775
flip_array: Optional[np.ndarray] = None,
753776
rescale_array: Optional[np.ndarray] = None,
777+
reference_array: Optional[np.ndarray] = None,
754778
params_array: Optional[np.ndarray] = None,
755779
param_labels: Optional[list] = None,
756780
param_label_schema: Optional[AnyLabelSchema] = None,
@@ -767,6 +791,12 @@ def add_persistent_array(
767791
factors and unit conversions. A value of ``1.0`` leaves the data
768792
unchanged.
769793
794+
``reference_array`` is an optional 1-D boolean array of the same length
795+
as ``indices_array``. Where ``True``, that entry is the reference
796+
(production) exchange for its column. It is stored as a
797+
``reference_array`` resource (``kind="reference"``) only when at least
798+
one entry is flagged.
799+
770800
``params_array`` is an optional 2-D float array of shape
771801
``(n_params, n_scenarios)`` where ``n_scenarios`` must equal
772802
``data_array.shape[1]``. It records the independent variable values
@@ -850,6 +880,15 @@ def add_persistent_array(
850880
matrix_serialize_format_type=matrix_serialize_format_type,
851881
**kwargs,
852882
)
883+
if reference_array is not None:
884+
self._add_reference_array_resource(
885+
reference_array=reference_array,
886+
indices_array=indices_array,
887+
name=name,
888+
keep_proxy=keep_proxy,
889+
matrix_serialize_format_type=matrix_serialize_format_type,
890+
**kwargs,
891+
)
853892
if params_array is not None:
854893
params_array = load_bytes(params_array)
855894
if params_array.ndim != 2:
@@ -912,7 +951,7 @@ def write_modified(self):
912951
if kind == "indices":
913952
meta_object = "vector"
914953
meta_type = "indices"
915-
elif kind in ("flip", "rescale", "params"):
954+
elif kind in ("flip", "rescale", "reference", "params"):
916955
meta_object = "vector"
917956
meta_type = "generic"
918957
elif kind == "distributions":
@@ -986,6 +1025,41 @@ def _add_rescale_array_resource(
9861025
**kwargs,
9871026
)
9881027

1028+
def _add_reference_array_resource(
1029+
self,
1030+
*,
1031+
reference_array: np.ndarray,
1032+
indices_array: np.ndarray,
1033+
name: str,
1034+
keep_proxy: bool,
1035+
matrix_serialize_format_type: Optional[MatrixSerializeFormat],
1036+
**kwargs,
1037+
) -> None:
1038+
reference_array = load_bytes(reference_array)
1039+
if reference_array.dtype != bool:
1040+
raise WrongDatatype(
1041+
"`reference_array` dtype is {}, but must be `bool`".format(reference_array.dtype)
1042+
)
1043+
if reference_array.shape != indices_array.shape:
1044+
raise ShapeMismatch(
1045+
"`reference_array` shape ({}) doesn't match `indices_array` ({}).".format(
1046+
reference_array.shape, indices_array.shape
1047+
)
1048+
)
1049+
# If no references flagged, don't need to store it
1050+
if reference_array.sum():
1051+
self._add_numpy_array_resource(
1052+
array=reference_array,
1053+
group=name,
1054+
name=name + ".reference",
1055+
kind="reference",
1056+
keep_proxy=keep_proxy,
1057+
matrix_serialize_format_type=matrix_serialize_format_type,
1058+
meta_object="vector",
1059+
meta_type="generic",
1060+
**kwargs,
1061+
)
1062+
9891063
@staticmethod
9901064
def _check_params_args(
9911065
params_array: Optional[np.ndarray],
@@ -1133,6 +1207,7 @@ def add_dynamic_vector(
11331207
name: Optional[str] = None,
11341208
flip_array: Optional[np.ndarray] = None, # Not interface
11351209
rescale_array: Optional[np.ndarray] = None, # Not interface
1210+
reference_array: Optional[np.ndarray] = None, # Not interface
11361211
params_array: Optional[np.ndarray] = None, # Not interface
11371212
param_labels: Optional[list] = None,
11381213
param_label_schema: Optional[AnyLabelSchema] = None,
@@ -1147,7 +1222,7 @@ def add_dynamic_vector(
11471222
1-D numpy array of length ``len(indices_array)`` each time it is called.
11481223
11491224
The ``indices_array``, optional ``flip_array``, optional ``rescale_array``,
1150-
and optional ``params_array`` are static and are stored as normal numpy
1225+
optional ``reference_array``, and optional ``params_array`` are static and are stored as normal numpy
11511226
resources. See ``add_persistent_vector`` for documentation of the
11521227
``params_array``, ``param_labels``, and ``param_label_schema`` arguments.
11531228
@@ -1162,6 +1237,8 @@ def add_dynamic_vector(
11621237
multiplied by ``-1`` before insertion.
11631238
rescale_array: Optional 1-D float array of multiplicative factors
11641239
applied before matrix insertion.
1240+
reference_array: Optional 1-D boolean array; where ``True`` the
1241+
entry is the reference (production) exchange for its column.
11651242
keep_proxy: If ``True``, store a proxy rather than the raw array
11661243
for on-disk resources.
11671244
matrix_serialize_format_type: Override the instance-level
@@ -1219,6 +1296,15 @@ def add_dynamic_vector(
12191296
matrix_serialize_format_type=matrix_serialize_format_type,
12201297
**kwargs,
12211298
)
1299+
if reference_array is not None:
1300+
self._add_reference_array_resource(
1301+
reference_array=reference_array,
1302+
indices_array=indices_array,
1303+
name=name,
1304+
keep_proxy=keep_proxy,
1305+
matrix_serialize_format_type=matrix_serialize_format_type,
1306+
**kwargs,
1307+
)
12221308
if params_array is not None:
12231309
params_array = load_bytes(params_array)
12241310
if params_array.ndim != 1:
@@ -1267,6 +1353,7 @@ def add_dynamic_array(
12671353
name: Optional[str] = None,
12681354
flip_array: Optional[np.ndarray] = None,
12691355
rescale_array: Optional[np.ndarray] = None, # Not interface
1356+
reference_array: Optional[np.ndarray] = None, # Not interface
12701357
params_array: Optional[np.ndarray] = None, # Not interface
12711358
param_labels: Optional[list] = None,
12721359
param_label_schema: Optional[AnyLabelSchema] = None,
@@ -1283,7 +1370,7 @@ def add_dynamic_array(
12831370
interface.
12841371
12851372
The ``indices_array``, optional ``flip_array``, optional ``rescale_array``,
1286-
and optional ``params_array`` are static and are stored as normal numpy
1373+
optional ``reference_array``, and optional ``params_array`` are static and are stored as normal numpy
12871374
resources. For dynamic arrays the column count of ``params_array`` is
12881375
not validated against the interface (whose column count may be unknown at
12891376
write time). See ``add_persistent_vector`` for documentation of the
@@ -1300,6 +1387,8 @@ def add_dynamic_array(
13001387
multiplied by ``-1`` before insertion.
13011388
rescale_array: Optional 1-D float array of multiplicative factors
13021389
applied before matrix insertion.
1390+
reference_array: Optional 1-D boolean array; where ``True`` the
1391+
entry is the reference (production) exchange for its column.
13031392
keep_proxy: If ``True``, store a proxy rather than the raw array
13041393
for on-disk resources.
13051394
matrix_serialize_format_type: Override the instance-level
@@ -1365,6 +1454,15 @@ def add_dynamic_array(
13651454
matrix_serialize_format_type=matrix_serialize_format_type,
13661455
**kwargs,
13671456
)
1457+
if reference_array is not None:
1458+
self._add_reference_array_resource(
1459+
reference_array=reference_array,
1460+
indices_array=indices_array,
1461+
name=name,
1462+
keep_proxy=keep_proxy,
1463+
matrix_serialize_format_type=matrix_serialize_format_type,
1464+
**kwargs,
1465+
)
13681466
if params_array is not None:
13691467
params_array = load_bytes(params_array)
13701468
if params_array.ndim != 2:

src/bw_processing/matrix_entry.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ class MatrixEntry:
6363
Stored as a ``rescale_array`` resource (``kind="rescale"``). Note
6464
that the Python ``float`` value is downcast to ``numpy.float32``
6565
when written to the structured array.
66+
reference: If True, this exchange is the reference (production) exchange
67+
for its activity/column. Consumers such as bw_graph_tools use this
68+
to identify production exchanges directly instead of guessing from
69+
matrix structure. Stored as a ``reference_array`` resource
70+
(``kind="reference"``) only when at least one entry is flagged;
71+
defaults to False.
6672
"""
6773

6874
row: int
@@ -77,6 +83,7 @@ class MatrixEntry:
7783
maximum: float = math.nan
7884
negative: bool = False
7985
rescale: float = 1.0
86+
reference: bool = False
8087

8188
def __post_init__(self):
8289
if self.uncertainty_type in _NO_UNCERTAINTY_IDS:
@@ -109,13 +116,18 @@ class ArrayEntry:
109116
rescale: Optional 1-D float array of per-entry multiplicative factors
110117
(one per row). ``1.0`` leaves the value unchanged. Stored as a
111118
``rescale_array`` resource (``kind="rescale"``).
119+
reference: Optional 1-D boolean sequence of length ``n_entries``.
120+
Where True, that entry is the reference (production) exchange for
121+
its column. Stored as a ``reference_array`` resource
122+
(``kind="reference"``) only when at least one entry is flagged.
112123
"""
113124

114125
rows: np.ndarray
115126
cols: np.ndarray
116127
data: np.ndarray
117128
flip: Optional[np.ndarray] = None
118129
rescale: Optional[np.ndarray] = None
130+
reference: Optional[np.ndarray] = None
119131

120132
def __post_init__(self):
121133
self.rows = np.asarray(self.rows)
@@ -150,6 +162,12 @@ def __post_init__(self):
150162
raise ValueError(
151163
f"`rescale` shape {self.rescale.shape} doesn't match `rows` shape {self.rows.shape}"
152164
)
165+
if self.reference is not None:
166+
self.reference = np.asarray(self.reference, dtype=bool)
167+
if self.reference.shape != self.rows.shape:
168+
raise ValueError(
169+
f"`reference` shape {self.reference.shape} doesn't match `rows` shape {self.rows.shape}"
170+
)
153171

154172

155173
def create_datapackage_from_entries(

src/bw_processing/merging.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def add_resource_suffix(metadata: dict, suffix: str) -> dict:
5353
last = metadata["name"].split(".")[-1]
5454
rest = metadata["name"][: -len(last) - 1]
5555

56-
if last not in {"indices", "data", "distributions", "flip"}:
56+
if last not in {"indices", "data", "distributions", "flip", "rescale", "reference"}:
5757
raise ValueError("Can't understand resource name suffix")
5858

5959
rest = metadata["name"][: -len(last) - 1]

0 commit comments

Comments
 (0)