ecosemantic-engine 0.1.8__tar.gz

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.
Files changed (26) hide show
  1. ecosemantic_engine-0.1.8/.gitignore +24 -0
  2. ecosemantic_engine-0.1.8/LICENSE +73 -0
  3. ecosemantic_engine-0.1.8/NOTICE +13 -0
  4. ecosemantic_engine-0.1.8/PKG-INFO +52 -0
  5. ecosemantic_engine-0.1.8/README.md +21 -0
  6. ecosemantic_engine-0.1.8/ecosemantic/engine/__init__.py +0 -0
  7. ecosemantic_engine-0.1.8/ecosemantic/engine/background.py +345 -0
  8. ecosemantic_engine-0.1.8/ecosemantic/engine/calculation.py +293 -0
  9. ecosemantic_engine-0.1.8/ecosemantic/engine/foreground.py +320 -0
  10. ecosemantic_engine-0.1.8/ecosemantic/engine/loops.py +39 -0
  11. ecosemantic_engine-0.1.8/ecosemantic/engine/montecarlo.py +350 -0
  12. ecosemantic_engine-0.1.8/ecosemantic/engine/paths.py +189 -0
  13. ecosemantic_engine-0.1.8/ecosemantic/engine/py.typed +0 -0
  14. ecosemantic_engine-0.1.8/ecosemantic/engine/refine.py +38 -0
  15. ecosemantic_engine-0.1.8/ecosemantic/engine/solver.py +90 -0
  16. ecosemantic_engine-0.1.8/ecosemantic/errors/__init__.py +205 -0
  17. ecosemantic_engine-0.1.8/ecosemantic/errors/py.typed +0 -0
  18. ecosemantic_engine-0.1.8/ecosemantic/model/__init__.py +0 -0
  19. ecosemantic_engine-0.1.8/ecosemantic/model/canonical.py +119 -0
  20. ecosemantic_engine-0.1.8/ecosemantic/model/entities.py +475 -0
  21. ecosemantic_engine-0.1.8/ecosemantic/model/py.typed +0 -0
  22. ecosemantic_engine-0.1.8/ecosemantic/model/refs.py +137 -0
  23. ecosemantic_engine-0.1.8/ecosemantic/model/summary.py +53 -0
  24. ecosemantic_engine-0.1.8/ecosemantic/model/units.py +181 -0
  25. ecosemantic_engine-0.1.8/ecosemantic/model/validation.py +520 -0
  26. ecosemantic_engine-0.1.8/pyproject.toml +45 -0
