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.
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)