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
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ See `README.md` for the full feature spec.
- **LCI** — life-cycle inventory: the raw physical flows (emissions, resources) for a product.
- **LCIA** — impact assessment: LCI flows × characterization factors → impact scores.
- **CF** — characterization factor: one flow's contribution to one impact category.
- **Activity / Exchange / Flow** — a process / one input-output line / the substance or product exchanged.
- **Activity** — a real-world transformation (`activityUUID`); may be **multi-output**. Owns `activity_name` and `all_products`. A *grouping* — never scored directly.
- **Product** — a reference output (a technosphere flow). Owns `product_name`.
- **Process** — a **single-output** unit = one `(activity, product)` pair, addressed by `process_id = activityUUID_productUUID`. Carries `activity_name` + `product_name`; **it has no name of its own** — a display label is generated on demand (`activity_name` + `product_name`), never stored or emitted as `process_name`. **This is the unit you search, get, and score.**
- **Exchange / Flow** — one input-output line / the substance or product exchanged.

**Field names: `process_id`, `activity_name`, `product_name`. There is no `process_name`** — it would be a derived value, so the consumer generates the label. The word "activity" is overloaded in the field: ecoinvent/Brightway call a single-output dataset an "activity" (with a reference product); SimaPro/ILCD/ISO call it a "(unit) process". VoLCA keeps both crisp — **activity = the grouping, process = the (activity, product) unit.** The API *verbs* are activity-named by ecoinvent convention but all operate on **processes**: **`search_activities` / `get_activity` / `score_activities` / `get_contributing_activities` return one process per `(activity, product)`.** `search_activities` filters by `activity_name` and/or `product_name` and always returns processes — to find the lowest-impact way to make a product, `search_activities(product=X)` → processes → `score_activities` → compare. (SimaPro conflates the two: its process name is often empty and the activity name leaks into the product string; VoLCA keeps the two fields separate and surfaces the gap honestly rather than fabricating a name.)

## Commands

Expand Down
10 changes: 10 additions & 0 deletions pyvolca/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ 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.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).

### Breaking changes

- `Activity.name` → `activity_name` and `Activity.product` → `product_name` — and the same on `ConsumerResult`, `SupplyChainEntry`, `PathStep`, and `ActivityDetail` (whose `reference_product` / `reference_product_amount` / `reference_product_unit` become `product_name` / `product_amount` / `product_unit`). A technosphere exchange's `target_activity` is now `target_activity_name`.
- Why: an activity can yield several products, so the *name* belongs to the activity (shared across its products) while the *product* is what tells them apart — `name` / `product` invited mixing the two. A "process" is an `(activity, product)` pair, addressed by `process_id`; it has no name of its own (compose a label from `activity_name` + `product_name`).
- Migration: `.name` → `.activity_name`, `.product` → `.product_name`, `.reference_product` → `.product_name`, `.target_activity` → `.target_activity_name`.

## [0.5.1] - 2026-05-31