@@ -0,0 +1,24 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ node_modules/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .mypy_cache/
12
+ .coverage
13
+ coverage.json
14
+ htmlcov/
15
+
16
+ .env
17
+ .env.*
18
+ !.env.example
19
+
20
+ .DS_Store
21
+ graphify-out
22
+ knowledge/
23
+ .okf-cache.json
24
+ **/ecosemantic/view/static/
@@ -0,0 +1,73 @@
1
+ # PolyForm Noncommercial License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/noncommercial/1.0.0>
4
+
5
+ ## Acceptance
6
+
7
+ In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
8
+
9
+ ## Copyright License
10
+
11
+ The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
12
+
13
+ ## Distribution License
14
+
15
+ The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
16
+
17
+ ## Notices
18
+
19
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
20
+
21
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
22
+
23
+ ## Changes and New Works License
24
+
25
+ The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
26
+
27
+ ## Patent License
28
+
29
+ The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
30
+
31
+ ## Noncommercial Purposes
32
+
33
+ Any noncommercial purpose is a permitted purpose.
34
+
35
+ ## Personal Uses
36
+
37
+ Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
38
+
39
+ ## Noncommercial Organizations
40
+
41
+ Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
42
+
43
+ ## Fair Use
44
+
45
+ You may have "fair use" rights for the software under the law. These terms do not limit them.
46
+
47
+ ## No Other Rights
48
+
49
+ These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
50
+
51
+ ## Patent Defense
52
+
53
+ If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
54
+
55
+ ## Violations
56
+
57
+ The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
58
+
59
+ ## No Liability
60
+
61
+ ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
62
+
63
+ ## Definitions
64
+
65
+ The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
66
+
67
+ **You** refers to the individual or entity agreeing to these terms.
68
+
69
+ **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
70
+
71
+ **Your licenses** are all the licenses granted to you for the software under these terms.
72
+
73
+ **Use** means anything you do with the software requiring one of your licenses.
@@ -0,0 +1,13 @@
1
+ EcoSemantic
2
+ Required Notice: Copyright 2026 Carlos Gaete-Morales (https://github.com/cdgaete/ecosemantic)
3
+
4
+ EcoSemantic is licensed under the PolyForm Noncommercial License 1.0.0, in LICENSE
5
+ and at https://polyformproject.org/licenses/noncommercial/1.0.0. Any noncommercial
6
+ purpose is permitted. Commercial use needs a separate licence from Carlos
7
+ Gaete-Morales: cdgaete@gmail.com.
8
+
9
+ The page that ecosemantic-view carries bundles third-party software under its
10
+ own licences; their notices are in ecosemantic/view/static/THIRD_PARTY_NOTICES.txt
11
+ inside that distribution. Components of the page adapt shadcn/ui, Copyright (c)
12
+ 2023 shadcn, under the MIT licence, as page/PROVENANCE.md in the source
13
+ repository records.
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.5
2
+ Name: ecosemantic-engine
3
+ Version: 0.1.8
4
+ Summary: The EcoSemantic product-system vocabulary, its typed errors, and the exact LCA computation.
5
+ Project-URL: Homepage, https://github.com/cdgaete/ecosemantic
6
+ Project-URL: Repository, https://github.com/cdgaete/ecosemantic
7
+ Project-URL: Issues, https://github.com/cdgaete/ecosemantic/issues
8
+ Project-URL: Changelog, https://github.com/cdgaete/ecosemantic/blob/main/CHANGELOG.md
9
+ Author-email: Carlos Gaete-Morales <cdgaete@gmail.com>
10
+ License-Expression: PolyForm-Noncommercial-1.0.0
11
+ License-File: LICENSE
12
+ License-File: NOTICE
13
+ Keywords: LCA,Monte Carlo,environmental impact,life cycle assessment,sparse linear algebra
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Scientific/Engineering
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: nimblend>=0.20261012.0
25
+ Requires-Dist: numpy>=2.5
26
+ Requires-Dist: pydantic<3,>=2.7
27
+ Requires-Dist: scipy>=1.16
28
+ Provides-Extra: mkl
29
+ Requires-Dist: pypardiso>=0.4.6; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and extra == 'mkl'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # ecosemantic-engine
33
+
34
+ The lowest distribution of the EcoSemantic core: the product-system vocabulary, the typed errors every other distribution raises, and the exact life cycle assessment computation over a compiled background.
35
+
36
+ ## Names it publishes
37
+
38
+ - `ecosemantic.model` — the vocabulary a product system is made of: its entities, the refs that name what it reaches, the units the core knows, the structural validation a system passes before a calculation runs, the result-relevant projection a version hash names, and the summary an answer keeps of itself.
39
+ - `ecosemantic.errors` — the typed errors, `EcoSemanticError` and the refusals under it: a layer that cannot answer raises rather than approximates.
40
+ - `ecosemantic.engine` — the computation: `background` compiles a database into labelled memory-mapped arrays, `solver` makes the forward and adjoint solves from one factorisation, `calculation` reads scores, contributions, intensities, supply, inventory and paths from one solve, `foreground` gives a product system its columns over that background, `paths` walks the supply chain under one process, `montecarlo` takes each draw from the seed and the draw's index, `loops` judges the technosphere loops a draw is checked on, and `refine` refines a sparse solve.
41
+
42
+ ## What it depends on
43
+
44
+ pydantic, numpy and scipy, and nimblend for the memory-mapped arrays a background is compiled into. The `mkl` extra adds pypardiso, on Linux x86-64 and Windows x86-64, where MKL publishes wheels.
45
+
46
+ ## Installing it
47
+
48
+ `pip install ecosemantic` installs this distribution with the other five and the `ecosemantic` command. `pip install ecosemantic-engine` installs it alone, for code that builds on the vocabulary and the engine without a server; `pip install "ecosemantic-engine[mkl]"` adds the solver.
49
+
50
+ ## Licence
51
+
52
+ PolyForm Noncommercial License 1.0.0, copyright Carlos Gaete-Morales: `LICENSE` and `NOTICE` in this distribution. Any noncommercial purpose is permitted, by individuals and by charities, schools, public research and government bodies; commercial use needs a separate licence from him (cdgaete@gmail.com). EcoSemantic is his trademark, and the licence grants no right to use that name (`TRADEMARK.md` in the repository). The code is published from https://github.com/cdgaete/ecosemantic.
@@ -0,0 +1,21 @@
1
+ # ecosemantic-engine
2
+
3
+ The lowest distribution of the EcoSemantic core: the product-system vocabulary, the typed errors every other distribution raises, and the exact life cycle assessment computation over a compiled background.
4
+
5
+ ## Names it publishes
6
+
7
+ - `ecosemantic.model` — the vocabulary a product system is made of: its entities, the refs that name what it reaches, the units the core knows, the structural validation a system passes before a calculation runs, the result-relevant projection a version hash names, and the summary an answer keeps of itself.
8
+ - `ecosemantic.errors` — the typed errors, `EcoSemanticError` and the refusals under it: a layer that cannot answer raises rather than approximates.
9
+ - `ecosemantic.engine` — the computation: `background` compiles a database into labelled memory-mapped arrays, `solver` makes the forward and adjoint solves from one factorisation, `calculation` reads scores, contributions, intensities, supply, inventory and paths from one solve, `foreground` gives a product system its columns over that background, `paths` walks the supply chain under one process, `montecarlo` takes each draw from the seed and the draw's index, `loops` judges the technosphere loops a draw is checked on, and `refine` refines a sparse solve.
10
+
11
+ ## What it depends on
12
+
13
+ pydantic, numpy and scipy, and nimblend for the memory-mapped arrays a background is compiled into. The `mkl` extra adds pypardiso, on Linux x86-64 and Windows x86-64, where MKL publishes wheels.
14
+
15
+ ## Installing it
16
+
17
+ `pip install ecosemantic` installs this distribution with the other five and the `ecosemantic` command. `pip install ecosemantic-engine` installs it alone, for code that builds on the vocabulary and the engine without a server; `pip install "ecosemantic-engine[mkl]"` adds the solver.
18
+
19
+ ## Licence
20
+
21
+ PolyForm Noncommercial License 1.0.0, copyright Carlos Gaete-Morales: `LICENSE` and `NOTICE` in this distribution. Any noncommercial purpose is permitted, by individuals and by charities, schools, public research and government bodies; commercial use needs a separate licence from him (cdgaete@gmail.com). EcoSemantic is his trademark, and the licence grants no right to use that name (`TRADEMARK.md` in the repository). The code is published from https://github.com/cdgaete/ecosemantic.
@@ -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
+ )