From fdd859ef8a0e38d01da521f1895b22a8fcae4f96 Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Wed, 24 Jun 2026 17:01:55 +0200 Subject: [PATCH 1/5] feat(pyvolca): surface co-product allocation on the typed client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-output process splits its parent activity's burden across its co-products, and the engine carries each share as `allocationPercent` on every `allProducts` entry. The typed client dropped it: `Activity` declared only its six identity/quantity fields and `FromJson` keeps only declared keys, so the share never reached `ActivityDetail.all_products`. Declare `allocation_percent` / `allocation_formula` on `Activity` (both optional — search results carry no share) and add `ActivityDetail.allocation_percent` for the fetched process's own share. `is_allocated` now counts the structured shares rather than importing the description-text parser. --- pyvolca/src/volca/types.py | 36 +++++++++++-- pyvolca/tests/test_typed_returns.py | 83 +++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/pyvolca/src/volca/types.py b/pyvolca/src/volca/types.py index 4fc1d3ba..72cdb4bb 100644 --- a/pyvolca/src/volca/types.py +++ b/pyvolca/src/volca/types.py @@ -438,6 +438,14 @@ class Activity(FromJson): (typically ``1.0`` of ``"kg"`` / ``"MJ"`` / etc.). ``location`` is the geography code (``"FR"``, ``"GLO"``, ``"RoW"``…). A process has no name of its own — compose a label from ``activity_name`` + ``product_name``. + + ``allocation_percent`` is this product's share (0..100) of the parent + activity's exchanges in a multi-output (allocated) process — e.g. a + cheese activity that also yields whey, cream and permeate gives each + product its own share, summing to ~100. It is ``None`` for single-output + processes. ``allocation_formula`` carries the raw symbolic formula when + the source expressed the share as an expression rather than a number, + else ``None``. """ process_id: str @@ -446,6 +454,8 @@ class Activity(FromJson): product_name: str product_amount: float product_unit: str + allocation_percent: float | None = None + allocation_formula: str | None = None @dataclass @@ -1025,15 +1035,31 @@ def technosphere_inputs(self) -> list[TechnosphereExchange]: if isinstance(e, TechnosphereExchange) and e.is_input ] + @property + def allocation_percent(self) -> float | None: + """This process's own allocation share (0..100), or ``None``. + + A multi-output process splits the parent activity's burden across its + co-products; every :attr:`all_products` entry carries its share. This + returns the share of *this* process — the entry whose ``process_id`` + matches — and ``None`` for single-output processes. + """ + return next( + (p.allocation_percent for p in self.all_products + if p.process_id == self.process_id), + None, + ) + @property def is_allocated(self) -> bool: - """True iff description contains a parseable allocation block. + """True iff the activity splits its burden across several co-products. - Implemented in volca/agribalyse.py to keep Agribalyse-specific text - parsing out of the generic types module. + Reads the structured ``allocation_percent`` the engine sets on each + :attr:`all_products` entry (authoritative), not the description text. """ - from .agribalyse import parse_allocation - return parse_allocation(self.description) is not None + return sum( + 1 for p in self.all_products if p.allocation_percent is not None + ) > 1 # --------------------------------------------------------------------------- diff --git a/pyvolca/tests/test_typed_returns.py b/pyvolca/tests/test_typed_returns.py index ca7753de..9efb4f41 100644 --- a/pyvolca/tests/test_typed_returns.py +++ b/pyvolca/tests/test_typed_returns.py @@ -18,6 +18,7 @@ from volca import ( Activity, + ActivityDetail, AggregateOp, AggregateScope, BioDirection, @@ -422,3 +423,85 @@ def test_contributing_activities_from_json(self): }) assert ca.activities[0].activity_name == "Farm" assert ca.activities[0].share_pct == 38.2 + + +# --------------------------------------------------------------------------- +# Co-product allocation surfacing +# --------------------------------------------------------------------------- + + +class TestActivityAllocation: + """Each co-product's allocation share surfaces on the typed client. + + The engine carries ``allocationPercent`` on every ``allProducts`` entry, + but the client used to drop it: ``Activity`` declared only six fields and + ``FromJson`` keeps only declared keys. A multi-output process (cheese → + cheese / whey / cream / permeate / whey concentrated) thus lost its split. + """ + + def test_activity_from_json_surfaces_allocation(self): + a = Activity.from_json({ + "processId": "cheese_cream", "activityName": "Abondance cheese production", + "location": "Europe, Western", "productName": "...1 kg of cream...", + "productAmount": 0.0686, "productUnit": "kg", + "allocationPercent": 2.27, "allocationFormula": None, + }) + assert a.allocation_percent == 2.27 + assert a.allocation_formula is None + + def test_activity_from_json_defaults_when_absent(self): + # Search results carry no allocation — must default to None, not crash. + a = Activity.from_json({ + "processId": "flour", "activityName": "Wheat flour", "location": "FR", + "productName": "wheat flour", "productAmount": 1.0, "productUnit": "kg", + }) + assert a.allocation_percent is None + assert a.allocation_formula is None + + @staticmethod + def _cheese_envelope() -> dict: + def prod(pid: str, name: str, amount: float, pct: float) -> dict: + return { + "processId": pid, "activityName": "Abondance cheese production", + "location": "Europe, Western", "productName": name, + "productAmount": amount, "productUnit": "kg", + "allocationPercent": pct, "allocationFormula": None, + } + return {"activity": { + "processId": "cheese_cheese", + "activityName": "Abondance cheese production", + "location": "Europe, Western", "unit": "kg", + "allProducts": [ + prod("cheese_perm", "...1 kg of permeate...", 5.58, 24.3), + prod("cheese_whey", "...1 kg of whey...", 0.776, 4.39), + prod("cheese_cheese", "...1 kg of Abondance cheese...", 1.0, 51.4), + prod("cheese_cream", "...1 kg of cream...", 0.0686, 2.27), + prod("cheese_wheyc", "...1 kg of whey concentrated...", 1.13, 17.6), + ], + }} + + def test_all_products_carry_allocation(self): + detail = ActivityDetail.from_json(self._cheese_envelope()) + shares = {p.product_name: p.allocation_percent for p in detail.all_products} + assert shares["...1 kg of Abondance cheese..."] == 51.4 + assert sum(p.allocation_percent for p in detail.all_products) == pytest.approx(99.96) + + def test_allocation_percent_property_returns_own_share(self): + # detail.process_id == cheese_cheese → its own share is the cheese one. + detail = ActivityDetail.from_json(self._cheese_envelope()) + assert detail.allocation_percent == 51.4 + + def test_is_allocated_true_for_multi_output(self): + assert ActivityDetail.from_json(self._cheese_envelope()).is_allocated is True + + def test_is_allocated_false_for_mono_product(self): + detail = ActivityDetail.from_json({"activity": { + "processId": "flour", "activityName": "Wheat flour", "location": "FR", + "unit": "kg", + "allProducts": [{ + "processId": "flour", "activityName": "Wheat flour", "location": "FR", + "productName": "wheat flour", "productAmount": 1.0, "productUnit": "kg", + }], + }}) + assert detail.allocation_percent is None + assert detail.is_allocated is False From 73aad227a6bb253211e2035f15cb118886c65a9d Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Wed, 24 Jun 2026 17:02:09 +0200 Subject: [PATCH 2/5] refactor(pyvolca): derive Agribalyse allocation from the structured field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_allocated` and `decompose`'s co-product enumeration scraped the allocation factors out of the description text. That text is rounded and can drift from the factor the engine actually applied — e.g. a cheese whose allocation comment reads 52.62% but whose applied share is 51.4%. Read the per-product shares from `all_products` instead, falling back to the text parse only when structured shares are absent (older databases). `parse_allocation` stays for the one thing the wire doesn't carry: the human-readable allocation method label. --- pyvolca/src/volca/agribalyse.py | 40 ++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/pyvolca/src/volca/agribalyse.py b/pyvolca/src/volca/agribalyse.py index 80e69f68..3870419e 100644 --- a/pyvolca/src/volca/agribalyse.py +++ b/pyvolca/src/volca/agribalyse.py @@ -101,10 +101,14 @@ class Decomposition: def parse_allocation(description: list[str]) -> Allocation | None: - """Parse allocation factors from a process's pfaDescription paragraphs. - - Returns ``None`` when no allocation block is found (genuine mono-product - processes) or when the text cannot be parsed. + """Parse the allocation block from a process's description paragraphs. + + The authoritative per-product shares live on the structured + ``ActivityDetail.all_products`` (``allocation_percent``); this text parse + is kept for the human-readable allocation *method* (dry matter, economic, + …), which the wire doesn't carry, and as a fallback for older databases + without structured shares. Returns ``None`` when no allocation block is + found (genuine mono-product processes) or when the text can't be parsed. """ text = " ".join(description or []) if not _ALLOC_KEYWORD_RE.search(text): @@ -299,6 +303,7 @@ def decompose(client: "Client", process_id: str) -> Decomposition: # sub-processes), so we re-detect once after descending. target_pid = process_id target_description = act.description + alloc_act = act # detail that owns the allocation shares (all_products) target_pattern = pattern dummy_op = False operation_name: str | None = None @@ -314,6 +319,7 @@ def decompose(client: "Client", process_id: str) -> Decomposition: target_pid = wfldb_e.target_process_id target_act = client.get_activity(target_pid) target_description = target_act.description + alloc_act = target_act target_pattern = detect_pattern(target_act) # Pattern C: for layered processes, point the aggregate queries at the @@ -415,11 +421,20 @@ def agg_total(**kw: object) -> float: co_products = [(name, qty, unit) for (name, unit), qty in grouped.items()] # Agribalyse uses allocation-based modelling, so co-products rarely - # appear as technosphere outputs — they're recorded in the allocation - # block of the description instead. Fall back to the parsed allocation: - # siblings of the main product (whose name is the closest match to the - # wrapping activity name) become the co-product list. + # appear as technosphere outputs — the engine records each co-product's + # allocation share on all_products instead. Prefer that authoritative + # split (allocation_percent); the description text is rounded and can + # drift from the applied factor, so it's only a fallback for older + # databases without structured shares. `allocation` is still parsed for + # its human-readable method label (dry matter, economic, …). allocation = parse_allocation(target_description) + if not co_products: + co_products = [ + (p.product_name, p.allocation_percent / 100.0, "kg/kg") + for p in alloc_act.all_products + if p.allocation_percent is not None + and p.process_id != alloc_act.process_id + ] if not co_products and allocation is not None and allocation.factors: act_name_l = act.activity_name.lower() main_key = max( @@ -460,5 +475,10 @@ def agg_total(**kw: object) -> float: def is_allocated(activity: ActivityDetail) -> bool: - """True iff an activity's description contains a parseable allocation block.""" - return parse_allocation(activity.description) is not None + """True iff the activity splits its burden across several co-products. + + Thin alias for :attr:`ActivityDetail.is_allocated`, which reads the + structured allocation shares on ``all_products`` rather than the + description text. + """ + return activity.is_allocated From a125de12770d6bd133ecd73203ac177890bd10a6 Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Wed, 24 Jun 2026 17:02:22 +0200 Subject: [PATCH 3/5] chore(pyvolca): release 0.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds co-product allocation shares to the typed client. Client-only and additive — it reads wire fields the engine already emits, so the wire contract is unchanged. --- pyvolca/CHANGELOG.md | 22 ++++++++++++++++++++++ pyvolca/pyproject.toml | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/pyvolca/CHANGELOG.md b/pyvolca/CHANGELOG.md index 7885b042..a2f950ff 100644 --- a/pyvolca/CHANGELOG.md +++ b/pyvolca/CHANGELOG.md @@ -18,6 +18,28 @@ git cliff --unreleased --tag pyvolca-v0.X.Y # render as a released section Then paste the rendered block at the top of this file and tighten wording. +## [0.7.0] - 2026-06-24 + +Co-product allocation shares are now visible from the typed client. + +### Added + +- `Activity.allocation_percent` / `Activity.allocation_formula` — a co-product's + share of its parent activity's burden. They populate every + `ActivityDetail.all_products` entry, so a multi-output process (e.g. a cheese + that also yields whey, cream and permeate) shows how its impact is split across + products. `None` for single-output processes. +- `ActivityDetail.allocation_percent` — the share of the process you fetched. + +### Changed + +- `ActivityDetail.is_allocated` (and `agribalyse.is_allocated`) now read the + structured shares on `all_products` instead of scraping the description text: + more reliable, and it matches the factor the engine actually applied (the + description's allocation comment is rounded and can drift — e.g. 52.62% in text + vs 51.4% applied). `agribalyse.decompose` enumerates co-products the same way, + keeping the text parse as a fallback for older databases. + ## [0.6.0] - 2026-06-21 Breaking: an activity's name fields are renamed for clarity. Needs the companion engine release (the wire keys changed too). diff --git a/pyvolca/pyproject.toml b/pyvolca/pyproject.toml index cc8af6c3..e04932b1 100644 --- a/pyvolca/pyproject.toml +++ b/pyvolca/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyvolca" -version = "0.6.0" +version = "0.7.0" description = "Python client for VoLCA — Life Cycle Assessment engine" requires-python = ">=3.10" license = "Apache-2.0" From 3a340d4ec3463e036e6b6ab5202d8db9294671ce Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Wed, 24 Jun 2026 17:15:34 +0200 Subject: [PATCH 4/5] docs(pyvolca): sync README API reference for allocation fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated by scripts/gen_api_md.py — picks up the new Activity allocation_percent / allocation_formula fields. Keeps the test_readme_api_reference_in_sync drift gate green. --- pyvolca/README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pyvolca/README.md b/pyvolca/README.md index c9fd1b4c..ccfd8d18 100644 --- a/pyvolca/README.md +++ b/pyvolca/README.md @@ -26,7 +26,7 @@ pyvolca speaks one revision of the engine's JSON wire format; the engine adverti _Generated from `volca._compat` — run `python scripts/gen_api_md.py` to regenerate._ -This build of **pyvolca 0.6.0** speaks wire format **1** and requires a VoLCA engine **≥ v0.8.0**. +This build of **pyvolca 0.7.0** speaks wire format **1** and requires a VoLCA engine **≥ v0.8.0**. @@ -828,6 +828,14 @@ and is what you pass to every detail endpoint (:meth:`Client.get_activity`, geography code (``"FR"``, ``"GLO"``, ``"RoW"``…). A process has no name of its own — compose a label from ``activity_name`` + ``product_name``. +``allocation_percent`` is this product's share (0..100) of the parent +activity's exchanges in a multi-output (allocated) process — e.g. a +cheese activity that also yields whey, cream and permeate gives each +product its own share, summing to ~100. It is ``None`` for single-output +processes. ``allocation_formula`` carries the raw symbolic formula when +the source expressed the share as an expression rather than a number, +else ``None``. + | Field | Type | Default | |-------|------|---------| | `process_id` | `str` | — | @@ -836,6 +844,8 @@ its own — compose a label from ``activity_name`` + ``product_name``. | `product_name` | `str` | — | | `product_amount` | `float` | — | | `product_unit` | `str` | — | +| `allocation_percent` | `float \| None` | None | +| `allocation_formula` | `str \| None` | None | ### `ActivityContribution` From c79721eb9451624bd9324c7ed0672b31b940c349 Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Wed, 24 Jun 2026 17:50:18 +0200 Subject: [PATCH 5/5] fix(pyvolca): keep description-text fallback in agribalyse.is_allocated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_allocated had become a thin alias to ActivityDetail.is_allocated, which reads structured allocation_percent only. On older Agribalyse databases that carry no structured shares — just an allocation block in the description — a genuinely allocated process then reported False, disagreeing with decompose, which still parses that text to enumerate co-products. Restore the prefer-structured-then-text precedence decompose uses, so the Agribalyse-specific helper keeps the text fallback while the generic property stays structured-only. --- pyvolca/CHANGELOG.md | 13 +++++++------ pyvolca/src/volca/agribalyse.py | 10 ++++++---- pyvolca/tests/test_typed_returns.py | 27 +++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/pyvolca/CHANGELOG.md b/pyvolca/CHANGELOG.md index a2f950ff..37f70e90 100644 --- a/pyvolca/CHANGELOG.md +++ b/pyvolca/CHANGELOG.md @@ -33,12 +33,13 @@ Co-product allocation shares are now visible from the typed client. ### Changed -- `ActivityDetail.is_allocated` (and `agribalyse.is_allocated`) now read the - structured shares on `all_products` instead of scraping the description text: - more reliable, and it matches the factor the engine actually applied (the - description's allocation comment is rounded and can drift — e.g. 52.62% in text - vs 51.4% applied). `agribalyse.decompose` enumerates co-products the same way, - keeping the text parse as a fallback for older databases. +- `ActivityDetail.is_allocated` now reads the structured shares on + `all_products` instead of scraping the description text: more reliable, and it + matches the factor the engine actually applied (the description's allocation + comment is rounded and can drift — e.g. 52.62% in text vs 51.4% applied). +- `agribalyse.is_allocated` and `agribalyse.decompose` enumerate co-products + from those structured shares the same way, keeping the description-text parse + as a fallback for older Agribalyse databases without structured shares. ## [0.6.0] - 2026-06-21 diff --git a/pyvolca/src/volca/agribalyse.py b/pyvolca/src/volca/agribalyse.py index 3870419e..aa051bb6 100644 --- a/pyvolca/src/volca/agribalyse.py +++ b/pyvolca/src/volca/agribalyse.py @@ -477,8 +477,10 @@ def agg_total(**kw: object) -> float: def is_allocated(activity: ActivityDetail) -> bool: """True iff the activity splits its burden across several co-products. - Thin alias for :attr:`ActivityDetail.is_allocated`, which reads the - structured allocation shares on ``all_products`` rather than the - description text. + Prefers the structured allocation shares on ``all_products`` + (:attr:`ActivityDetail.is_allocated`), falling back to an allocation block + in the description text for older Agribalyse databases that carry no + structured shares — the same precedence :func:`decompose` uses to + enumerate co-products. """ - return activity.is_allocated + return activity.is_allocated or parse_allocation(activity.description) is not None diff --git a/pyvolca/tests/test_typed_returns.py b/pyvolca/tests/test_typed_returns.py index 9efb4f41..db94685b 100644 --- a/pyvolca/tests/test_typed_returns.py +++ b/pyvolca/tests/test_typed_returns.py @@ -505,3 +505,30 @@ def test_is_allocated_false_for_mono_product(self): }}) assert detail.allocation_percent is None assert detail.is_allocated is False + + def test_agribalyse_is_allocated_falls_back_to_description_text(self): + # Older Agribalyse databases carry no structured allocation_percent on + # all_products, only an allocation block in the description. The generic + # property reads structured shares only (→ False), but the Agribalyse + # helper must still recognise the split via the text fallback that + # `decompose` relies on. + from volca.agribalyse import is_allocated as agribalyse_is_allocated + + detail = ActivityDetail.from_json({"activity": { + "processId": "butter_butter", "activityName": "Butter production", + "location": "FR", "unit": "kg", + "description": [ + "Allocation method: dry matter. " + "butter 33%, skimmed milk 63%, buttermilk 4%" + ], + "allProducts": [ + {"processId": "butter_butter", "activityName": "Butter production", + "location": "FR", "productName": "butter", + "productAmount": 1.0, "productUnit": "kg"}, + {"processId": "butter_skim", "activityName": "Butter production", + "location": "FR", "productName": "skimmed milk", + "productAmount": 1.9, "productUnit": "kg"}, + ], + }}) + assert detail.is_allocated is False + assert agribalyse_is_allocated(detail) is True