Two bug fixes for engine 0.7.0. No API change — just upgrade.
Expand Down
53 changes: 27 additions & 26 deletions pyvolca/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,10 @@ Other listings: `c.list_classifications()` returns the classification systems an
plants = c.search_activities(name="wheat flour, at plant", page_size=5)
print(f"{len(plants)} matches; showing page 1 ({plants.page_size} items)")
for a in plants:
print(f"{a.process_id} {a.name} ({a.location})")
print(f"{a.process_id} {a.activity_name} → {a.product_name} ({a.location})")
```

`search_activities` returns a `SearchResults[Activity]` — a paginated wire envelope. Iterate it to walk every match across all pages (subsequent pages fetched on demand, then cached so re-iteration is free); `len(results)` is the server-reported total. Use `results.page(n, page_size=M)` for explicit page access, or pass `page=N` + `page_size=M` to jump straight to a page (both are required together — `page=` alone is rejected since the offset can't be derived without committing to a page size). Each `Activity` carries `process_id`, `name`, `location`, `product`, `product_amount`, `product_unit`. Narrow the query with `geo="FR"`, `classification=`/`classification_value=` (ISIC/CPC), or set `exact=True` for an exact-name match. To search by flow name (technosphere products and biosphere flows) instead of activity name, use `c.search_flows(query=...)`.
`search_activities` returns a `SearchResults[Activity]` — a paginated wire envelope. Iterate it to walk every match across all pages (subsequent pages fetched on demand, then cached so re-iteration is free); `len(results)` is the server-reported total. Use `results.page(n, page_size=M)` for explicit page access, or pass `page=N` + `page_size=M` to jump straight to a page (both are required together — `page=` alone is rejected since the offset can't be derived without committing to a page size). Each `Activity` is a process — an `(activity, product)` pair — carrying `process_id`, `activity_name`, `location`, `product_name`, `product_amount`, `product_unit`. A process has no name of its own; compose a label from `activity_name` + `product_name`. Narrow the query with `geo="FR"`, `classification=`/`classification_value=` (ISIC/CPC), or set `exact=True` for an exact-name match. To search by flow name (technosphere products and biosphere flows) instead of activity name, use `c.search_flows(query=...)`.

## Inspect an activity

Expand All @@ -117,7 +117,7 @@ for a in plants:
```python
detail = c.get_activity(plants[0].process_id)
for ex in detail.technosphere_inputs:
print(f"{ex.amount:.4g} {ex.unit} of {ex.flow_name} ← {ex.target_activity}")
print(f"{ex.amount:.4g} {ex.unit} of {ex.flow_name} ← {ex.target_activity_name}")
```

`get_activity` returns a typed `ActivityDetail`. Use `.inputs` / `.outputs` / `.technosphere_inputs` to filter the exchanges; each entry is an `Exchange` — either a `TechnosphereExchange` (an input or output of an intermediate product) or a `BiosphereExchange` (resource extracted or pollutant emitted).
Expand All @@ -130,7 +130,7 @@ for ex in detail.technosphere_inputs:
chain = c.get_supply_chain(plants[0].process_id, name="at farm", limit=20)
print(f"{chain.filtered_activities} of {chain.total_activities} upstream activities match 'at farm'")
for entry in chain.entries[:5]:
print(f" {entry.quantity:.4g} {entry.unit} of {entry.name} ({entry.location})")
print(f" {entry.quantity:.4g} {entry.unit} of {entry.activity_name} ({entry.location})")
```

