ecosemantic-databases 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.
Files changed (72) hide show
  1. ecosemantic/databases/__init__.py +1 -0
  2. ecosemantic/databases/bafu/__init__.py +1 -0
  3. ecosemantic/databases/bafu/build.py +44 -0
  4. ecosemantic/databases/bafu/cli.py +124 -0
  5. ecosemantic/databases/bafu/differences.py +52 -0
  6. ecosemantic/databases/bafu/download.py +142 -0
  7. ecosemantic/databases/bafu/probe.py +168 -0
  8. ecosemantic/databases/bafu/profile.py +91 -0
  9. ecosemantic/databases/bafu/releases/2026-v1.yaml +163 -0
  10. ecosemantic/databases/bafu/releases/differences-2026-v1.csv +12403 -0
  11. ecosemantic/databases/bafu/releases/unlinked-2026-v1.csv +1 -0
  12. ecosemantic/databases/bafu/setup.py +146 -0
  13. ecosemantic/databases/bafu/validate.py +323 -0
  14. ecosemantic/databases/bafu/workbook.py +186 -0
  15. ecosemantic/databases/checks.py +27 -0
  16. ecosemantic/databases/cli.py +33 -0
  17. ecosemantic/databases/command.py +57 -0
  18. ecosemantic/databases/ecoinvent/__init__.py +1 -0
  19. ecosemantic/databases/ecoinvent/build.py +63 -0
  20. ecosemantic/databases/ecoinvent/cli.py +161 -0
  21. ecosemantic/databases/ecoinvent/datasets.py +290 -0
  22. ecosemantic/databases/ecoinvent/download.py +69 -0
  23. ecosemantic/databases/ecoinvent/lcia.py +83 -0
  24. ecosemantic/databases/ecoinvent/masterdata.py +119 -0
  25. ecosemantic/databases/ecoinvent/overview.py +39 -0
  26. ecosemantic/databases/ecoinvent/probe.py +125 -0
  27. ecosemantic/databases/ecoinvent/profile.py +139 -0
  28. ecosemantic/databases/ecoinvent/py.typed +0 -0
  29. ecosemantic/databases/ecoinvent/release.py +52 -0
  30. ecosemantic/databases/ecoinvent/releases/3.10-cutoff.yaml +67 -0
  31. ecosemantic/databases/ecoinvent/releases/3.10.1-cutoff.yaml +69 -0
  32. ecosemantic/databases/ecoinvent/releases/3.11-cutoff.yaml +67 -0
  33. ecosemantic/databases/ecoinvent/releases/3.12-cutoff.yaml +71 -0
  34. ecosemantic/databases/ecoinvent/setup.py +85 -0
  35. ecosemantic/databases/ecoinvent/validate.py +732 -0
  36. ecosemantic/databases/lists.py +219 -0
  37. ecosemantic/databases/profiles.py +43 -0
  38. ecosemantic/databases/py.typed +0 -0
  39. ecosemantic/databases/scores.py +136 -0
  40. ecosemantic/databases/uslci/__init__.py +1 -0
  41. ecosemantic/databases/uslci/build.py +554 -0
  42. ecosemantic/databases/uslci/cli.py +124 -0
  43. ecosemantic/databases/uslci/download.py +92 -0
  44. ecosemantic/databases/uslci/probe.py +216 -0
  45. ecosemantic/databases/uslci/profile.py +68 -0
  46. ecosemantic/databases/uslci/releases/1.2026-09.0.yaml +24 -0
  47. ecosemantic/databases/uslci/releases/left-out-1.2026-09.0.csv +50 -0
  48. ecosemantic/databases/uslci/releases/unlinked-1.2026-09.0.csv +3230 -0
  49. ecosemantic/databases/uslci/setup.py +98 -0
  50. ecosemantic/databases/uslci/validate.py +292 -0
  51. ecosemantic/databases/zolca/__init__.py +1 -0
  52. ecosemantic/databases/zolca/derby/__init__.py +1 -0
  53. ecosemantic/databases/zolca/derby/catalog.py +272 -0
  54. ecosemantic/databases/zolca/derby/database.py +95 -0
  55. ecosemantic/databases/zolca/derby/log.py +116 -0
  56. ecosemantic/databases/zolca/derby/numbers.py +66 -0
  57. ecosemantic/databases/zolca/derby/pages.py +269 -0
  58. ecosemantic/databases/zolca/derby/records.py +210 -0
  59. ecosemantic/databases/zolca/derby/values.py +201 -0
  60. ecosemantic/databases/zolca/export/__init__.py +1 -0
  61. ecosemantic/databases/zolca/export/entities.py +527 -0
  62. ecosemantic/databases/zolca/export/geometry.py +146 -0
  63. ecosemantic/databases/zolca/export/refs.py +102 -0
  64. ecosemantic/databases/zolca/export/render.py +79 -0
  65. ecosemantic/databases/zolca/export/tables.py +77 -0
  66. ecosemantic/databases/zolca/export/write.py +174 -0
  67. ecosemantic_databases-0.1.8.dist-info/METADATA +81 -0
  68. ecosemantic_databases-0.1.8.dist-info/RECORD +72 -0
  69. ecosemantic_databases-0.1.8.dist-info/WHEEL +4 -0
  70. ecosemantic_databases-0.1.8.dist-info/entry_points.txt +2 -0
  71. ecosemantic_databases-0.1.8.dist-info/licenses/LICENSE +73 -0
  72. ecosemantic_databases-0.1.8.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1 @@
