Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions pyvolca/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,29 @@ 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` 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

Breaking: an activity's name fields are renamed for clarity. Needs the companion engine release (the wire keys changed too).
Expand Down
12 changes: 11 additions & 1 deletion pyvolca/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

<!-- END: compatibility -->

Expand Down Expand Up @@ -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` | — |
Expand All @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion pyvolca/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
42 changes: 32 additions & 10 deletions pyvolca/src/volca/agribalyse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -460,5 +475,12 @@ 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.

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 or parse_allocation(activity.description) is not None
36 changes: 31 additions & 5 deletions pyvolca/src/volca/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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


# ---------------------------------------------------------------------------
Expand Down
110 changes: 110 additions & 0 deletions pyvolca/tests/test_typed_returns.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from volca import (
Activity,
ActivityDetail,
AggregateOp,
AggregateScope,
BioDirection,
Expand Down Expand Up @@ -422,3 +423,112 @@ 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

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
Loading