For *"how exactly does this root reach a specific upstream supplier?"*, use `get_path_to(process_id, target=...)` — returns a `PathResult` of ordered `PathStep`s root → target with cumulative quantities and step ratios.
Expand All @@ -142,7 +142,7 @@ For *"how exactly does this root reach a specific upstream supplier?"*, use `get
```python
result = c.get_consumers(plants[0].process_id, max_depth=2, page_size=10)
for cons in result.consumers:
print(f" depth={cons.depth} {cons.name} ({cons.location})")
print(f" depth={cons.depth} {cons.activity_name} ({cons.location})")
```

Returns a `ConsumersResponse` whose `consumers` field is a `SearchResults[ConsumerResult]` — same paginated iterator semantics as `search_activities`. When `include_edges=True`, `result.edges` carries the technosphere edges so callers can reconstruct supplier→consumer paths without a second round trip. Pass `classification_filters=[...]` to restrict to a category.
Expand Down Expand Up @@ -799,19 +799,20 @@ One activity in a database — the row returned by /activities search.

``process_id`` is the engine's canonical address (``activityUUID_productUUID``)
and is what you pass to every detail endpoint (:meth:`Client.get_activity`,
:meth:`Client.get_supply_chain`, :meth:`Client.get_impacts`, …). ``name`` is
the activity name (e.g. ``"wheat flour, at plant"``); ``product`` is the
reference output product (e.g. ``"wheat flour"``); ``product_amount`` and
``product_unit`` describe the functional unit (typically ``1.0`` of
``"kg"`` / ``"MJ"`` / etc.). ``location`` is the geography code
(``"FR"``, ``"GLO"``, ``"RoW"``…).
:meth:`Client.get_supply_chain`, :meth:`Client.get_impacts`, …).
``activity_name`` is the activity name (e.g. ``"wheat flour, at plant"``);
``product_name`` is the reference output product (e.g. ``"wheat flour"``);
``product_amount`` and ``product_unit`` describe the functional unit
(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``.

| Field | Type | Default |
|-------|------|---------|
| `process_id` | `str` | — |
| `name` | `str` | — |
| `activity_name` | `str` | — |
| `location` | `str` | — |
| `product` | `str` | — |
| `product_name` | `str` | — |
| `product_amount` | `float` | — |
| `product_unit` | `str` | — |

Expand Down Expand Up @@ -841,14 +842,14 @@ instead of walking the raw exchanges list.
| Field | Type | Default |
|-------|------|---------|
| `process_id` | `str` | — |
| `name` | `str` | — |
| `activity_name` | `str` | — |
| `location` | `str` | — |
| `unit` | `str` | — |
| `description` | `list[str]` | — |
| `classifications` | `dict[str, str]` | — |
| `reference_product` | `str \| None` | — |
| `reference_product_amount` | `float \| None` | — |
| `reference_product_unit` | `str \| None` | — |
| `product_name` | `str \| None` | — |
| `product_amount` | `float \| None` | — |
| `product_unit` | `str \| None` | — |
| `all_products` | `list[Activity]` | — |
| `exchanges` | `list[Union[TechnosphereExchange, BiosphereExchange, WasteExchange]]` | — |

Expand Down Expand Up @@ -1003,9 +1004,9 @@ Activity that consumes a given supplier, with BFS depth.
| Field | Type | Default |
|-------|------|---------|
| `process_id` | `str` | — |
| `name` | `str` | — |
| `activity_name` | `str` | — |
| `location` | `str` | — |
| `product` | `str` | — |
| `product_name` | `str` | — |
| `product_amount` | `float` | — |
| `product_unit` | `str` | — |
| `depth` | `int` | — |
Expand Down Expand Up @@ -1288,14 +1289,14 @@ Shortest upstream path from a root process to a matching activity.

One step in the supply chain path returned by get_path_to.

Note: the /path endpoint emits snake_case JSON directly (built via
aeson's `object [...]` rather than generic ToJSON), so it bypasses
the engine's stripLowerPrefix transform.
Note: the /path endpoint is hand-built (aeson `object [...]`) but now
emits camelCase keys (``processId``, ``activityName``,
``cumulativeQuantity``, …) like the rest of the API.

| Field | Type | Default |
|-------|------|---------|
| `process_id` | `str` | — |
| `name` | `str` | — |
| `activity_name` | `str` | — |
| `location` | `str` | — |
| `unit` | `str` | — |
| `cumulative_quantity` | `float` | — |
Expand Down Expand Up @@ -1453,7 +1454,7 @@ second :meth:`Client.get_activity` round trip.
| Field | Type | Default |
|-------|------|---------|
| `process_id` | `str` | — |
| `name` | `str` | — |
| `activity_name` | `str` | — |
| `location` | `str` | — |
| `quantity` | `float` | — |
| `unit` | `str` | — |
Expand All @@ -1471,7 +1472,7 @@ producing activity's classifications describe the product taxonomy.
| `amount` | `float` | — |
| `unit` | `str` | — |
| `role` | `TechRole` | — |
| `target_activity` | `str \| None` | — |
| `target_activity_name` | `str \| None` | — |
| `target_location` | `str \| None` | — |
| `target_process_id` | `str \| None` | — |
| `comment` | `str \| None` | None |
Expand All @@ -1493,7 +1494,7 @@ product input. Orphan waste (no linked treatment) contributes zero impact
| `amount` | `float` | — |
| `unit` | `str` | — |
| `is_input` | `bool` | — |
| `target_activity` | `str \| None` | — |
| `target_activity_name` | `str \| None` | — |
| `target_location` | `str \| None` | — |
| `target_process_id` | `str \| None` | — |
| `comment` | `str \| None` | None |
Expand Down
6 changes: 3 additions & 3 deletions pyvolca/examples/explore_classifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def main():
for i, act in enumerate(all_activities[:sample_size]):
detail = c.get_activity(act.process_id)
classifications = detail.get("activity", {}).get("classifications", {})
name_vs_class.append((act.name, classifications))
name_vs_class.append((act.activity_name, classifications))

for k, v in classifications.items():
key_counts[k] += 1
Expand Down Expand Up @@ -97,7 +97,7 @@ def main():
flour_results = c.search_activities(name="Wheat flour, type 55", limit=5)
if flour_results:
flour = flour_results[0]
print(f" Root: {flour.name} ({flour.location})")
print(f" Root: {flour.activity_name} ({flour.location})")
chain = c.get_supply_chain(flour.process_id, limit=200)
print(f" Supply chain: {chain.total_activities} activities, showing {len(chain.entries)}")

Expand All @@ -122,7 +122,7 @@ def main():
print(f"\n Sample entries with classifications:")
for entry in chain.entries[:15]:
cls_str = ", ".join(f"{k}={v}" for k, v in entry.classifications.items())
print(f" {entry.quantity:.4f} {entry.unit} {entry.name}")
print(f" {entry.quantity:.4f} {entry.unit} {entry.activity_name}")
if cls_str:
print(f" Classifications: {cls_str}")
else:
Expand Down
12 changes: 6 additions & 6 deletions pyvolca/examples/feed_ratio.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ def print_supply_chain(
print(f"\n {label} ({len(entries)} activities, {per_kg:.3f} kg/kg)")
for e in sorted(entries, key=lambda x: -x.quantity):
e_per_kg = e.quantity / ref_amount if ref_amount > 0 else e.quantity
print(f" {e_per_kg:.4f} {e.unit}/kg {e.name} ({e.location})")
csv_rows.append({"category": path, "name": e.name, "location": e.location,
print(f" {e_per_kg:.4f} {e.unit}/kg {e.activity_name} ({e.location})")
csv_rows.append({"category": path, "name": e.activity_name, "location": e.location,
"quantity": e.quantity, "unit": e.unit,
"per_kg_product": round(e_per_kg, 6)})

Expand Down Expand Up @@ -100,9 +100,9 @@ def main():
if args.name:
idx = 0
else:
idx = select_from_list(products, "Select product", lambda p: f"{p.name} ({p.location})")
idx = select_from_list(products, "Select product", lambda p: f"{p.activity_name} ({p.location})")
product = products[idx]
print(f"\nAnalyzing: {product.name}")
print(f"\nAnalyzing: {product.activity_name}")

# Step 2: Get supply chain grouped by classification
print("\nFetching supply chain...")
Expand Down Expand Up @@ -133,8 +133,8 @@ def main():
all_products = activity.get("activity", {}).get("allProducts", [])
ref_amount = float(all_products[0]["productAmount"]) if all_products else 1.0

csv_rows = print_supply_chain(groups, ref_amount, product.name, filter_text if not args.prefix else (args.prefix or ""))
safe_name = "".join(c if c.isalnum() or c in "._-" else "_" for c in product.name)[:50]
csv_rows = print_supply_chain(groups, ref_amount, product.activity_name, filter_text if not args.prefix else (args.prefix or ""))
safe_name = "".join(c if c.isalnum() or c in "._-" else "_" for c in product.activity_name)[:50]
export_csv(csv_rows, f"supply_chain_{safe_name}.csv")


Expand Down
12 changes: 6 additions & 6 deletions pyvolca/examples/ingredient_substitution.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def main():
return

flour = wheat_flour[0]
print(f" Found: {flour.name} ({flour.location}) [{flour.process_id}]")
print(f" Found: {flour.activity_name} ({flour.location}) [{flour.process_id}]")

chain = c.get_supply_chain(flour.process_id, name="at farm", limit=100, include_edges=True)
print(f" Total upstream activities: {chain.total_activities}")
Expand All @@ -68,7 +68,7 @@ def main():
)
print(" Top 5 'at farm' ingredients:")
for entry in chain.entries[:5]:
print(f" {entry.quantity:.4f} {entry.unit} of {entry.name} ({entry.location})")
print(f" {entry.quantity:.4f} {entry.unit} of {entry.activity_name} ({entry.location})")

# ── Phase 3: Substitute conventional → organic wheat ───────────
print("\nPhase 3: Ingredient substitution (conventional → organic wheat)...")
Expand All @@ -81,7 +81,7 @@ def main():
print(f" ERROR: conventional wheat not found (query: {conv_query!r})")
return
from_activity = conv_fr[0]
print(f" From: {from_activity.name} ({from_activity.location}) [{from_activity.process_id}]")
print(f" From: {from_activity.activity_name} ({from_activity.location}) [{from_activity.process_id}]")

# Find organic wheat (the "to")
org_query = "Soft wheat grain, organic, 15% moisture, Central Region, at feed plant"
Expand All @@ -91,12 +91,12 @@ def main():
print(f" ERROR: organic wheat not found (query: {org_query!r})")
return
to_activity = org_fr[0]
print(f" To: {to_activity.name} ({to_activity.location}) [{to_activity.process_id}]")
print(f" To: {to_activity.activity_name} ({to_activity.location}) [{to_activity.process_id}]")

# Find which activity consumes the conventional wheat (from supply chain edges)
consumer_edges = [e for e in chain.edges if e.from_id == from_activity.process_id]
if not consumer_edges:
print(f" ERROR: no consumer found for {from_activity.name} in supply chain edges")
print(f" ERROR: no consumer found for {from_activity.activity_name} in supply chain edges")
return
consumer_id = consumer_edges[0].to_id
print(f" Consumer: {consumer_id}")
Expand All @@ -108,7 +108,7 @@ def main():
print(f"\n Substituted supply chain: {variant_chain.total_activities} activities")
print(f" Top 10 entries:")
for entry in variant_chain.entries[:10]:
print(f" {entry.quantity:.6f} {entry.unit} of {entry.name} ({entry.location})")
print(f" {entry.quantity:.6f} {entry.unit} of {entry.activity_name} ({entry.location})")


if __name__ == "__main__":
Expand Down
Loading
Loading