Runnable example · Linked databases

Trace a supply chain across linked databases

When a foreground database links into a background one - the Ginko → Agribalyse case that motivated this script - a wrong unit conversion can hide anywhere in the chain. This diagnostic dumps exactly what the computation touches, so you can compare the numbers against another tool such as Brightway.

What the script does

  • Traces every activity reached with a non-zero scaling factor when computing impacts for a root process
  • For each reached activity, lists its cross-database exchanges with amount, unit, consumer reference amount, and the target activity's name, reference amount, and unit
  • Writes the trace to CSV - the numbers you compare side by side to spot a bad conversion

Prerequisites

A running VoLCA engine at http://localhost:8080 with the linked databases loaded. The process ID is activityUUID_productUUID; --max-depth defaults to 4. Run with uv run from the examples project.

Dump the reached chain

uv run cross_db_chain/dump_cross_db_chain.py <db> <processId> \
  --out cross_db_chain.csv
dump_cross_db_chain.py Python 3 · pyvolca
"""Dump the cross-DB exchange chain from Ginko through Agribalyse for one process.

Traces every Ginko activity reached (non-zero scaling) when computing impacts
for a root process, then for each reached activity lists its cross-DB
exchanges (activityLinkId == 00000000...) with: exchange amount, unit,
consumer refAmount, target Agribalyse activity name + refAmount + refUnit.

Used to diagnose unit-conversion / scaling mismatches between VoLCA and
brightway for Ginko-organic-apple-style datasets.

Usage:
    cd volca-deploy/volca/examples
    uv run cross_db_chain/dump_cross_db_chain.py <db> <processId>

https://www.volca.run/docs/python/
"""

import argparse
import sys
from collections import deque

from volca import Client, VoLCAError

BASE_URL = "http://localhost:8080"
NIL_UUID = "00000000-0000-0000-0000-000000000000"

client = Client(base_url=BASE_URL)


def get_activity(db, pid):
    # Raw JSON via the escape hatch: the typed ActivityDetail hides the wire
    # fields this diagnostic needs (activityLinkId, per-exchange role/tag).
    return client.call("get_activity", db_name=db, process_id=pid)["activity"]


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("db", help="root database name")
    parser.add_argument("processId", help="root process ID (actUUID_prodUUID)")
    parser.add_argument("--max-depth", type=int, default=4)
    parser.add_argument("--out", default="cross_db_chain.csv")
    args = parser.parse_args()

    # BFS over internal links, recording cross-DB exchanges at every level
    visited = set()
    queue = deque([(args.db, args.processId, 1.0, 0, "root")])  # (db, pid, weight, depth, path)
    rows = []
    dep_act_cache = {}  # cache for Agribalyse activities

    while queue:
        db, pid, weight, depth, path = queue.popleft()
        if (db, pid) in visited:
            continue
        visited.add((db, pid))

        try:
            act = get_activity(db, pid)
        except VoLCAError as e:
            print(f"  [skip] {db}/{pid[:16]}: {e}", file=sys.stderr)
            continue

        consumer_ref_amount = act["productAmount"]
        consumer_ref_unit = act["productUnit"]
        consumer_name = act["activityName"]

        if depth >= args.max_depth:
            continue

        for ex_entry in act["exchanges"]:
            ex = ex_entry["exchange"]
            # role Input = non-reference technosphere input (was isInput && !isReference)
            if ex.get("role") != "Input" or ex.get("tag") != "TechnosphereExchange":
                continue

            amount = ex["amount"]
            unit = ex_entry.get("unitName", "?")
            link_id = ex.get("activityLinkId", NIL_UUID)
            target_pid = ex_entry.get("targetProcessId") or ""

            # Normalize consumer's per-kg coefficient
            per_kg_consumer = amount / consumer_ref_amount if consumer_ref_amount else amount

            if link_id == NIL_UUID and "::" in target_pid:
                # Cross-DB link
                dep_db, dep_pid = target_pid.split("::", 1)

                # Look up supplier refAmount/refUnit
                key = (dep_db, dep_pid)
                if key not in dep_act_cache:
                    try:
                        dep_act = get_activity(dep_db, dep_pid)
                        dep_act_cache[key] = {
                            "name": dep_act["activityName"],
                            "refAmount": dep_act["productAmount"],
                            "refUnit": dep_act["productUnit"],
                        }
                    except VoLCAError:
                        dep_act_cache[key] = {"name": "<missing>", "refAmount": None, "refUnit": "?"}
                dep_info = dep_act_cache[key]

                rows.append({
                    "depth": depth,
                    "path": path,
                    "consumer_db": db,
                    "consumer_name": consumer_name,
                    "consumer_ref_amount": consumer_ref_amount,
                    "consumer_ref_unit": consumer_ref_unit,
                    "exchange_amount": amount,
                    "exchange_unit": unit,
                    "per_kg_consumer": per_kg_consumer,
                    "weight_kg_of_consumer_per_kg_root": weight,
                    "effective_demand_kg_supplier_per_kg_root": per_kg_consumer * weight,
                    "supplier_db": dep_db,
                    "supplier_name": dep_info["name"],
                    "supplier_ref_amount": dep_info["refAmount"],
                    "supplier_ref_unit": dep_info["refUnit"],
                })
            elif link_id != NIL_UUID and "::" not in target_pid and target_pid:
                # Internal link - recurse with scaled weight
                sub_weight = weight * per_kg_consumer
                queue.append((db, target_pid, sub_weight, depth + 1, f"{path} -> {consumer_name}"))

    # Write CSV
    import csv
    fields = ["depth", "path", "consumer_db", "consumer_name", "consumer_ref_amount",
              "consumer_ref_unit", "exchange_amount", "exchange_unit", "per_kg_consumer",
              "weight_kg_of_consumer_per_kg_root", "effective_demand_kg_supplier_per_kg_root",
              "supplier_db", "supplier_name", "supplier_ref_amount", "supplier_ref_unit"]
    with open(args.out, "w", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=fields)
        w.writeheader()
        for row in sorted(rows, key=lambda r: -r["effective_demand_kg_supplier_per_kg_root"]):
            w.writerow(row)
    print(f"Wrote {len(rows)} cross-DB link rows to {args.out}")

    # Top 20 by effective demand
    print()
    print(f"{'supplier':70s}  {'amount':>12s}  {'unit':>6s}  {'cons.ref':>10s} {'cons.unit':>10s}  {'eff.dem':>12s}")
    for r in sorted(rows, key=lambda r: -abs(r["effective_demand_kg_supplier_per_kg_root"]))[:20]:
        print(f"{r['supplier_name'][:70]:70s}  "
              f"{r['exchange_amount']:>12.4g}  "
              f"{r['exchange_unit']:>6s}  "
              f"{r['consumer_ref_amount']:>10.1f}"
              f"{r['consumer_ref_unit']:>6s}    "
              f"{r['effective_demand_kg_supplier_per_kg_root']:>12.4g}")


if __name__ == "__main__":
    main()

Linking your own databases?

VoLCA loads several databases side by side and resolves cross-database links between them - this script shows you exactly how.