ecosemantic-engine 0.1.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ecosemantic/engine/__init__.py +0 -0
- ecosemantic/engine/background.py +345 -0
- ecosemantic/engine/calculation.py +293 -0
- ecosemantic/engine/foreground.py +320 -0
- ecosemantic/engine/loops.py +39 -0
- ecosemantic/engine/montecarlo.py +350 -0
- ecosemantic/engine/paths.py +189 -0
- ecosemantic/engine/py.typed +0 -0
- ecosemantic/engine/refine.py +38 -0
- ecosemantic/engine/solver.py +90 -0
- ecosemantic/errors/__init__.py +205 -0
- ecosemantic/errors/py.typed +0 -0
- ecosemantic/model/__init__.py +0 -0
- ecosemantic/model/canonical.py +119 -0
- ecosemantic/model/entities.py +475 -0
- ecosemantic/model/py.typed +0 -0
- ecosemantic/model/refs.py +137 -0
- ecosemantic/model/summary.py +53 -0
- ecosemantic/model/units.py +181 -0
- ecosemantic/model/validation.py +520 -0
- ecosemantic_engine-0.1.8.dist-info/METADATA +52 -0
- ecosemantic_engine-0.1.8.dist-info/RECORD +25 -0
- ecosemantic_engine-0.1.8.dist-info/WHEEL +4 -0
- ecosemantic_engine-0.1.8.dist-info/licenses/LICENSE +73 -0
- ecosemantic_engine-0.1.8.dist-info/licenses/NOTICE +13 -0
|
File without changes
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""A database compiled for calculation: its matrices as labelled arrays, its exchange rows with
|
|
2
|
+
their distributions, and the directory of numpy files it is saved as."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from dataclasses import dataclass, fields
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import nimblend as nb
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from ecosemantic.errors import Conflict, Invalid, NotFound
|
|
16
|
+
from ecosemantic.model.entities import DISTRIBUTION_IDS, Method, Uncertainty
|
|
17
|
+
from ecosemantic.model.refs import Database
|
|
18
|
+
|
|
19
|
+
FORMAT = 1
|
|
20
|
+
_PARAMETERS = ("loc", "scale", "minimum", "maximum")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Rows:
|
|
25
|
+
"""Exchange rows by label, as a source states them: each row's row and column label, its
|
|
26
|
+
amount, whether it enters the matrix negated, and its distribution."""
|
|
27
|
+
|
|
28
|
+
rows: Sequence[str]
|
|
29
|
+
columns: Sequence[str]
|
|
30
|
+
amounts: Sequence[float]
|
|
31
|
+
flipped: Sequence[bool]
|
|
32
|
+
uncertainties: Sequence[Uncertainty | None]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Factors:
|
|
37
|
+
"""Characterisation factors by label: each one's category, its flow and its value."""
|
|
38
|
+
|
|
39
|
+
categories: Sequence[str]
|
|
40
|
+
flows: Sequence[str]
|
|
41
|
+
values: Sequence[float]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Exchanges:
|
|
46
|
+
"""Exchange rows unsummed, at the positions of a background's coordinates: each row's row and
|
|
47
|
+
column, its amount as stated, whether it enters the matrix negated, and its distribution:
|
|
48
|
+
the type as `DISTRIBUTION_IDS` numbers it, 0 for none, and NaN for a parameter the type has
|
|
49
|
+
not."""
|
|
50
|
+
|
|
51
|
+
row: np.ndarray
|
|
52
|
+
column: np.ndarray
|
|
53
|
+
amount: np.ndarray
|
|
54
|
+
flip: np.ndarray
|
|
55
|
+
uncertainty_type: np.ndarray
|
|
56
|
+
loc: np.ndarray
|
|
57
|
+
scale: np.ndarray
|
|
58
|
+
minimum: np.ndarray
|
|
59
|
+
maximum: np.ndarray
|
|
60
|
+
negative: np.ndarray
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def of(
|
|
64
|
+
cls,
|
|
65
|
+
row: np.ndarray,
|
|
66
|
+
column: np.ndarray,
|
|
67
|
+
amounts: Sequence[float],
|
|
68
|
+
flipped: Sequence[bool],
|
|
69
|
+
uncertainties: Sequence[Uncertainty | None],
|
|
70
|
+
) -> Exchanges:
|
|
71
|
+
"""Rows at positions, with their distributions as columns."""
|
|
72
|
+
n = len(amounts)
|
|
73
|
+
kind = np.zeros(n, dtype=np.int8)
|
|
74
|
+
parameters = {name: np.full(n, np.nan) for name in _PARAMETERS}
|
|
75
|
+
negative = np.zeros(n, dtype=bool)
|
|
76
|
+
for at, uncertainty in enumerate(uncertainties):
|
|
77
|
+
if uncertainty is None:
|
|
78
|
+
continue
|
|
79
|
+
kind[at] = DISTRIBUTION_IDS[uncertainty.type]
|
|
80
|
+
for name, value in uncertainty.params.items():
|
|
81
|
+
if name == "negative":
|
|
82
|
+
negative[at] = bool(value)
|
|
83
|
+
else:
|
|
84
|
+
parameters[name][at] = float(value)
|
|
85
|
+
return cls(
|
|
86
|
+
row=np.asarray(row, dtype=np.int32),
|
|
87
|
+
column=np.asarray(column, dtype=np.int32),
|
|
88
|
+
amount=np.asarray(amounts, dtype=np.float64),
|
|
89
|
+
flip=np.asarray(flipped, dtype=bool),
|
|
90
|
+
uncertainty_type=kind,
|
|
91
|
+
negative=negative,
|
|
92
|
+
**parameters,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _known(what: str, labels: np.ndarray, coordinate: np.ndarray) -> None:
|
|
97
|
+
"""Invalid when a row names a label the coordinate does not hold."""
|
|
98
|
+
unknown = labels[~np.isin(labels, coordinate)]
|
|
99
|
+
if unknown.size:
|
|
100
|
+
raise Invalid(
|
|
101
|
+
f"{unknown.size} {what} name a label the background does not hold, "
|
|
102
|
+
f"the first {str(unknown[0])!r}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _exchanges(rows: Rows, row: nb.StoredCoord, column: nb.StoredCoord) -> Exchanges:
|
|
107
|
+
"""The rows at the positions of two coordinates, their distributions as columns."""
|
|
108
|
+
return Exchanges.of(
|
|
109
|
+
row=row.to_position(np.asarray(rows.rows)),
|
|
110
|
+
column=column.to_position(np.asarray(rows.columns)),
|
|
111
|
+
amounts=rows.amounts,
|
|
112
|
+
flipped=rows.flipped,
|
|
113
|
+
uncertainties=rows.uncertainties,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _signed(rows: Rows) -> np.ndarray:
|
|
118
|
+
"""Each row's amount as it enters the matrix."""
|
|
119
|
+
return np.where(np.asarray(rows.flipped, dtype=bool), -1.0, 1.0) * np.asarray(
|
|
120
|
+
rows.amounts, dtype=np.float64
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Background:
|
|
125
|
+
"""A database compiled for calculation. `technosphere` is over ("product", "activity"),
|
|
126
|
+
`biosphere` over ("flow", "activity") and `characterization` over ("category", "flow").
|
|
127
|
+
"product" and "activity" share one coordinate: one label per dataset, its native id."""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
database: Database,
|
|
132
|
+
methods: Sequence[Method],
|
|
133
|
+
coords: dict[str, nb.StoredCoord],
|
|
134
|
+
technosphere: nb.SparseArray,
|
|
135
|
+
biosphere: nb.SparseArray,
|
|
136
|
+
characterization: nb.SparseArray,
|
|
137
|
+
exchanges: dict[str, Exchanges],
|
|
138
|
+
) -> None:
|
|
139
|
+
self.database = database
|
|
140
|
+
self.methods = tuple(methods)
|
|
141
|
+
self.coords = coords
|
|
142
|
+
self.technosphere = technosphere
|
|
143
|
+
self.biosphere = biosphere
|
|
144
|
+
self.characterization = characterization
|
|
145
|
+
self.exchanges = exchanges
|
|
146
|
+
self._category = {method.id: at for at, method in enumerate(self.methods)}
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def compile(
|
|
150
|
+
cls,
|
|
151
|
+
database: Database,
|
|
152
|
+
activities: Sequence[str],
|
|
153
|
+
flows: Sequence[str],
|
|
154
|
+
methods: Sequence[Method],
|
|
155
|
+
technosphere: Rows,
|
|
156
|
+
biosphere: Rows,
|
|
157
|
+
factors: Factors,
|
|
158
|
+
) -> Background:
|
|
159
|
+
"""The background of these datasets, flows and methods. A dataset's rows at one
|
|
160
|
+
coordinate, its production and its use of its own product, are added. Invalid for a row
|
|
161
|
+
naming a label outside its coordinate."""
|
|
162
|
+
activity_labels = np.array(sorted(activities))
|
|
163
|
+
flow_labels = np.array(sorted(flows))
|
|
164
|
+
category_labels = np.array([method.id for method in methods])
|
|
165
|
+
_known("technosphere rows", np.asarray(technosphere.rows), activity_labels)
|
|
166
|
+
_known("technosphere columns", np.asarray(technosphere.columns), activity_labels)
|
|
167
|
+
_known("biosphere rows", np.asarray(biosphere.rows), flow_labels)
|
|
168
|
+
_known("biosphere columns", np.asarray(biosphere.columns), activity_labels)
|
|
169
|
+
_known("factor categories", np.asarray(factors.categories), category_labels)
|
|
170
|
+
_known("factor flows", np.asarray(factors.flows), flow_labels)
|
|
171
|
+
activity = nb.StoredCoord(activity_labels)
|
|
172
|
+
coords = {
|
|
173
|
+
"product": activity,
|
|
174
|
+
"activity": activity,
|
|
175
|
+
"flow": nb.StoredCoord(flow_labels),
|
|
176
|
+
"category": nb.StoredCoord(category_labels),
|
|
177
|
+
}
|
|
178
|
+
return cls(
|
|
179
|
+
database,
|
|
180
|
+
methods,
|
|
181
|
+
coords,
|
|
182
|
+
nb.from_long(
|
|
183
|
+
("product", "activity"),
|
|
184
|
+
coords,
|
|
185
|
+
{
|
|
186
|
+
"product": np.asarray(technosphere.rows),
|
|
187
|
+
"activity": np.asarray(technosphere.columns),
|
|
188
|
+
},
|
|
189
|
+
_signed(technosphere),
|
|
190
|
+
on_duplicate="sum",
|
|
191
|
+
),
|
|
192
|
+
nb.from_long(
|
|
193
|
+
("flow", "activity"),
|
|
194
|
+
coords,
|
|
195
|
+
{"flow": np.asarray(biosphere.rows), "activity": np.asarray(biosphere.columns)},
|
|
196
|
+
_signed(biosphere),
|
|
197
|
+
on_duplicate="sum",
|
|
198
|
+
),
|
|
199
|
+
nb.from_long(
|
|
200
|
+
("category", "flow"),
|
|
201
|
+
coords,
|
|
202
|
+
{"category": np.asarray(factors.categories), "flow": np.asarray(factors.flows)},
|
|
203
|
+
np.asarray(factors.values, dtype=np.float64),
|
|
204
|
+
on_duplicate="sum",
|
|
205
|
+
),
|
|
206
|
+
{
|
|
207
|
+
"technosphere": _exchanges(technosphere, activity, activity),
|
|
208
|
+
"biosphere": _exchanges(biosphere, coords["flow"], activity),
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def treating(self) -> frozenset[str]:
|
|
213
|
+
"""The datasets whose production, net of their use of their own product, enters the
|
|
214
|
+
matrix negative: the treatments, each taking in the waste its reference names, so a
|
|
215
|
+
consumer sends it a negative amount. ecoinvent states such a reference as a negative
|
|
216
|
+
output and openLCA as an input; both compile to a negative production."""
|
|
217
|
+
rows = self.exchanges["technosphere"]
|
|
218
|
+
own = np.asarray(rows.row) == np.asarray(rows.column)
|
|
219
|
+
signed = np.where(np.asarray(rows.flip)[own], -1.0, 1.0) * np.asarray(rows.amount)[own]
|
|
220
|
+
labels = self.coords["activity"].labels
|
|
221
|
+
net = np.bincount(np.asarray(rows.column)[own], weights=signed, minlength=labels.size)
|
|
222
|
+
return frozenset(str(label) for label in labels[net < 0])
|
|
223
|
+
|
|
224
|
+
def position(self, dim: str, label: str) -> int | None:
|
|
225
|
+
"""The position of a label along "activity" or "flow", whose labels are sorted, or
|
|
226
|
+
None."""
|
|
227
|
+
labels = self.coords[dim].labels
|
|
228
|
+
at = int(np.searchsorted(labels, label))
|
|
229
|
+
return at if at < labels.size and labels[at] == label else None
|
|
230
|
+
|
|
231
|
+
def positions(self, methods: Sequence[Method]) -> list[int]:
|
|
232
|
+
"""The position of each method's category, in the order given; NotFound naming every
|
|
233
|
+
method the background does not hold."""
|
|
234
|
+
missing = [method.spec for method in methods if method.id not in self._category]
|
|
235
|
+
if missing:
|
|
236
|
+
raise NotFound(f"{self.database.name!r} lacks methods {missing}")
|
|
237
|
+
return [self._category[method.id] for method in methods]
|
|
238
|
+
|
|
239
|
+
def arrays(self) -> dict[str, np.ndarray]:
|
|
240
|
+
"""Every array the background is saved as, by the name of its file: the label columns,
|
|
241
|
+
each matrix's index and data, and each exchange column."""
|
|
242
|
+
arrays: dict[str, np.ndarray] = {
|
|
243
|
+
"activities": np.asarray(self.coords["activity"].labels),
|
|
244
|
+
"flows": np.asarray(self.coords["flow"].labels),
|
|
245
|
+
"categories": np.asarray(self.coords["category"].labels),
|
|
246
|
+
}
|
|
247
|
+
for name in ("technosphere", "biosphere", "characterization"):
|
|
248
|
+
array: nb.SparseArray = getattr(self, name)
|
|
249
|
+
arrays[f"{name}.index"] = np.asarray(array.index)
|
|
250
|
+
arrays[f"{name}.data"] = np.asarray(array.data)
|
|
251
|
+
for matrix, exchanges in self.exchanges.items():
|
|
252
|
+
for field in fields(Exchanges):
|
|
253
|
+
arrays[f"{matrix}.exchanges.{field.name}"] = np.asarray(
|
|
254
|
+
getattr(exchanges, field.name)
|
|
255
|
+
)
|
|
256
|
+
return arrays
|
|
257
|
+
|
|
258
|
+
def fingerprint(self) -> str:
|
|
259
|
+
"""SHA-256 of the arrays in the order of their names, each as its name, its dtype, its
|
|
260
|
+
shape and its bytes in C order, the category labels taken as each category's key less
|
|
261
|
+
the database name, since a method id carries the name its database is installed under:
|
|
262
|
+
what a calculation reads, apart from that name, background.json's other fields and the
|
|
263
|
+
files' headers, so one build installed under any name, and every build holding the same
|
|
264
|
+
numbers, shares it."""
|
|
265
|
+
arrays = self.arrays()
|
|
266
|
+
arrays["categories"] = np.asarray(
|
|
267
|
+
[" > ".join(method.tuple[1:]) for method in self.methods], dtype=np.str_
|
|
268
|
+
)
|
|
269
|
+
digest = hashlib.sha256()
|
|
270
|
+
for name, array in sorted(arrays.items()):
|
|
271
|
+
values = np.ascontiguousarray(array)
|
|
272
|
+
digest.update(f"{name}\0{values.dtype.str}\0{values.shape}\0".encode())
|
|
273
|
+
digest.update(values.tobytes())
|
|
274
|
+
return digest.hexdigest()
|
|
275
|
+
|
|
276
|
+
def save(self, directory: Path) -> None:
|
|
277
|
+
"""Write the background into a new directory: one .npy file per array and label column,
|
|
278
|
+
and background.json, written last, with the format, the database, the methods and the
|
|
279
|
+
counts."""
|
|
280
|
+
if directory.exists():
|
|
281
|
+
raise Conflict(f"{directory} exists; save the background to a new directory")
|
|
282
|
+
directory.mkdir(parents=True)
|
|
283
|
+
arrays = self.arrays()
|
|
284
|
+
for name, values in arrays.items():
|
|
285
|
+
np.save(directory / f"{name}.npy", values, allow_pickle=False)
|
|
286
|
+
manifest = {
|
|
287
|
+
"format": FORMAT,
|
|
288
|
+
"database": self.database.model_dump(mode="json"),
|
|
289
|
+
"methods": [method.model_dump(mode="json") for method in self.methods],
|
|
290
|
+
"counts": {name: int(values.shape[-1]) for name, values in arrays.items()},
|
|
291
|
+
}
|
|
292
|
+
(directory / "background.json").write_text(
|
|
293
|
+
json.dumps(manifest, indent=1) + "\n", encoding="utf-8"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
@classmethod
|
|
297
|
+
def load(cls, directory: Path) -> Background:
|
|
298
|
+
"""The background saved in a directory, its arrays memory-mapped. NotFound for a
|
|
299
|
+
directory holding none, Invalid for a format this engine does not read."""
|
|
300
|
+
manifest_path = directory / "background.json"
|
|
301
|
+
if not manifest_path.is_file():
|
|
302
|
+
raise NotFound(f"{directory} holds no saved background")
|
|
303
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
304
|
+
if manifest.get("format") != FORMAT:
|
|
305
|
+
raise Invalid(
|
|
306
|
+
f"{directory} holds background format {manifest.get('format')!r}; "
|
|
307
|
+
f"this engine reads format {FORMAT}"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def read(name: str) -> np.ndarray:
|
|
311
|
+
return np.load(directory / f"{name}.npy", mmap_mode="r", allow_pickle=False)
|
|
312
|
+
|
|
313
|
+
activity = nb.StoredCoord(read("activities"))
|
|
314
|
+
coords = {
|
|
315
|
+
"product": activity,
|
|
316
|
+
"activity": activity,
|
|
317
|
+
"flow": nb.StoredCoord(read("flows")),
|
|
318
|
+
"category": nb.StoredCoord(read("categories")),
|
|
319
|
+
}
|
|
320
|
+
dims = {
|
|
321
|
+
"technosphere": ("product", "activity"),
|
|
322
|
+
"biosphere": ("flow", "activity"),
|
|
323
|
+
"characterization": ("category", "flow"),
|
|
324
|
+
}
|
|
325
|
+
arrays = {
|
|
326
|
+
name: nb.SparseArray.from_canonical(
|
|
327
|
+
read(f"{name}.index"), read(f"{name}.data"), coords, frame
|
|
328
|
+
)
|
|
329
|
+
for name, frame in dims.items()
|
|
330
|
+
}
|
|
331
|
+
exchanges = {
|
|
332
|
+
matrix: Exchanges(
|
|
333
|
+
**{f.name: read(f"{matrix}.exchanges.{f.name}") for f in fields(Exchanges)}
|
|
334
|
+
)
|
|
335
|
+
for matrix in ("technosphere", "biosphere")
|
|
336
|
+
}
|
|
337
|
+
return cls(
|
|
338
|
+
Database.model_validate(manifest["database"]),
|
|
339
|
+
[Method.model_validate(method) for method in manifest["methods"]],
|
|
340
|
+
coords,
|
|
341
|
+
arrays["technosphere"],
|
|
342
|
+
arrays["biosphere"],
|
|
343
|
+
arrays["characterization"],
|
|
344
|
+
exchanges,
|
|
345
|
+
)
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Exact results over a compiled background: one solve, and the scores, contributions,
|
|
2
|
+
intensities, supply, inventory and paths read from it."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
import nimblend as nb
|
|
11
|
+
import numpy as np
|
|
12
|
+
import scipy.sparse as sp
|
|
13
|
+
|
|
14
|
+
from ecosemantic.engine.background import Background
|
|
15
|
+
from ecosemantic.engine.foreground import Foreground
|
|
16
|
+
from ecosemantic.engine.loops import Loops, loops_of
|
|
17
|
+
from ecosemantic.engine.paths import PathTree, Walked, walk
|
|
18
|
+
from ecosemantic.engine.solver import Solver, SolverName
|
|
19
|
+
from ecosemantic.errors import Invalid, NotFound
|
|
20
|
+
from ecosemantic.model.entities import DemandItem, Method, ProductSystem
|
|
21
|
+
from ecosemantic.model.refs import ForegroundRef
|
|
22
|
+
|
|
23
|
+
NOISE_FLOOR = 1e-34
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _csr(array: nb.SparseArray) -> sp.csr_matrix:
|
|
27
|
+
"""A two-dimensional array as a scipy CSR matrix over its buffers."""
|
|
28
|
+
indices, values, indptr = array.to_csr()
|
|
29
|
+
return sp.csr_matrix((values, indices, indptr), shape=array.shape)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _over(dim: str, coord: nb.StoredCoord, values: np.ndarray) -> nb.SparseArray:
|
|
33
|
+
"""A value at every label of one coordinate."""
|
|
34
|
+
return nb.Domain.full((dim,), {dim: coord}).array(np.asarray(values, dtype=np.float64))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Solution:
|
|
39
|
+
"""One solve: the foreground it solved, the supply of each foreground process over
|
|
40
|
+
"process" and of each background activity over "activity", the inventory over "flow", the
|
|
41
|
+
demand placed on background activities, and the solver that ran."""
|
|
42
|
+
|
|
43
|
+
foreground: Foreground | None
|
|
44
|
+
process_supply: nb.SparseArray
|
|
45
|
+
activity_supply: nb.SparseArray
|
|
46
|
+
inventory: nb.SparseArray
|
|
47
|
+
background_demand: np.ndarray
|
|
48
|
+
solver: SolverName
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Contributions:
|
|
53
|
+
"""A score split over the processes that emit, each one's direct emissions characterised
|
|
54
|
+
times its supply: the foreground's over "process", the background's over "activity"."""
|
|
55
|
+
|
|
56
|
+
foreground: nb.SparseArray
|
|
57
|
+
background: nb.SparseArray
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Inventory:
|
|
62
|
+
"""The amount of every elementary flow over "flow", and for each flow with an amount the
|
|
63
|
+
label of the foreground process whose own and upstream emissions of it are largest, None
|
|
64
|
+
where a demanded background activity's are or where there is no foreground. An amount at or
|
|
65
|
+
below `floor`, NOISE_FLOOR of the largest, is float64 residue: it is set to zero, and
|
|
66
|
+
`dropped` counts it."""
|
|
67
|
+
|
|
68
|
+
amounts: nb.SparseArray
|
|
69
|
+
sources: dict[str, str | None]
|
|
70
|
+
floor: float
|
|
71
|
+
dropped: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Calculation:
|
|
75
|
+
"""Exact results over one background: its technosphere factorised once by the named solver,
|
|
76
|
+
and per category the direct impact of each activity and the cradle-to-gate intensities, each
|
|
77
|
+
built on first use."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, background: Background, solver: SolverName = "superlu") -> None:
|
|
80
|
+
self.background = background
|
|
81
|
+
self.technosphere = _csr(background.technosphere)
|
|
82
|
+
self.biosphere = _csr(background.biosphere)
|
|
83
|
+
self.characterization = _csr(background.characterization)
|
|
84
|
+
self.solver = Solver(solver, self.technosphere)
|
|
85
|
+
self._direct: dict[int, np.ndarray] = {}
|
|
86
|
+
self._intensities: dict[int, np.ndarray] = {}
|
|
87
|
+
self._loops: Loops | None = None
|
|
88
|
+
|
|
89
|
+
def foreground(
|
|
90
|
+
self, product_system: ProductSystem, linked: Mapping[uuid.UUID, ProductSystem]
|
|
91
|
+
) -> Foreground:
|
|
92
|
+
"""The foreground of a product system and the systems it links."""
|
|
93
|
+
return Foreground.of(self.background, product_system, linked)
|
|
94
|
+
|
|
95
|
+
def loops(self) -> Loops:
|
|
96
|
+
"""The background's loops a sampled draw is checked on, found on first use."""
|
|
97
|
+
if self._loops is None:
|
|
98
|
+
self._loops = loops_of(self.technosphere)
|
|
99
|
+
return self._loops
|
|
100
|
+
|
|
101
|
+
def _factors(self, position: int) -> np.ndarray:
|
|
102
|
+
"""The characterisation factor of every flow under one category."""
|
|
103
|
+
return np.asarray(self.characterization[[position]].toarray()).ravel()
|
|
104
|
+
|
|
105
|
+
def _direct_impacts(self, position: int) -> np.ndarray:
|
|
106
|
+
"""Each activity's direct emissions characterised under one category, per unit of its
|
|
107
|
+
operation."""
|
|
108
|
+
if position not in self._direct:
|
|
109
|
+
self._direct[position] = (
|
|
110
|
+
(self.characterization[[position]] @ self.biosphere).toarray().ravel()
|
|
111
|
+
)
|
|
112
|
+
return self._direct[position]
|
|
113
|
+
|
|
114
|
+
def _intensity_rows(self, positions: Sequence[int]) -> np.ndarray:
|
|
115
|
+
"""The cradle-to-gate intensity of every activity under each category, one row each; the
|
|
116
|
+
categories not yet held are solved together, one right-hand side each."""
|
|
117
|
+
missing = sorted({p for p in positions if p not in self._intensities})
|
|
118
|
+
if missing:
|
|
119
|
+
direct = np.column_stack([self._direct_impacts(p) for p in missing])
|
|
120
|
+
solved = self.solver.adjoint(direct)
|
|
121
|
+
for at, p in enumerate(missing):
|
|
122
|
+
self._intensities[p] = solved[:, at]
|
|
123
|
+
return np.vstack([self._intensities[p] for p in positions])
|
|
124
|
+
|
|
125
|
+
def placed(
|
|
126
|
+
self, foreground: Foreground | None, demand: Sequence[DemandItem]
|
|
127
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
128
|
+
"""The demand on foreground processes and on background activities."""
|
|
129
|
+
background = self.background
|
|
130
|
+
d_fg = np.zeros(len(foreground.columns) if foreground is not None else 0)
|
|
131
|
+
d_bg = np.zeros(len(background.coords["activity"]))
|
|
132
|
+
for item in demand:
|
|
133
|
+
if isinstance(item.ref, ForegroundRef):
|
|
134
|
+
if foreground is None:
|
|
135
|
+
raise Invalid(
|
|
136
|
+
f"the demand names foreground process {item.ref.code!r} "
|
|
137
|
+
"and the calculation has no product system"
|
|
138
|
+
)
|
|
139
|
+
host = foreground.product_system.id
|
|
140
|
+
d_fg[foreground.column_of(host, item.ref.code, "the demand")] += item.amount
|
|
141
|
+
continue
|
|
142
|
+
if item.ref.database != background.database.name:
|
|
143
|
+
raise Invalid(f"the demand names database {item.ref.database!r}")
|
|
144
|
+
at = background.position("activity", item.ref.id)
|
|
145
|
+
if at is None:
|
|
146
|
+
raise NotFound(
|
|
147
|
+
f"the demand names background dataset {item.ref.id!r}, "
|
|
148
|
+
f"which {background.database.name!r} does not hold"
|
|
149
|
+
)
|
|
150
|
+
d_bg[at] += item.amount
|
|
151
|
+
return d_fg, d_bg
|
|
152
|
+
|
|
153
|
+
def _process_coord(self, foreground: Foreground | None) -> nb.StoredCoord:
|
|
154
|
+
labels = foreground.labels if foreground is not None else np.array([], dtype=str)
|
|
155
|
+
return nb.StoredCoord(labels)
|
|
156
|
+
|
|
157
|
+
def solve(self, foreground: Foreground | None, demand: Sequence[DemandItem]) -> Solution:
|
|
158
|
+
"""The supply and the inventory that meet a demand: the foreground first, then the
|
|
159
|
+
background with the foreground's uses added to its demand."""
|
|
160
|
+
background = self.background
|
|
161
|
+
d_fg, d_bg = self.placed(foreground, demand)
|
|
162
|
+
rhs = d_bg.copy()
|
|
163
|
+
s_fg = np.zeros(0)
|
|
164
|
+
if foreground is not None:
|
|
165
|
+
s_fg = foreground.solve(d_fg)
|
|
166
|
+
rhs = rhs + foreground.uses @ s_fg
|
|
167
|
+
s_bg = self.solver.forward(rhs) if rhs.any() else np.zeros_like(rhs)
|
|
168
|
+
g = np.asarray(self.biosphere @ s_bg).ravel()
|
|
169
|
+
if foreground is not None:
|
|
170
|
+
g = g + np.asarray(foreground.direct @ s_fg).ravel()
|
|
171
|
+
return Solution(
|
|
172
|
+
foreground=foreground,
|
|
173
|
+
process_supply=_over("process", self._process_coord(foreground), s_fg),
|
|
174
|
+
activity_supply=_over("activity", background.coords["activity"], s_bg),
|
|
175
|
+
inventory=_over("flow", background.coords["flow"], g),
|
|
176
|
+
background_demand=d_bg,
|
|
177
|
+
solver=self.solver.name,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def scores(self, solution: Solution, methods: Sequence[Method]) -> nb.SparseArray:
|
|
181
|
+
"""One score per method over "category", in the order given; NotFound naming every
|
|
182
|
+
method the background does not hold."""
|
|
183
|
+
positions = self.background.positions(methods)
|
|
184
|
+
values = np.asarray(self.characterization[positions] @ solution.inventory.data).ravel()
|
|
185
|
+
coord = nb.StoredCoord(np.array([method.id for method in methods]))
|
|
186
|
+
return _over("category", coord, values)
|
|
187
|
+
|
|
188
|
+
def by_process(self, solution: Solution, method: Method) -> Contributions:
|
|
189
|
+
"""The score of each foreground process and each background activity: its direct
|
|
190
|
+
emissions characterised, times its supply."""
|
|
191
|
+
[position] = self.background.positions([method])
|
|
192
|
+
factors = self._factors(position)
|
|
193
|
+
background = self._direct_impacts(position) * solution.activity_supply.data
|
|
194
|
+
fg = solution.foreground
|
|
195
|
+
own = np.zeros(0) if fg is None else np.asarray(factors @ fg.direct).ravel()
|
|
196
|
+
return Contributions(
|
|
197
|
+
foreground=_over(
|
|
198
|
+
"process", self._process_coord(fg), own * solution.process_supply.data
|
|
199
|
+
),
|
|
200
|
+
background=_over("activity", self.background.coords["activity"], background),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def by_flow(self, solution: Solution, method: Method) -> nb.SparseArray:
|
|
204
|
+
"""Each flow's characterised amount over "flow"."""
|
|
205
|
+
[position] = self.background.positions([method])
|
|
206
|
+
values = self._factors(position) * solution.inventory.data
|
|
207
|
+
return _over("flow", self.background.coords["flow"], values)
|
|
208
|
+
|
|
209
|
+
def intensities(self, methods: Sequence[Method]) -> nb.SparseArray:
|
|
210
|
+
"""The cradle-to-gate score of one unit of every activity's product under each method,
|
|
211
|
+
over ("category", "activity")."""
|
|
212
|
+
rows = self._intensity_rows(self.background.positions(methods))
|
|
213
|
+
coords = {
|
|
214
|
+
"category": nb.StoredCoord(np.array([method.id for method in methods])),
|
|
215
|
+
"activity": self.background.coords["activity"],
|
|
216
|
+
}
|
|
217
|
+
return nb.Domain.full(("category", "activity"), coords).array(rows.ravel())
|
|
218
|
+
|
|
219
|
+
def supply(self, foreground: Foreground, demand: Sequence[DemandItem]) -> nb.SparseArray:
|
|
220
|
+
"""The level of each foreground process that meets the demand, over "process", with no
|
|
221
|
+
background solve."""
|
|
222
|
+
d_fg, _ = self.placed(foreground, demand)
|
|
223
|
+
return _over("process", self._process_coord(foreground), foreground.solve(d_fg))
|
|
224
|
+
|
|
225
|
+
def inventory(self, solution: Solution) -> Inventory:
|
|
226
|
+
"""The inventory, and each flow's dominant source: per foreground process its own
|
|
227
|
+
emissions plus those of the background it uses, one background solve each, and the
|
|
228
|
+
demanded background activities together."""
|
|
229
|
+
found = solution.inventory.data
|
|
230
|
+
floor = NOISE_FLOOR * float(np.abs(found).max(initial=0.0))
|
|
231
|
+
residue = (found != 0.0) & (np.abs(found) <= floor)
|
|
232
|
+
g = np.where(residue, 0.0, found)
|
|
233
|
+
amounts = _over("flow", self.background.coords["flow"], g)
|
|
234
|
+
dropped = int(residue.sum())
|
|
235
|
+
flows = self.background.coords["flow"].labels
|
|
236
|
+
nonzero = np.flatnonzero(g)
|
|
237
|
+
fg = solution.foreground
|
|
238
|
+
if fg is None:
|
|
239
|
+
sourced = {str(flows[row]): None for row in nonzero}
|
|
240
|
+
return Inventory(amounts, sourced, floor, dropped)
|
|
241
|
+
s_fg = solution.process_supply.data
|
|
242
|
+
k = len(fg.columns)
|
|
243
|
+
shares = np.zeros((k + 1, g.size))
|
|
244
|
+
for j in range(k):
|
|
245
|
+
own = np.asarray(fg.direct[:, [j]].toarray()).ravel() * s_fg[j]
|
|
246
|
+
used = np.asarray(fg.uses[:, [j]].toarray()).ravel() * s_fg[j]
|
|
247
|
+
upstream = self.biosphere @ self.solver.forward(used) if used.any() else 0.0
|
|
248
|
+
shares[j] = own + upstream
|
|
249
|
+
if solution.background_demand.any():
|
|
250
|
+
shares[k] = self.biosphere @ self.solver.forward(solution.background_demand)
|
|
251
|
+
labels = fg.labels
|
|
252
|
+
sources: dict[str, str | None] = {}
|
|
253
|
+
for row in nonzero:
|
|
254
|
+
winner = int(np.argmax(np.abs(shares[:, row])))
|
|
255
|
+
sources[str(flows[row])] = None if winner == k else str(labels[winner])
|
|
256
|
+
return Inventory(amounts, sources, floor, dropped)
|
|
257
|
+
|
|
258
|
+
def paths(
|
|
259
|
+
self,
|
|
260
|
+
foreground: Foreground,
|
|
261
|
+
item: DemandItem,
|
|
262
|
+
method: Method,
|
|
263
|
+
max_level: int,
|
|
264
|
+
max_nodes: int,
|
|
265
|
+
cutoff: float,
|
|
266
|
+
) -> PathTree:
|
|
267
|
+
"""The paths under the one foreground process an item demands, walked best first."""
|
|
268
|
+
if not isinstance(item.ref, ForegroundRef):
|
|
269
|
+
raise Invalid("paths start at one foreground process")
|
|
270
|
+
[position] = self.background.positions([method])
|
|
271
|
+
factors = self._factors(position)
|
|
272
|
+
background_intensity = self._intensity_rows([position])[0]
|
|
273
|
+
foreground_intensity = foreground.solve_transposed(
|
|
274
|
+
np.asarray(factors @ foreground.direct).ravel()
|
|
275
|
+
+ np.asarray(foreground.uses.T @ background_intensity).ravel()
|
|
276
|
+
)
|
|
277
|
+
solution = self.solve(foreground, [item])
|
|
278
|
+
total = float(factors @ solution.inventory.data)
|
|
279
|
+
root = foreground.column_of(foreground.product_system.id, item.ref.code, "the paths")
|
|
280
|
+
walked = Walked(
|
|
281
|
+
foreground_intensity=foreground_intensity,
|
|
282
|
+
background_intensity=background_intensity,
|
|
283
|
+
factors=factors,
|
|
284
|
+
foreground=foreground.technosphere.tocsc(),
|
|
285
|
+
uses=foreground.uses.tocsc(),
|
|
286
|
+
direct=foreground.direct.tocsc(),
|
|
287
|
+
technosphere=self.technosphere.tocsc(),
|
|
288
|
+
biosphere=self.biosphere.tocsc(),
|
|
289
|
+
process_labels=foreground.labels,
|
|
290
|
+
activity_labels=self.background.coords["activity"].labels,
|
|
291
|
+
flow_labels=self.background.coords["flow"].labels,
|
|
292
|
+
)
|
|
293
|
+
return walk(walked, root, item.amount, total, max_level, max_nodes, cutoff)
|