Runnable example · What-if analysis
Swap conventional for organic wheat across Agribalyse
What happens to a product's footprint when one ingredient changes supplier? These two scripts answer that without touching the database: they rewrite one supplier link in the computation and compare the supply chains before and after.
What the scripts do
- Find "at farm" ingredients upstream of processed "at plant" products
- Swap conventional wheat for its organic counterpart in the computation only - the database is never modified
- Recompute impacts through rank-1 matrix updates (Sherman-Morrison), so each what-if costs far less than a full recomputation
- Compare the supply chains before and after the swap, product by product
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.
Guided single-product substitution
The walkthrough version: list "at plant" activities, extract the wheat-flour "at farm" ingredients, swap conventional wheat for organic, and print both supply chains side by side.
uv run substitution/ingredient_substitution.py """Ingredient substitution workflow using VoLCA.
Three phases:
1. Find all "at plant" activities → export CSV
2. Find upstream "at farm" ingredients for wheat flour → export CSV with paths
3. Substitute conventional wheat with organic wheat → compare supply chains
Usage:
uv run substitution/ingredient_substitution.py
uv run substitution/ingredient_substitution.py --url http://localhost:8080 --db agribalyse-3.2
Docs: https://www.volca.run/docs/python/
"""
import argparse
import csv
from volca import Client
def to_csv(rows: list, filename: str, fields: list[str]) -> None:
"""Write a list of objects to CSV."""
with open(filename, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fields)
writer.writeheader()
for row in rows:
writer.writerow({k: getattr(row, k) for k in fields})
print(f" Written {len(rows)} rows to {filename}")
def main():
parser = argparse.ArgumentParser(description="Ingredient substitution workflow")
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)
# ── Phase 1: All "at plant" activities ──────────────────────────
print("Phase 1: Searching for 'at plant' activities...")
plants = list(c.search_activities(name="at plant", limit=10000))
print(f" Found {len(plants)} 'at plant' activities")
to_csv(
plants,
"at_plant.csv",
["process_id", "activity_name", "location", "product_name", "product_amount", "product_unit"],
)
# ── Phase 2: Wheat flour upstream "at farm" ────────────────────
print("\nPhase 2: Searching for wheat flour type 55...")
wheat_flour = list(c.search_activities(name="Wheat flour, type 55", limit=10))
if not wheat_flour:
print(" ERROR: 'Wheat flour, type 55' not found")
return
flour = wheat_flour[0]
print(f" Found: {flour.activity_name} ({flour.location}) [{flour.process_id}]")
# Filter client-side: the engine's supply-chain name filter (0.9.1)
# updates filtered_activities but returns unfiltered entries.
chain = c.get_supply_chain(flour.process_id, limit=10000, include_edges=True)
farm_entries = [e for e in chain.entries if "at farm" in e.activity_name.lower()]
print(f" Total upstream activities: {chain.total_activities}")
print(f" 'at farm' matches: {len(farm_entries)}")
if farm_entries:
to_csv(
farm_entries,
"wheat_flour_farm_ingredients.csv",
["process_id", "activity_name", "location", "quantity", "unit", "scaling_factor"],
)
print(" Top 5 'at farm' ingredients:")
for entry in farm_entries[:5]:
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)...")
# Find conventional wheat (the "from")
conv_query = "Soft wheat grain, conventional, breadmaking quality, 15% moisture, at farm gate"
conv_results = c.search_activities(name=conv_query, limit=10)
conv_fr = [a for a in conv_results if a.location == "FR"]
if not conv_fr:
print(f" ERROR: conventional wheat not found (query: {conv_query!r})")
return
from_activity = conv_fr[0]
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"
org_results = c.search_activities(name=org_query, limit=10)
org_fr = [a for a in org_results if a.location == "FR"]
if not org_fr:
print(f" ERROR: organic wheat not found (query: {org_query!r})")
return
to_activity = org_fr[0]
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.activity_name} in supply chain edges")
return
consumer_id = consumer_edges[0].to_id
print(f" Consumer: {consumer_id}")
# Compute supply chain with substitution (Sherman-Morrison rank-1 update)
subs = [{"from": from_activity.process_id, "to": to_activity.process_id, "consumer": consumer_id}]
variant_chain = c.get_supply_chain(flour.process_id, substitutions=subs, limit=100)
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.activity_name} ({entry.location})")
if __name__ == "__main__":
main() Bulk substitution across the whole database
The production version: resolve a substitution table, find every affected product through the reverse supply chain (the consumers endpoint), and apply the swap product by product.
uv run substitution/raw_ingredient_substitution.py """Bulk raw-ingredient substitution across processed food products.
Finds processed ingredients in Agribalyse, identifies their upstream raw
ingredients (farm-gate activities), and substitutes them with alternatives
using Sherman-Morrison rank-1 updates.
Workflow:
1. Resolve substitution table (source → target raw ingredients)
2. Use reverse supply chain (consumers endpoint) to find all products affected
3. For each affected product, find the consumer link and apply substitution
4. Compare original vs substituted supply chains
Usage:
uv run substitution/raw_ingredient_substitution.py
uv run substitution/raw_ingredient_substitution.py --url http://localhost:8080 --db agribalyse-3.2
Docs: https://www.volca.run/docs/python/
"""
import argparse
import csv
from volca import Client
# ── Raw ingredient substitution table ─────────────────────────────────
# Each entry: (search query for source, location, search query for target, location)
SUBSTITUTIONS = [
(
"Soft wheat grain, conventional, breadmaking quality, 15% moisture, at farm gate",
"FR",
"Soft wheat grain, organic, 15% moisture, Central Region, at feed plant",
"FR",
),
]
def find_activity(client: Client, query: str, location: str | None = None):
"""Find a single activity by name query, optionally filtered by location."""
results = client.search_activities(name=query, geo=location, limit=5)
if not results:
results = client.search_activities(name=query, limit=10)
if location:
results = [a for a in results if a.location == location]
return results[0] if results else None
def resolve_substitutions(client: Client):
"""Resolve the substitution table into (from_activity, to_activity) pairs."""
pairs = []
for src_query, src_loc, tgt_query, tgt_loc in SUBSTITUTIONS:
src = find_activity(client, src_query, src_loc)
tgt = find_activity(client, tgt_query, tgt_loc)
if not src:
print(f" WARNING: source not found: {src_query!r} ({src_loc})")
continue
if not tgt:
print(f" WARNING: target not found: {tgt_query!r} ({tgt_loc})")
continue
print(f" {src.activity_name[:60]} → {tgt.activity_name[:60]}")
pairs.append((src, tgt))
return pairs
def find_consumer_id(client: Client, product_id: str, supplier_id: str) -> str | None:
"""Find the direct consumer of a supplier in a product's supply chain edges."""
# limit=10000: the server's default limit could truncate the chain and
# hide a real consumer edge on large products.
chain = client.get_supply_chain(product_id, limit=10000, include_edges=True)
for edge in chain.edges:
if edge.from_id == supplier_id:
return edge.to_id
return None
def main():
parser = argparse.ArgumentParser(description="Bulk raw-ingredient substitution")
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()
client = Client(base_url=args.url, db=args.db, password=args.password)
# ── Step 1: Resolve substitution pairs ────────────────────────────
print("Step 1: Resolving substitution table...")
sub_pairs = resolve_substitutions(client)
if not sub_pairs:
print(" No valid substitutions found. Exiting.")
return
# ── Step 2: Find all products affected (reverse supply chain) ─────
print("\nStep 2: Finding affected products via reverse supply chain...")
applicable = [] # (consumer_activity, source, target)
for src, tgt in sub_pairs:
resp = client.get_consumers(src.process_id, limit=10000)
print(f" {src.activity_name[:50]}: {len(resp.consumers)} downstream consumers")
for consumer in resp.consumers:
applicable.append((consumer, src, tgt))
print(f" Total: {len(applicable)} product-substitution pairs")
if not applicable:
print(" No affected products found.")
return
# Export list of applicable products
with open("substitutable_products.csv", "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["process_id", "name", "location", "raw_ingredient"])
for product, src, _tgt in applicable:
writer.writerow([product.process_id, product.activity_name, product.location, src.activity_name])
print(f" Written {len(applicable)} products to substitutable_products.csv")
# ── Step 3: Apply substitutions on a sample ───────────────────────
print(f"\nStep 3: Applying substitutions on first {min(5, len(applicable))} products...")
for product, src, tgt in applicable[:5]:
print(f"\n Product: {product.activity_name[:70]}")
# Find the consumer link for this specific product
consumer_id = find_consumer_id(client, product.process_id, src.process_id)
if not consumer_id:
print(f" Could not find consumer link, skipping")
continue
substitutions = [{"from": src.process_id, "to": tgt.process_id, "consumer": consumer_id}]
try:
variant_chain = client.get_supply_chain(
product.process_id, substitutions=substitutions, limit=10
)
print(f" Substituted supply chain: {variant_chain.total_activities} activities")
except Exception as e:
print(f" Substitution failed: {e}")
if __name__ == "__main__":
main()