1
+ """Native EcoSemantic bundles built from a source database's own files."""
@@ -0,0 +1 @@
1
+ """Build a native EcoSemantic bundle from BAFU's own files."""
@@ -0,0 +1,44 @@
1
+ """Build a BAFU zolca into one openLCA JSON-LD bundle, read back by the core's importer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ecosemantic.databases.zolca.export.write import NotCarried, export
10
+ from ecosemantic.errors import Conflict
11
+ from ecosemantic.package.background import read_background
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Built:
16
+ """What a build wrote: the bundle, the records it holds by folder, and the rows its package does
17
+ not carry."""
18
+
19
+ bundle: Path
20
+ records: dict[str, int]
21
+ not_carried: tuple[NotCarried, ...]
22
+
23
+
24
+ def build(zolca: Path, *, version: str, out_dir: Path) -> Built:
25
+ """Write a zolca as `<out_dir>/bafu-<version>.zip` and import it under `bafu-<version>`. The
26
+ package is exported to a staging file beside the destination and moved into place only once the
27
+ core reads it, so a bundle the core refuses leaves nothing under the final name; a destination
28
+ that exists is `Conflict`."""
29
+ destination = out_dir / f"bafu-{version}.zip"
30
+ staging = out_dir / f".bafu-{version}.staging.zip"
31
+ if destination.exists():
32
+ raise Conflict(f"{destination.name} exists; name another destination")
33
+ out_dir.mkdir(parents=True, exist_ok=True)
34
+ written = export(zolca, staging)
35
+ try:
36
+ read_background(written.bundle, f"bafu-{version}", foreground=frozenset())
37
+ os.replace(staging, destination)
38
+ finally:
39
+ staging.unlink(missing_ok=True)
40
+ return Built(
41
+ bundle=destination,
42
+ records=written.records,
43
+ not_carried=written.not_carried,
44
+ )
@@ -0,0 +1,124 @@
1
+ """The command line: the subcommands a BAFU source answers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+ from typing import cast
8
+
9
+ from lca_data_interface import NexusSession
10
+
11
+ from ecosemantic.databases import command
12
+ from ecosemantic.databases.bafu.build import Built, build
13
+ from ecosemantic.databases.bafu.download import Downloads, Terms, download, translating
14
+ from ecosemantic.databases.bafu.probe import Probe, probe
15
+ from ecosemantic.databases.bafu.profile import RELEASES, read_profile, recorded
16
+ from ecosemantic.databases.bafu.validate import Counted, record, validate
17
+ from ecosemantic.databases.command import Command
18
+
19
+
20
+ def _download(args: argparse.Namespace) -> dict[str, object] | Terms:
21
+ """Fetch a release's packs through Nexus and answer each file's path and the hash the profile
22
+ pins; while the terms are not accepted, answer the licence file and the citation instead."""
23
+ profile = read_profile(args.version)
24
+ with translating(), NexusSession.from_environment(base=profile.server) as session:
25
+ fetched = download(profile, session, accept_terms=args.accept_terms, results=args.results)
26
+ if isinstance(fetched, Terms):
27
+ return fetched
28
+ return {
29
+ "inventory": {"path": str(fetched.inventory), "sha256": profile.inventory.sha256},
30
+ "results": (
31
+ None
32
+ if fetched.results is None
33
+ else {"path": str(fetched.results), "sha256": profile.results.sha256}
34
+ ),
35
+ "licence": str(fetched.licence),
36
+ "citation": fetched.citation,
37
+ }
38
+
39
+
40
+ def _probe(args: argparse.Namespace) -> Probe:
41
+ """Read a zolca and answer what it holds and what the core would not take of it."""
42
+ return probe(Path(args.zolca))
43
+
44
+
45
+ def _fetched(version: str) -> Path:
46
+ """The zolca a release's profile pins, fetched through Nexus with the pack's terms accepted, so
47
+ the answer is always the downloaded file."""
48
+ profile = read_profile(version)
49
+ with translating(), NexusSession.from_environment(base=profile.server) as session:
50
+ fetched = cast(Downloads, download(profile, session, accept_terms=True, results=False))
51
+ return fetched.inventory
52
+
53
+
54
+ def _build(args: argparse.Namespace) -> Built:
55
+ """Build the zolca given, or the one the release's profile pins, into one bundle under
56
+ `--out`."""
57
+ zolca = Path(args.zolca) if args.zolca is not None else _fetched(args.version)
58
+ return build(zolca, version=args.version, out_dir=Path(args.out))
59
+
60
+
61
+ def _validate(args: argparse.Namespace) -> Counted:
62
+ """Compare a bundle with its release's workbook and the release's two lists, record the run
63
+ when asked, and answer the differences counted by column."""
64
+ profile = read_profile(args.version)
65
+ validation = validate(
66
+ Path(args.bundle),
67
+ results=Path(args.results),
68
+ profile=profile,
69
+ unlinked=recorded(profile.unlinked),
70
+ differences=recorded(profile.differences),
71
+ compiled=Path(args.compiled) if args.compiled is not None else None,
72
+ )
73
+ if args.record:
74
+ record(profile, validation, directory=RELEASES)
75
+ return validation.counted()
76
+
77
+
78
+ def _code(answer: object) -> int:
79
+ """2 for the answer that asks for the pack's terms to be accepted, 1 for a validation that
80
+ found a mismatch, and 0 for an answer that stands."""
81
+ if isinstance(answer, Terms):
82
+ return 2
83
+ return 1 if isinstance(answer, Counted) and bool(answer.mismatches) else 0
84
+
85
+
86
+ COMMANDS: dict[str, Command] = {
87
+ "download": Command("Fetch a release's pinned packs from Nexus.", _download),
88
+ "probe": Command("Read a zolca and list what it holds.", _probe),
89
+ "build": Command("Write a release's bundle and import it.", _build),
90
+ "validate": Command("Compare a bundle with its release's workbook.", _validate),
91
+ }
92
+
93
+
94
+ def _parser() -> argparse.ArgumentParser:
95
+ parser = argparse.ArgumentParser(
96
+ prog="ecosemantic-databases bafu",
97
+ description="Build a native EcoSemantic bundle from a BAFU release.",
98
+ )
99
+ subcommands = parser.add_subparsers(dest="command", required=True)
100
+ for name, each in COMMANDS.items():
101
+ sub = subcommands.add_parser(name, help=each.help)
102
+ if name == "download":
103
+ sub.add_argument("version")
104
+ sub.add_argument("--accept-terms", action="store_true")
105
+ sub.add_argument("--results", action="store_true")
106
+ elif name == "probe":
107
+ sub.add_argument("zolca")
108
+ elif name == "build":
109
+ sub.add_argument("version")
110
+ sub.add_argument("--out", required=True)
111
+ sub.add_argument("--zolca")
112
+ elif name == "validate":
113
+ sub.add_argument("version")
114
+ sub.add_argument("--bundle", required=True)
115
+ sub.add_argument("--results", required=True)
116
+ sub.add_argument("--compiled")
117
+ sub.add_argument("--record", action="store_true")
118
+ return parser
119
+
120
+
121
+ def main(argv: list[str] | None = None) -> int:
122
+ """Run one command; 0 on success, 1 on a typed error or a validation that found a mismatch,
123
+ and 2 when the terms are not accepted."""
124
+ return command.main(_parser(), COMMANDS, argv, code=_code)
@@ -0,0 +1,52 @@
1
+ """The (process, column) pairs a BAFU validation knows differ beyond its tolerance, keyed by the
2
+ process's id and the workbook column's header so neither BAFU's names nor its values are written
3
+ into the package."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import csv
8
+ from collections.abc import Iterable
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from ecosemantic.errors import Invalid
13
+
14
+ COLUMNS = ("process_id", "column")
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Difference:
19
+ """One pair whose two scores differ beyond the tolerance."""
20
+
21
+ process_id: str
22
+ column: str
23
+
24
+
25
+ def _order(row: Difference) -> tuple[str, str]:
26
+ """A row's sort position: its process, then its column."""
27
+ return (row.process_id, row.column)
28
+
29
+
30
+ def read_differences(path: Path) -> tuple[Difference, ...]:
31
+ """The pairs a list holds, in process and column order; a pair the list holds twice is
32
+ `Invalid`."""
33
+ with path.open(newline="", encoding="utf-8") as read:
34
+ rows = [
35
+ Difference(process_id=record["process_id"], column=record["column"])
36
+ for record in csv.DictReader(read)
37
+ ]
38
+ seen: set[tuple[str, str]] = set()
39
+ for row in rows:
40
+ if _order(row) in seen:
41
+ raise Invalid(f"{path} holds {row.process_id} under {row.column!r} twice")
42
+ seen.add(_order(row))
43
+ return tuple(sorted(rows, key=_order))
44
+
45
+
46
+ def write_differences(path: Path, rows: Iterable[Difference]) -> None:
47
+ """Write the pairs as CSV, in process and column order."""
48
+ with path.open("w", newline="", encoding="utf-8") as written:
49
+ writer = csv.writer(written, lineterminator="\n")
50
+ writer.writerow(COLUMNS)
51
+ for row in sorted(rows, key=_order):
52
+ writer.writerow((row.process_id, row.column))
@@ -0,0 +1,142 @@
1
+ """Fetch BAFU's openLCA pack and its LCIA results pack from Nexus, refusing a file whose hash is not
2
+ the one its profile pins."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Final, Protocol
11
+
12
+ from lca_data_interface import Downloaded
13
+ from lca_data_interface import Pack as NexusPack
14
+ from lca_data_interface.errors import (
15
+ CredentialsMissing,
16
+ LcaDataInterfaceError,
17
+ LicenceNotAccepted,
18
+ LoginRefused,
19
+ MissingField,
20
+ NotInCatalog,
21
+ NotOnServer,
22
+ ServiceError,
23
+ ShortDownload,
24
+ TokenRequired,
25
+ )
26
+
27
+ from ecosemantic.databases.bafu.profile import Pack, Profile
28
+ from ecosemantic.errors import Conflict, EcoSemanticError, Invalid, NotFound, Unavailable
29
+
30
+ _TRANSLATIONS: Final[dict[type[LcaDataInterfaceError], type[EcoSemanticError]]] = {
31
+ CredentialsMissing: Unavailable,
32
+ LoginRefused: Unavailable,
33
+ TokenRequired: Unavailable,
34
+ ShortDownload: Unavailable,
35
+ ServiceError: Unavailable,
36
+ NotInCatalog: NotFound,
37
+ NotOnServer: NotFound,
38
+ MissingField: Invalid,
39
+ LicenceNotAccepted: Invalid,
40
+ }
41
+
42
+
43
+ class Nexus(Protocol):
44
+ """The three calls a download makes on a Nexus session; `NexusSession` satisfies it and a test
45
+ doubles it."""
46
+
47
+ def pack(self, uuid: str) -> NexusPack: ...
48
+
49
+ def licence(self, pack: NexusPack) -> Path: ...
50
+
51
+ def download(
52
+ self,
53
+ pack: NexusPack,
54
+ *,
55
+ version: int,
56
+ file_format: str,
57
+ accept_licence: bool,
58
+ ) -> Downloaded: ...
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Terms:
63
+ """What a download answers while a pack's terms are not accepted: the licence file to read and
64
+ the citation to carry."""
65
+
66
+ licence: Path
67
+ citation: str
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Downloads:
72
+ """The files a download fetched, the pack's licence file, and the release's citation."""
73
+
74
+ inventory: Path
75
+ results: Path | None
76
+ licence: Path
77
+ citation: str
78
+
79
+
80
+ @contextmanager
81
+ def translating() -> Iterator[None]:
82
+ """A client call whose errors leave as the core's, of the kind and with the names the spec's
83
+ *Errors* table gives them."""
84
+ try:
85
+ yield
86
+ except LcaDataInterfaceError as error:
87
+ raise _translated(error) from error
88
+
89
+
90
+ def download(
91
+ profile: Profile, session: Nexus, *, accept_terms: bool, results: bool
92
+ ) -> Downloads | Terms:
93
+ """Fetch a release's packs through Nexus, each file's SHA-256 compared with the profile's.
94
+
95
+ Without `accept_terms` the pack's licence file is fetched and nothing is downloaded: the answer
96
+ is the file a person reads to accept the terms. `results` also fetches the LCIA results pack,
97
+ which only a validation reads.
98
+ """
99
+ with translating():
100
+ inventory_pack = session.pack(profile.inventory.pack)
101
+ licence = session.licence(inventory_pack)
102
+ if not accept_terms:
103
+ return Terms(licence=licence, citation=profile.citation)
104
+ inventory = session.download(
105
+ inventory_pack,
106
+ version=profile.inventory.version,
107
+ file_format=profile.inventory.format,
108
+ accept_licence=True,
109
+ )
110
+ results_file = _fetched(profile.results, session) if results else None
111
+ return Downloads(
112
+ inventory=_checked(profile.inventory, inventory),
113
+ results=None if results_file is None else _checked(profile.results, results_file),
114
+ licence=licence,
115
+ citation=profile.citation,
116
+ )
117
+
118
+
119
+ def _fetched(pinned: Pack, session: Nexus) -> Downloaded:
120
+ """One pack's file at the version and the format the profile pins, its terms accepted."""
121
+ return session.download(
122
+ session.pack(pinned.pack),
123
+ version=pinned.version,
124
+ file_format=pinned.format,
125
+ accept_licence=True,
126
+ )
127
+
128
+
129
+ def _checked(pinned: Pack, downloaded: Downloaded) -> Path:
130
+ """The path of a file whose SHA-256 is the one the profile pins; a different hash is a conflict
131
+ naming the file and both hashes."""
132
+ if downloaded.sha256 != pinned.sha256:
133
+ raise Conflict(
134
+ f"{downloaded.path.name} hashes to {downloaded.sha256}, the profile pins "
135
+ f"{pinned.sha256}"
136
+ )
137
+ return downloaded.path
138
+
139
+
140
+ def _translated(error: LcaDataInterfaceError) -> EcoSemanticError:
141
+ """The core's error for one the client raised."""
142
+ return _TRANSLATIONS[type(error)](str(error))
@@ -0,0 +1,168 @@
1
+ """What a BAFU zolca holds and what of it the core's reader takes: its schema version, its tables,
2
+ the counts its export becomes, the inputs that stay unlinked, the exchanges the core refuses and
3
+ the rows its package does not carry."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import tempfile
8
+ from collections import Counter
9
+ from collections.abc import Iterable, Sequence
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ import olca_schema as o
14
+ from olca_schema.zipio import ZipReader
15
+
16
+ from ecosemantic.databases.lists import distinct_unlinked
17
+ from ecosemantic.databases.zolca.derby.database import Database
18
+ from ecosemantic.databases.zolca.export.write import NotCarried, export
19
+ from ecosemantic.errors import Invalid, NotSupported
20
+ from ecosemantic.model import units
21
+ from ecosemantic.package.background import Import, exchange_refusal, read_background
22
+ from ecosemantic.package.olca import uncertainty_refusal
23
+
24
+ BUNDLE = "probe.zip"
25
+ DATABASE = "bafu-probe"
26
+ VERSION_TABLE = "OPENLCA_VERSION"
27
+ VERSION_COLUMN = "VERSION"
28
+ PREFIX = "ecosemantic-bafu-probe-"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class TableProbe:
33
+ """One table of the database: its rows, and the SQL type each column is stored as."""
34
+
35
+ name: str
36
+ rows: int
37
+ columns: dict[str, str]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Probe:
42
+ """What a zolca holds and what the core would take of it: its schema version, every table with
43
+ its rows and column types, what its export becomes in the core, the unlinked inputs by their
44
+ flow's top category, the exchanges the core refuses by its own reason, the rows the package
45
+ does not carry, and the factors method categories set apart."""
46
+
47
+ schema_version: str
48
+ tables: tuple[TableProbe, ...]
49
+ processes: int
50
+ flows: int
51
+ methods: int
52
+ categories: int
53
+ unlinked: dict[str, int]
54
+ refused: dict[str, int]
55
+ not_carried: tuple[NotCarried, ...]
56
+ set_apart: int
57
+
58
+
59
+ def _schema_version(database: Database) -> str:
60
+ """The openLCA schema version the database stores."""
61
+ stored = list(database.rows(VERSION_TABLE))
62
+ if len(stored) != 1:
63
+ raise Invalid(f"{database.path} stores {len(stored)} {VERSION_TABLE} rows, not one")
64
+ return str(stored[0][VERSION_COLUMN])
65
+
66
+
67
+ def _tables(database: Database) -> tuple[TableProbe, ...]:
68
+ """Every table of schema `APP`, its rows counted and each column's SQL type named."""
69
+ return tuple(
70
+ TableProbe(
71
+ name=name,
72
+ rows=sum(1 for _ in database.rows(table)),
73
+ columns={column.name: column.type.name for column in table.columns},
74
+ )
75
+ for name, table in sorted(database.tables().items())
76
+ )
77
+
78
+
79
+ def _read(bundle: Path) -> tuple[tuple[o.Process, ...], dict[str, o.Flow]]:
80
+ """A package's processes, and its flows by id."""
81
+ with ZipReader(str(bundle)) as reader:
82
+ processes = tuple(reader.read_each(o.Process))
83
+ flows = {flow.id: flow for flow in reader.read_each(o.Flow)}
84
+ return processes, flows
85
+
86
+
87
+ def _factors(bundle: Path) -> list[o.ImpactFactor]:
88
+ """Every impact factor the package's categories carry."""
89
+ with ZipReader(str(bundle)) as reader:
90
+ return [
91
+ factor
92
+ for category in reader.read_each(o.ImpactCategory)
93
+ for factor in category.impact_factors or []
94
+ ]
95
+
96
+
97
+ def _symbols(records: Iterable[o.Exchange | o.ImpactFactor]) -> set[str]:
98
+ """The unit symbols a run of exchanges or impact factors names."""
99
+ return {
100
+ record.unit.name
101
+ for record in records
102
+ if record.unit is not None and record.unit.name is not None
103
+ }
104
+
105
+
106
+ def _known(symbol: str) -> bool:
107
+ """Whether the core's unit table holds a symbol."""
108
+ try:
109
+ units.by_symbol(symbol)
110
+ except NotSupported:
111
+ return False
112
+ return True
113
+
114
+
115
+ def _lacking(bundle: Path, processes: Sequence[o.Process]) -> tuple[str, ...]:
116
+ """Every unit symbol the package's exchanges and impact factors name that the core's table
117
+ lacks, in one sorted tuple."""
118
+ named = _symbols(
119
+ exchange for process in processes for exchange in process.exchanges or []
120
+ ) | _symbols(_factors(bundle))
121
+ return tuple(sorted(symbol for symbol in named if not _known(symbol)))
122
+
123
+
124
+ def _unlinked(imported: Import, flows: dict[str, o.Flow]) -> dict[str, int]:
125
+ """The import's distinct unlinked inputs, counted by the top category of their flow."""
126
+ tops = {flow_id: (flow.category or "").split("/")[0] for flow_id, flow in flows.items()}
127
+ return dict(Counter(row.flow_category for row in distinct_unlinked(imported, tops)))
128
+
129
+
130
+ def _refused(processes: Sequence[o.Process], flows: dict[str, o.Flow]) -> Counter[str]:
131
+ """What the core's two refusals answer over the package's exchanges, counted by reason."""
132
+ return Counter(
133
+ reason
134
+ for process in processes
135
+ for exchange in process.exchanges or []
136
+ if exchange.flow is not None
137
+ and exchange.flow.id in flows
138
+ and (
139
+ reason := uncertainty_refusal(exchange.uncertainty, exchange.amount or 0.0)
140
+ or exchange_refusal(exchange, flows[exchange.flow.id])
141
+ )
142
+ )
143
+
144
+
145
+ def probe(zolca: Path) -> Probe:
146
+ """Read a zolca through the export and the core's importer in a temporary directory, and answer
147
+ what it holds, what the core refuses of it and what its package does not carry; a unit symbol
148
+ the core's table lacks is `NotSupported`, every lacking symbol named at once."""
149
+ database = Database(zolca)
150
+ with tempfile.TemporaryDirectory(prefix=PREFIX) as directory:
151
+ written = export(zolca, Path(directory) / BUNDLE)
152
+ processes, flows = _read(written.bundle)
153
+ lacking = _lacking(written.bundle, processes)
154
+ if lacking:
155
+ raise NotSupported(f"{zolca}: the core's unit table lacks {', '.join(lacking)}")
156
+ imported = read_background(written.bundle, DATABASE, foreground=frozenset())
157
+ return Probe(
158
+ schema_version=_schema_version(database),
159
+ tables=_tables(database),
160
+ processes=imported.processes,
161
+ flows=len(imported.flows),
162
+ methods=len({method.key[1] for method in imported.methods}),
163
+ categories=len(imported.methods),
164
+ unlinked=_unlinked(imported, flows),
165
+ refused=dict(_refused(processes, flows)),
166
+ not_carried=written.not_carried,
167
+ set_apart=sum(method.set_apart for method in imported.methods),
168
+ )
@@ -0,0 +1,91 @@
1
+ """The facts a BAFU release pins: the two packs Nexus serves, the workbook's columns mapped to the
2
+ method and category each scores, its total column, the lists its validation holds it to, and what a
3
+ validated run recorded."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ from pydantic import BaseModel, ConfigDict
10
+
11
+ from ecosemantic.databases import profiles
12
+
13
+ RELEASES = Path(__file__).parent / "releases"
14
+
15
+
16
+ class Pack(BaseModel):
17
+ """One file a release is read from: the datafile Nexus serves, its version, its format, and the
18
+ SHA-256 of the bytes the profile pins."""
19
+
20
+ model_config = ConfigDict(frozen=True)
21
+
22
+ pack: str
23
+ version: int
24
+ format: str
25
+ sha256: str
26
+
27
+
28
+ class Column(BaseModel):
29
+ """One workbook column that scores a category, and the method and category it scores."""
30
+
31
+ model_config = ConfigDict(frozen=True)
32
+
33
+ header: str
34
+ method: str
35
+ category: str
36
+
37
+
38
+ class Total(BaseModel):
39
+ """The workbook column that sums one method's categories."""
40
+
41
+ model_config = ConfigDict(frozen=True)
42
+
43
+ header: str
44
+ method: str
45
+
46
+
47
+ class Validated(BaseModel):
48
+ """What a validated run measured: the processes and columns it compared, how many known
49
+ differences it holds, and the largest relative difference inside the tolerance."""
50
+
51
+ model_config = ConfigDict(frozen=True)
52
+
53
+ processes: int
54
+ columns: int
55
+ differences: int
56
+ largest_rel_diff_within_tolerance: float
57
+
58
+
59
+ class Profile(BaseModel):
60
+ """One BAFU release's pinned facts: its inventory and results packs, the workbook's columns and
61
+ total, the file names of its two lists, and the tolerance and counts a validated run
62
+ recorded."""
63
+
64
+ model_config = ConfigDict(frozen=True)
65
+
66
+ version: str
67
+ server: str
68
+ citation: str
69
+ inventory: Pack
70
+ results: Pack
71
+ columns: tuple[Column, ...]
72
+ total: Total
73
+ unlinked: str
74
+ differences: str
75
+ tolerance: float
76
+ validated: Validated | None = None
77
+
78
+
79
+ def versions() -> tuple[str, ...]:
80
+ """The release labels the profiles directory carries, newest first."""
81
+ return profiles.versions(RELEASES)
82
+
83
+
84
+ def recorded(name: str) -> Path | None:
85
+ """The list file a profile names, or nothing when the release has not recorded it yet."""
86
+ return profiles.recorded(RELEASES, name)
87
+
88
+
89
+ def read_profile(version: str) -> Profile:
90
+ """The profile named for a release label; a label without one is not found."""
91
+ return profiles.read_profile(Profile, RELEASES, version)