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_classifications.py Python 3 · pyvolca
"""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.py Python 3 · pyvolca
"""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.