Runnable example · Supply-chain slicing
Split a supply chain by classification path
How much of a cheese's supply chain is animal feed? These scripts slice a supply chain along the classification paths the database already carries (ISIC, CPC, Category…) - no machine learning, no guessing: the categories come from the data.
What the scripts do
- Dump the distinct classification keys and values a database carries, and how they correlate with name patterns ("at farm", "at plant"…)
- Pick a product interactively or with
--name, fetch its full supply chain, and group contributions by classification path - Filter to the feed groups with
--category "Animal feed"and export the result to CSV
Prerequisites
A running VoLCA engine with agribalyse-3.2 loaded, reachable at http://localhost:8080 by default. Both scripts take --url, --db, and --password to point elsewhere, and run with uv run from the examples project.
Step 1 - discover what the database can classify
Run the reconnaissance tool first: it shows which classification keys exist and which values they take, so you know what --category can filter on.
uv run feed_ratio/explore_classifications.py """Explore classification values in a database.
Dumps all distinct classification keys/values to understand how to distinguish
raw ingredients (at farm) from processed ingredients (at plant).
Usage:
uv run feed_ratio/explore_classifications.py
uv run feed_ratio/explore_classifications.py --db ecoinvent-3.9.1
Docs: https://www.volca.run/docs/python/
"""
import argparse
from collections import Counter
from volca import Client
def main():
parser = argparse.ArgumentParser(description="Explore classification values")
parser.add_argument("--url", default="http://localhost:8080", help="VoLCA server URL")
parser.add_argument("--db", default="agribalyse-3.2", help="Database name")
parser.add_argument("--password", default="", help="Server password")
args = parser.parse_args()
c = Client(base_url=args.url, db=args.db, password=args.password)
# ── Step 1: Get all activities ────────────────────────────────────
print("Fetching all activities...")
all_activities = list(c.search_activities(limit=10000))
print(f" Total activities: {len(all_activities)}")
# ── Step 2: Sample activities and collect classifications ─────────
sample_size = min(200, len(all_activities))
print(f"\nFetching classifications for {sample_size} activities...")
key_counts: Counter = Counter() # classification key → count
value_counts: dict[str, Counter] = {} # key → (value → count)
name_vs_class: list[tuple[str, dict]] = [] # (name, classifications)
for i, act in enumerate(all_activities[:sample_size]):
detail = c.get_activity(act.process_id)
name_vs_class.append((act.activity_name, detail.classifications))
for k, v in detail.classifications.items():
key_counts[k] += 1
value_counts.setdefault(k, Counter())[v] += 1
if (i + 1) % 50 == 0:
print(f" ... {i + 1}/{sample_size}")
# ── Step 3: Print classification key distribution ─────────────────
print(f"\n{'='*60}")
print(f"CLASSIFICATION KEYS (across {sample_size} activities)")
print(f"{'='*60}")
for k, count in key_counts.most_common():
print(f" {k}: {count} activities")
# ── Step 4: Print value distribution per key ──────────────────────
for k in sorted(value_counts):
vc = value_counts[k]
print(f"\n{'─'*60}")
print(f"KEY: {k!r} ({len(vc)} distinct values)")
print(f"{'─'*60}")
for val, cnt in vc.most_common(30):
print(f" [{cnt:3d}] {val}")
if len(vc) > 30:
print(f" ... and {len(vc) - 30} more values")
# ── Step 5: Cross-reference name patterns with classifications ────
print(f"\n{'='*60}")
print("NAME PATTERN vs CLASSIFICATION CORRELATION")
print(f"{'='*60}")
patterns = ["at farm", "at plant", "at feed plant", "at slaughterhouse",
"at packaging", "at distribution", "at store"]
for pattern in patterns:
matches = [(name, cls) for name, cls in name_vs_class
if pattern in name.lower()]
if not matches:
continue
print(f"\n Pattern: '{pattern}' ({len(matches)} matches in sample)")
cls_vals: Counter = Counter()
for _, cls in matches:
for k, v in cls.items():
cls_vals[f"{k}={v}"] += 1
for kv, cnt in cls_vals.most_common(10):
print(f" [{cnt:3d}] {kv}")
# ── Step 6: Supply chain classifications for a known product ──────
print(f"\n{'='*60}")
print("SUPPLY CHAIN CLASSIFICATIONS (Wheat flour, type 55)")
print(f"{'='*60}")
flour_results = c.search_activities(name="Wheat flour, type 55", limit=5)
if flour_results:
flour = flour_results[0]
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)}")
sc_class_vals: dict[str, Counter] = {}
for entry in chain.entries:
for k, v in entry.classifications.items():
sc_class_vals.setdefault(k, Counter())[v] += 1
for k in sorted(sc_class_vals):
vc = sc_class_vals[k]
print(f"\n KEY: {k!r} ({len(vc)} distinct values)")
for val, cnt in vc.most_common(20):
# Mark farm/plant entries
tag = ""
if "farm" in val.lower():
tag = " ← FARM"
elif "plant" in val.lower():
tag = " ← PLANT"
print(f" [{cnt:3d}] {val}{tag}")
# Show a few entries with their classifications
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.activity_name}")
if cls_str:
print(f" Classifications: {cls_str}")
else:
print(" Wheat flour not found, skipping supply chain analysis")
if __name__ == "__main__":
main() Step 2 - split the supply chain
Then group the product's supply chain by classification path and keep only the feed share.
uv run feed_ratio/feed_ratio.py --name "Emmental cheese" --category "Animal feed" """Feed ratio analysis for animal-derived food products.
Uses classification paths from the database to identify animal processes
and their feed inputs. No ML model needed - categories come from the data.
Usage:
uv run feed_ratio/feed_ratio.py --url http://localhost:8080 --db agribalyse-3.2
Docs: https://www.volca.run/docs/python/
"""
import argparse
import csv
from volca import Client, SupplyChainEntry
def group_supply_chain_by_classification(
client: Client, process_id: str, category: str | None = None
) -> dict[str, list[SupplyChainEntry]]:
"""Group a supply chain's entries by classification path.
The path is the entry's "Category" classification when present (the
hierarchy SimaPro-derived databases like Agribalyse carry), otherwise
every classification joined as "system: value". ``category`` keeps only
groups whose path contains it (case-insensitive).
"""
chain = client.get_supply_chain(process_id, limit=10000)
if chain.has_more:
print(f" WARNING: truncated to {len(chain.entries)} of {chain.filtered_activities} activities")
groups: dict[str, list[SupplyChainEntry]] = {}
for entry in chain.entries:
path = entry.classifications.get("Category") or " / ".join(
f"{k}: {v}" for k, v in sorted(entry.classifications.items())
) or "(unclassified)"
groups.setdefault(path, []).append(entry)
if category:
lower = category.lower()
groups = {p: es for p, es in groups.items() if lower in p.lower()}
return groups
def select_from_list(items: list, prompt: str, fmt=str, limit: int = 10) -> int:
"""Interactive selection. Returns chosen index."""
for i, item in enumerate(items[:limit], 1):
print(f" {i}. {fmt(item)}")
while True:
choice = input(f"\n{prompt} [1]: ").strip() or "1"
try:
idx = int(choice) - 1
if 0 <= idx < min(len(items), limit):
return idx
except ValueError:
pass
print(f" Please enter 1–{min(len(items), limit)}")
def print_supply_chain(
groups: dict[str, list[SupplyChainEntry]], ref_amount: float, ref_unit: str, title: str
) -> list[dict]:
"""Print supply chain grouped by classification path."""
print(f"\n{'=' * 70}")
print(f"Supply Chain: {title}")
print(f"Reference product: {ref_amount:.1f} {ref_unit}")
print(f"{'=' * 70}")
if not groups:
print(" (no entries)")
return []
csv_rows = []
# Sort groups by total quantity descending
sorted_groups = sorted(groups.items(), key=lambda g: sum(e.quantity for e in g[1]), reverse=True)
for path, entries in sorted_groups:
units = {e.unit for e in entries}
if len(units) == 1:
total_qty = sum(e.quantity for e in entries)
per_unit = total_qty / ref_amount if ref_amount > 0 else total_qty
group_total = f"{per_unit:.3f} {units.pop()}/{ref_unit}"
else:
group_total = "mixed units, no total"
print(f"\n {path} ({len(entries)} activities, {group_total})")
for e in sorted(entries, key=lambda x: -x.quantity):
e_per_unit = e.quantity / ref_amount if ref_amount > 0 else e.quantity
print(f" {e_per_unit:.4f} {e.unit}/{ref_unit} {e.activity_name} ({e.location})")
csv_rows.append({"category": path, "name": e.activity_name, "location": e.location,
"quantity": e.quantity, "unit": e.unit,
"per_unit_product": round(e_per_unit, 6)})
return csv_rows
def export_csv(rows: list[dict], filename: str) -> None:
if not rows:
return
with open(filename, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
print(f"\nWritten {len(rows)} rows to {filename}")
def main():
parser = argparse.ArgumentParser(description="Supply chain analysis")
parser.add_argument("--url", default="http://localhost:8080", help="VoLCA server URL")
parser.add_argument("--db", default="agribalyse-3.2", help="Database name")
parser.add_argument("--password", default="", help="Server password")
parser.add_argument("--category", default=None,
help="Keep only classification paths containing this text (e.g. 'Animal feed')")
parser.add_argument("--name", default=None, help="Product search query (non-interactive)")
args = parser.parse_args()
c = Client(base_url=args.url, db=args.db, password=args.password)
# Step 1: Find a product
query = args.name or input("Search for a product: ").strip()
products = list(c.search_activities(name=query, limit=20))
if not products:
print(f"No activities found for '{query}'")
return
print(f"\nFound {len(products)} activities:")
if args.name:
idx = 0
else:
idx = select_from_list(products, "Select product", lambda p: f"{p.activity_name} ({p.location})")
product = products[idx]
print(f"\nAnalyzing: {product.activity_name}")
# Step 2: Get supply chain grouped by classification
print("\nFetching supply chain...")
groups = group_supply_chain_by_classification(c, product.process_id, category=args.category)
if not groups:
print("No entries found (try without --category)")
return
# Step 3: Show classification tree overview
print(f"\nClassification groups ({len(groups)} categories):")
for path in sorted(groups):
print(f" [{len(groups[path]):3d}] {path}")
# Step 4: Let user filter categories
if not args.category and not args.name:
filter_text = input("\nFilter categories (or Enter for all): ").strip()
if filter_text:
lower = filter_text.lower()
groups = {k: v for k, v in groups.items() if lower in k.lower()}
# Step 5: Display supply chain, normalized by the reference amount
detail = c.get_activity(product.process_id)
ref_amount = detail.product_amount or 1.0
ref_unit = detail.product_unit or detail.unit
csv_rows = print_supply_chain(groups, ref_amount, ref_unit, product.activity_name)
safe_name = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in product.activity_name)[:50]
export_csv(csv_rows, f"supply_chain_{safe_name}.csv")
if __name__ == "__main__":
main() Need another slice of the supply chain?
The same grouping works for any classification the database carries - not just animal feed.