Runnable example · Frontend data pipeline
Score every Ecobalyse activity with VoLCA
A complete pipeline feeding another product: score whole activity catalogues through a running VoLCA server and write the results back in the JSON shape each downstream project expects. One launcher loads databases and methods once; two export scripts read from the same server.
What the scripts do
- Start a VoLCA server and load every SimaPro CSV database plus the EF 3.1 (adapted) and Biomaps methods with their scoring, from one TOML config
- Compute EF 3.1 impacts and the ECS single score for every Ecobalyse activity, into Ecobalyse-format JSON
- Compute the 14 EF 3.1 trigram scores, the regionalized Biomaps biodiversity score, and the EcoFoodChoice single score per activity, into EcoFoodChoice-format JSON
Prerequisites
The Ecobalyse SimaPro CSV exports and an activity catalog from ecobalyse (licensed data, not redistributed here), referenced by volca-ecobalyse.toml. Run the launcher in one terminal, the exports in another, with uv run from the examples project.
Step 1 - launch and load
Starts the engine and loads all databases, methods, and scoring definitions. Both export scripts talk to the server it starts.
uv run ecobalyse/ecobalyse_import.py """VoLCA Ecobalyse import - load all SimaPro CSV databases + EF 3.1 scoring.
Usage:
cd volca-deploy/volca/examples
uv run ecobalyse/ecobalyse_import.py
https://www.volca.run/docs/python/
"""
import time
import tomllib
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from volca import Client, Server, VoLCAError
HERE = Path(__file__).resolve().parent
METHOD = "Environmental Footprint 3.1 (adapted)"
def load_config(path):
with open(path, "rb") as f:
return tomllib.load(f)
def main():
config_path = HERE / "volca-ecobalyse.toml"
config = load_config(config_path)
databases = config["databases"]
methods = config["methods"]
print("=" * 70)
print(" VoLCA Demo - SimaPro CSV databases + EF 3.1 scoring")
print("=" * 70)
# --- Start server ---
print(f"\n► Starting VoLCA server (port {config['server']['port']})...")
binary = config_path.parent / config["server"]["binary"]
srv = Server(config=str(config_path), binary=str(binary))
srv.start(wait_timeout=30)
client = Client(base_url=srv.base_url, password=srv.password)
print(f" Server ready at {srv.base_url}")
# --- Show configured databases ---
print(f"\n► {len(databases)} databases configured:")
for db in databases:
deps = db.get("depends", [])
dep_info = f" (depends on {', '.join(deps)})" if deps else ""
print(f" · {db['displayName']}{dep_info}")
# --- Show configured methods ---
print(f"\n► {len(methods)} method(s) configured:")
for m in methods:
scoring_sets = m.get("scoring", [])
scoring_info = f" - scoring: {', '.join(s['name'] for s in scoring_sets)}" if scoring_sets else ""
print(f" · {m['name']}{scoring_info}")
# --- Load databases ---
print("\n► Loading databases into memory...")
for db in databases:
name, display = db["name"], db["displayName"]
print(f" Loading {display}...", end=" ", flush=True)
t = time.perf_counter()
try:
client.load_database(name)
print(f"done ({time.perf_counter() - t:.1f}s)")
except VoLCAError as e:
print(f"FAILED ({e})")
# --- Search for a test activity ---
test_db = "agribalyse-3-2"
query = "mozzarella"
print(f"\n► Searching '{query}' in {test_db}...")
activity = client.use(test_db).search_activities(name=query, limit=1)[0]
print(f" Found: {activity.activity_name}")
print(f" Process ID: {activity.process_id}")
# --- Compute impacts ---
print(f"\n► Computing LCIA impacts ({METHOD})...")
result = client.use(test_db).get_impacts_batch(activity.process_id, collection=METHOD)
print(f" {len(result.results)} impact categories computed\n")
print(f" {'Category':<50s} {'Score':>12s} Unit")
print(f" {'-' * 76}")
for cat in result.results:
print(f" {cat.category:<50s} {cat.score:12.4e} {cat.unit}")
# --- Scoring results ---
if result.scoring_results:
print("\n► Scoring results (formula-based single scores):")
for set_name, scores in result.scoring_results.items():
unit = result.scoring_units.get(set_name, "Pt")
for score_name, value in scores.items():
label = set_name if len(scores) == 1 else f"{set_name}.{score_name}"
print(f" {label} = {value:.2f} {unit}")
# --- Pre-warm method tables for every loaded database (parallel) ---
# First /impacts hit on a (db, method-collection) pair pays the
# regionalized weight precomputation up-front. Doing it here, in
# parallel across DBs, moves that cost OUT of any later batch run -
# ecobalyse_export.py then measures only the steady-state per-pid
# characterization.
print(f"\n► Warming up {len(databases)} databases (one /impacts call each, parallel)...")
warm_start = time.perf_counter()
def _warm_one(db_cfg):
name, display = db_cfg["name"], db_cfg["displayName"]
t0 = time.perf_counter()
c = client.use(name)
# Grab any activity to hand to /impacts; the work is per-(db, method),
# not per-pid, so the choice of pid doesn't matter.
results = c.search_activities(limit=1)
if not results:
return display, "no activities", time.perf_counter() - t0
try:
c.score_activities([results[0].process_id], collection=METHOD, top_flows=0)
status = "ok"
except VoLCAError as e:
status = str(e)
return display, status, time.perf_counter() - t0
with ThreadPoolExecutor(max_workers=len(databases)) as pool:
futures = {pool.submit(_warm_one, db): db for db in databases}
for fut in as_completed(futures):
display, status, dt = fut.result()
print(f" [{dt:5.1f}s] {display}: {status}")
print(f" Warmup wall-clock: {time.perf_counter() - warm_start:.1f}s")
print()
if __name__ == "__main__":
main() Step 2 - export in Ecobalyse format
uv run ecobalyse/ecobalyse_export.py """VoLCA Ecobalyse export - compute impacts for all Ecobalyse activities.
Reads activities from ecobalyse-data4/activities.json, connects to a running
VoLCA server (started by ecobalyse_import.py), computes EF 3.1 impacts + ECS
score for each process, and writes results in Ecobalyse JSON format.
Usage:
cd volca-deploy/volca/examples
uv run ecobalyse/ecobalyse_import.py # start server + load databases
uv run ecobalyse/ecobalyse_export.py # compute impacts (in another terminal)
https://www.volca.run/docs/python/
"""
import argparse
import json
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from volca import Client, VoLCAError
HERE = Path(__file__).resolve().parent
OUTPUT_JSON = HERE / "ecobalyse_activities.json"
BASE_URL = "http://localhost:8080"
METHOD = "Environmental Footprint 3.1 (adapted)"
WORKERS = 8
# Source field in lci_catalog activity JSON → DB slug loaded in the running server.
# "Ecobalyse" and "Custom" sources are virtual aggregates without a backing
# SimaPro/EcoSpold export - they're skipped silently here.
SOURCE_TO_DB = {
"Agribalyse 3.2": "agribalyse-3-2",
"Ecoinvent 3.9.1": "ecoinvent-3-9-1-adapted",
"Ecoinvent 3.11": "ecoinvent-3-11-adapted",
"Ginko 2025": "ginko-2025-v2",
"PastoEco": "pastoeco",
"Woolmark": "woolmark",
"WFLDB": "wfldb",
}
# EF 3.1 impact category name → Ecobalyse trigram
CATEGORY_TO_TRIGRAM = {
"Acidification": "acd",
"Climate change": "cch",
"Eutrophication, freshwater": "fwe",
"Eutrophication, marine": "swe",
"Eutrophication, terrestrial": "tre",
"Ecotoxicity, freshwater - part 1": "etf1",
"Ecotoxicity, freshwater - part 2": "etf2",
"Human toxicity, cancer": "htc",
"Human toxicity, non-cancer": "htn",
"Ionising radiation": "ior",
"Land use": "ldu",
"Ozone depletion": "ozd",
"Particulate matter": "pma",
"Photochemical ozone formation": "pco",
"Resource use, fossils": "fru",
"Resource use, minerals and metals": "mru",
"Water use": "wtu",
}
def check_server(client):
"""Verify the VoLCA server is running and return loaded database names."""
try:
client.get_version()
except Exception:
print("ERROR: VoLCA server not running. Start it first with:")
print(" uv run ecobalyse_import.py")
sys.exit(1)
return {db.name for db in client.list_databases() if db.status == "loaded"}
def resolve_pid(client, db_name, activity):
"""Resolve one activity name to a processId. Returns (activity, pid_or_None, status)."""
try:
found = client.use(db_name).search_activities(
name=activity["activityName"], limit=1
).results
except VoLCAError:
return activity, None, "failed"
if not found:
return activity, None, "not_found"
return activity, found[0].process_id, "ok"
def extract_impacts(batch_result):
"""Map an LCIABatchResult to a {trigram: score} dict."""
impacts = {}
for cat in batch_result.results:
trigram = CATEGORY_TO_TRIGRAM.get(cat.category)
if trigram:
impacts[trigram] = cat.score
for set_name, scores in batch_result.scoring_results.items():
if set_name == "ECS":
impacts["ecs"] = scores.get("total", 0)
elif set_name == "ecobalyse":
for score_name, value in scores.items():
impacts[score_name] = value
return impacts
def batch_impacts(client, db_name, pid_to_activity):
"""Score all pids for this DB in one MUMPS multi-RHS solve.
Returns {pid: impacts_dict}. Missing pids → empty dict entries filled by caller.
Sends `top_flows=0` to skip the O(|inventory|×|methods|) contribution walk
we don't consume; raw scores + scoring sets are unaffected."""
if not pid_to_activity:
return {}
scored = client.use(db_name).score_activities(
list(pid_to_activity.keys()), collection=METHOD, top_flows=0
)
return {sa.process_id: extract_impacts(sa.impacts) for sa in scored.results}
def build_record(activity, impacts):
return {
"activityName": activity["activityName"],
"comment": activity.get("comment", ""),
"displayName": activity.get("displayName", ""),
"impacts": {k: format_number(v) for k, v in sorted(impacts.items())},
"location": activity.get("location", ""),
"massPerUnit": activity.get("massPerUnit"),
"scopes": activity.get("scopes", []),
"source": activity["source"],
"unit": activity.get("unit", ""),
"waste": activity.get("waste", 0),
}
def format_number(v):
"""Format like Ecobalyse: 5 significant digits, use int for zero.
Tolerates non-numeric server values (e.g. an error string surfaced by a
scoring formula that referenced a Left-tainted regionalized score). We
pass them through unchanged so the export still emits a record; the
downstream consumer can spot and triage non-numeric entries.
"""
if v is None:
return None
if not isinstance(v, (int, float)):
return v
if v == 0:
return 0
return float(f"{v:.5g}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--only-db",
metavar="NAME",
action="append",
default=[],
help="Restrict to one TOML db name (repeatable, e.g. --only-db ecoinvent-3.11)",
)
parser.add_argument(
"--warmup",
action="store_true",
help="Issue a GET /impacts for one activity per DB first to pay factorization outside the timed section",
)
parser.add_argument(
"--lci-catalog",
metavar="DIR",
type=Path,
default=Path("lci_catalog"),
help="Path to the ecobalyse-data lci_catalog directory (one JSON file per activity under <db>/)",
)
args = parser.parse_args()
if not args.lci_catalog.is_dir():
parser.error(f"--lci-catalog: no such directory: {args.lci_catalog}")
# Read input activities - one JSON file per activity under lci_catalog/<db>/
print(f"► Reading activities from {args.lci_catalog}/<db>/*.json...")
activities = [
json.load(open(p))
for p in sorted(args.lci_catalog.glob("*/*.json"))
]
computable = [
a
for a in activities
if a.get("activityName") and a.get("source") in SOURCE_TO_DB
]
if args.only_db:
allowed = set(args.only_db)
computable = [a for a in computable if SOURCE_TO_DB[a["source"]] in allowed]
print(
f" --only-db filter: keeping {len(computable)} activities across {len(allowed)} databases"
)
skipped_no_name = sum(1 for a in activities if not a.get("activityName"))
skipped_unknown_source = sum(
1
for a in activities
if a.get("activityName") and a.get("source") not in SOURCE_TO_DB
)
print(f" {len(activities)} total, {len(computable)} computable")
print(
f" Skipped: {skipped_no_name} without activityName, {skipped_unknown_source} unknown source"
)
# Check server is running
print(f"\n► Connecting to VoLCA server at {BASE_URL}...")
client = Client(base_url=BASE_URL)
loaded_dbs = check_server(client)
print(
f" Connected. {len(loaded_dbs)} databases loaded: {', '.join(sorted(loaded_dbs))}"
)
# Warn about missing databases
needed_dbs = {SOURCE_TO_DB[a["source"]] for a in computable}
missing = needed_dbs - loaded_dbs
if missing:
print(f" WARNING: databases not loaded: {', '.join(sorted(missing))}")
computable = [a for a in computable if SOURCE_TO_DB[a["source"]] in loaded_dbs]
print(f" Reduced to {len(computable)} computable activities")
# Build (db_name, activity) pairs
tasks = [(SOURCE_TO_DB[a["source"]], a) for a in computable]
results = []
not_found = []
failed = []
total = len(tasks)
total_start = time.perf_counter()
# Phase 1: resolve names → processIds in parallel (light I/O-bound work)
print(f"\n► Resolving {total} processIds ({WORKERS} parallel searches)...")
phase1_start = time.perf_counter()
by_db = {} # db_name -> {pid: activity}
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
futures = [pool.submit(resolve_pid, client, db, act) for db, act in tasks]
for done, fut in enumerate(as_completed(futures), start=1):
activity, pid, status = fut.result()
display = activity.get("displayName", activity.get("activityName", "?"))
db_name = SOURCE_TO_DB[activity["source"]]
if status == "ok":
by_db.setdefault(db_name, {})[pid] = activity
elif status == "not_found":
not_found.append((db_name, display, activity["activityName"]))
else:
failed.append((db_name, display))
if done % 200 == 0 or done == total:
print(f" [{done}/{total}]")
print(f" Resolved in {time.perf_counter() - phase1_start:.1f}s")
# Optional warmup: force MUMPS factorization + method flow-mapping cache per DB
# so Phase 2 measures only the steady-state per-activity cost.
if args.warmup:
print(
f"\n► Warming {len(by_db)} databases (GET /impacts on first pid of each)..."
)
warm_start = time.perf_counter()
for db_name, pid_map in by_db.items():
first_pid = next(iter(pid_map))
t = time.perf_counter()
client.use(db_name).get_impacts_batch(first_pid, collection=METHOD)
print(f" [{db_name}] warmup {time.perf_counter() - t:.2f}s")
print(f" Warmup total {time.perf_counter() - warm_start:.1f}s")
# Phase 2: one batch-impacts call per DB, run in parallel across DBs.
# Different DBs use different MUMPS handles so concurrent solves don't
# contend; wall-clock becomes max(per-DB) instead of sum.
print(f"\n► Batch impacts across {len(by_db)} databases (parallel)...")
phase2_start = time.perf_counter()
def run_batch(db_name, pid_map):
t0 = time.perf_counter()
impacts_by_pid = batch_impacts(client, db_name, pid_map)
return db_name, pid_map, impacts_by_pid, time.perf_counter() - t0
with ThreadPoolExecutor(max_workers=max(1, len(by_db))) as pool:
futures = [pool.submit(run_batch, db, pm) for db, pm in by_db.items()]
for fut in as_completed(futures):
db_name, pid_map, impacts_by_pid, elapsed = fut.result()
print(
f" [{db_name}] {len(impacts_by_pid)}/{len(pid_map)} in {elapsed:.1f}s"
f" ({1000 * elapsed / max(1, len(pid_map)):.0f}ms/process)"
)
for pid, activity in pid_map.items():
impacts = impacts_by_pid.get(pid)
if impacts is None:
failed.append((
db_name,
activity.get("displayName", activity["activityName"]),
))
else:
results.append(build_record(activity, impacts))
print(f" Phase 2 wall-clock: {time.perf_counter() - phase2_start:.1f}s")
done = len(results) + len(not_found) + len(failed)
total_time = time.perf_counter() - total_start
# Write output
print(f"\n► Writing {len(results)} activities to {OUTPUT_JSON.name}...")
with open(OUTPUT_JSON, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
f.write("\n")
# Summary
print(f"\n{'=' * 60}")
print(
f" Results: {len(results)} computed, {len(not_found)} not found, {len(failed)} failed"
)
if done > 0:
mean_ms = 1000 * total_time / done
print(f" Timing: {total_time:.1f}s total, {mean_ms:.0f}ms mean per process")
if not_found:
print(f"\n Not found ({len(not_found)}):")
for db, display, name in not_found[:10]:
print(f" [{db}] {display}")
if len(not_found) > 10:
print(f" ... and {len(not_found) - 10} more")
print()
if __name__ == "__main__":
main() Or - export in EcoFoodChoice format
uv run ecobalyse/ecofoodchoice_export.py """VoLCA EcoFoodChoice export - compute EFC + Biomaps impacts.
Reads activities from ecobalyse-data/lci_catalog/<db>/*.json, connects to a
running VoLCA server (started by ecobalyse_import.py), computes EF 3.1 impacts
+ ECS + EFC + regionalized Biomaps for each process, and writes a JSON file
with one record per activity carrying:
- the 14 PEF/EF 3.1 trigram scores (raw characterized values)
- the regionalized Biomaps biodiversity-loss potential ("bio")
- the EFC single-score total ("efc")
The Biomaps method is regionalized (CFs vary by activity location). VoLCA
resolves the per-activity location → CF cell via the location hierarchy
configured in the TOML's `geographies = "..."`.
Usage:
cd volca-deploy/volca/examples
uv run ecobalyse/ecobalyse_import.py # start server + load + warm-up
uv run ecobalyse/ecofoodchoice_export.py # compute impacts (in another terminal)
https://www.volca.run/docs/python/
"""
import argparse
import json
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from volca import Client, VoLCAError
HERE = Path(__file__).resolve().parent
OUTPUT_JSON = HERE / "ecofoodchoice_activities.json"
BASE_URL = "http://localhost:8080"
METHOD = "Environmental Footprint 3.1 (adapted)"
WORKERS = 8
# Source field in lci_catalog activity JSON → DB slug loaded in the running server.
# "Ecobalyse" and "Custom" sources are virtual aggregates without a backing
# SimaPro/EcoSpold export - they're skipped silently here.
SOURCE_TO_DB = {
"Agribalyse 3.2": "agribalyse-3-2",
"Ecoinvent 3.9.1": "ecoinvent-3-9-1-adapted",
"Ecoinvent 3.11": "ecoinvent-3-11-adapted",
"Ginko 2025": "ginko-2025-v2",
"PastoEco": "pastoeco",
"Woolmark": "woolmark",
"WFLDB": "wfldb",
}
# Impact category name → EFC short code (trigrams kept in sync with EF 3.1).
# Biomaps is a single regionalized category; EFC is the formula-based total.
CATEGORY_TO_TRIGRAM = {
"Acidification": "acd",
"Climate change": "cch",
"Eutrophication, freshwater": "fwe",
"Eutrophication, marine": "swe",
"Eutrophication, terrestrial": "tre",
"Ecotoxicity, freshwater - inorganics": "etfi",
"Ecotoxicity, freshwater - organics - p.1": "etfo1",
"Ecotoxicity, freshwater - organics - p.2": "etfo2",
"Ionising radiation": "ior",
"Land use": "ldu",
"Ozone depletion": "ozd",
"Particulate matter": "pma",
"Photochemical ozone formation": "pco",
"Resource use, fossils": "fru",
"Resource use, minerals and metals": "mru",
"Water use": "wtu",
"Biomaps - Biodiversity Loss Potential": "bio",
}
def check_server(client):
"""Verify the VoLCA server is running and return loaded database names."""
try:
client.get_version()
except Exception:
print("ERROR: VoLCA server not running. Start it first with:")
print(" uv run ecobalyse_import.py")
sys.exit(1)
return {db.name for db in client.list_databases() if db.status == "loaded"}
def resolve_pid(client, db_name, activity):
"""Resolve one activity name to a processId. Returns (activity, pid_or_None, status)."""
try:
found = client.use(db_name).search_activities(
name=activity["activityName"], limit=1
).results
except VoLCAError:
return activity, None, "failed"
if not found:
return activity, None, "not_found"
return activity, found[0].process_id, "ok"
def extract_impacts(batch_result):
"""Map an LCIABatchResult to an EFC-shaped {trigram: score} dict.
Carries the 14 PEF-mapped EF 3.1 categories + the regionalized Biomaps
score + the EFC formula total. The raw inorganic / organics-p.1 / -p.2
Ecotoxicity, freshwater categories ship as-is (etfi / etfo1 / etfo2) so
downstream consumers can re-aggregate the way they want; EFC's own
`organics = etfo1 + etfo2` happens server-side via the scoring formula
and is exposed in `scoring_results`.
"""
impacts = {}
for cat in batch_result.results:
trigram = CATEGORY_TO_TRIGRAM.get(cat.category)
if trigram:
impacts[trigram] = cat.score
for set_name, scores in batch_result.scoring_results.items():
if set_name == "EFC":
impacts["efc"] = scores.get("total", 0)
elif set_name == "ECS":
impacts["ecs"] = scores.get("total", 0)
elif set_name == "PEF":
impacts["pef"] = scores.get("total", 0)
return impacts
def batch_impacts(client, db_name, pid_to_activity):
"""Score all pids for this DB in one MUMPS multi-RHS solve.
Returns {pid: impacts_dict}. Missing pids → empty dict entries filled by caller.
Sends `top_flows=0` to skip the O(|inventory|×|methods|) contribution walk
we don't consume; raw scores + scoring sets are unaffected."""
if not pid_to_activity:
return {}
scored = client.use(db_name).score_activities(
list(pid_to_activity.keys()), collection=METHOD, top_flows=0
)
return {sa.process_id: extract_impacts(sa.impacts) for sa in scored.results}
def build_record(activity, impacts):
return {
"activityName": activity["activityName"],
"comment": activity.get("comment", ""),
"displayName": activity.get("displayName", ""),
"impacts": {k: format_number(v) for k, v in sorted(impacts.items())},
"location": activity.get("location", ""),
"massPerUnit": activity.get("massPerUnit"),
"scopes": activity.get("scopes", []),
"source": activity["source"],
"unit": activity.get("unit", ""),
"waste": activity.get("waste", 0),
}
def format_number(v):
"""Format like Ecobalyse: 5 significant digits, use int for zero.
Tolerates non-numeric server values (e.g. an error string surfaced by a
scoring formula that referenced a Left-tainted regionalized score). We
pass them through unchanged so the export still emits a record; the
downstream consumer can spot and triage non-numeric entries.
"""
if v is None:
return None
if not isinstance(v, (int, float)):
return v
if v == 0:
return 0
return float(f"{v:.5g}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--only-db",
metavar="NAME",
action="append",
default=[],
help="Restrict to one TOML db name (repeatable, e.g. --only-db ecoinvent-3.11)",
)
parser.add_argument(
"--warmup",
action="store_true",
help="Issue a GET /impacts for one activity per DB first to pay factorization outside the timed section",
)
parser.add_argument(
"--lci-catalog",
metavar="DIR",
type=Path,
default=Path("lci_catalog"),
help="Path to the ecobalyse-data lci_catalog directory (one JSON file per activity under <db>/)",
)
args = parser.parse_args()
if not args.lci_catalog.is_dir():
parser.error(f"--lci-catalog: no such directory: {args.lci_catalog}")
# Read input activities - one JSON file per activity under lci_catalog/<db>/
print(f"► Reading activities from {args.lci_catalog}/<db>/*.json...")
activities = [
json.load(open(p))
for p in sorted(args.lci_catalog.glob("*/*.json"))
]
computable = [
a
for a in activities
if a.get("activityName") and a.get("source") in SOURCE_TO_DB
]
if args.only_db:
allowed = set(args.only_db)
computable = [a for a in computable if SOURCE_TO_DB[a["source"]] in allowed]
print(
f" --only-db filter: keeping {len(computable)} activities across {len(allowed)} databases"
)
skipped_no_name = sum(1 for a in activities if not a.get("activityName"))
skipped_unknown_source = sum(
1
for a in activities
if a.get("activityName") and a.get("source") not in SOURCE_TO_DB
)
print(f" {len(activities)} total, {len(computable)} computable")
print(
f" Skipped: {skipped_no_name} without activityName, {skipped_unknown_source} unknown source"
)
# Check server is running
print(f"\n► Connecting to VoLCA server at {BASE_URL}...")
client = Client(base_url=BASE_URL)
loaded_dbs = check_server(client)
print(
f" Connected. {len(loaded_dbs)} databases loaded: {', '.join(sorted(loaded_dbs))}"
)
# Warn about missing databases
needed_dbs = {SOURCE_TO_DB[a["source"]] for a in computable}
missing = needed_dbs - loaded_dbs
if missing:
print(f" WARNING: databases not loaded: {', '.join(sorted(missing))}")
computable = [a for a in computable if SOURCE_TO_DB[a["source"]] in loaded_dbs]
print(f" Reduced to {len(computable)} computable activities")
# Build (db_name, activity) pairs
tasks = [(SOURCE_TO_DB[a["source"]], a) for a in computable]
results = []
not_found = []
failed = []
total = len(tasks)
total_start = time.perf_counter()
# Phase 1: resolve names → processIds in parallel (light I/O-bound work)
print(f"\n► Resolving {total} processIds ({WORKERS} parallel searches)...")
phase1_start = time.perf_counter()
by_db = {} # db_name -> {pid: activity}
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
futures = [pool.submit(resolve_pid, client, db, act) for db, act in tasks]
for done, fut in enumerate(as_completed(futures), start=1):
activity, pid, status = fut.result()
display = activity.get("displayName", activity.get("activityName", "?"))
db_name = SOURCE_TO_DB[activity["source"]]
if status == "ok":
by_db.setdefault(db_name, {})[pid] = activity
elif status == "not_found":
not_found.append((db_name, display, activity["activityName"]))
else:
failed.append((db_name, display))
if done % 200 == 0 or done == total:
print(f" [{done}/{total}]")
print(f" Resolved in {time.perf_counter() - phase1_start:.1f}s")
# Optional warmup: force MUMPS factorization + method flow-mapping cache per DB
# so Phase 2 measures only the steady-state per-activity cost.
if args.warmup:
print(
f"\n► Warming {len(by_db)} databases (GET /impacts on first pid of each)..."
)
warm_start = time.perf_counter()
for db_name, pid_map in by_db.items():
first_pid = next(iter(pid_map))
t = time.perf_counter()
client.use(db_name).get_impacts_batch(first_pid, collection=METHOD)
print(f" [{db_name}] warmup {time.perf_counter() - t:.2f}s")
print(f" Warmup total {time.perf_counter() - warm_start:.1f}s")
# Phase 2: one batch-impacts call per DB, run in parallel across DBs.
# Different DBs use different MUMPS handles so concurrent solves don't
# contend; wall-clock becomes max(per-DB) instead of sum.
print(f"\n► Batch impacts across {len(by_db)} databases (parallel)...")
phase2_start = time.perf_counter()
def run_batch(db_name, pid_map):
t0 = time.perf_counter()
impacts_by_pid = batch_impacts(client, db_name, pid_map)
return db_name, pid_map, impacts_by_pid, time.perf_counter() - t0
with ThreadPoolExecutor(max_workers=max(1, len(by_db))) as pool:
futures = [pool.submit(run_batch, db, pm) for db, pm in by_db.items()]
for fut in as_completed(futures):
db_name, pid_map, impacts_by_pid, elapsed = fut.result()
print(
f" [{db_name}] {len(impacts_by_pid)}/{len(pid_map)} in {elapsed:.1f}s"
f" ({1000 * elapsed / max(1, len(pid_map)):.0f}ms/process)"
)
for pid, activity in pid_map.items():
impacts = impacts_by_pid.get(pid)
if impacts is None:
failed.append((
db_name,
activity.get("displayName", activity["activityName"]),
))
else:
results.append(build_record(activity, impacts))
print(f" Phase 2 wall-clock: {time.perf_counter() - phase2_start:.1f}s")
done = len(results) + len(not_found) + len(failed)
total_time = time.perf_counter() - total_start
# Write output
print(f"\n► Writing {len(results)} activities to {OUTPUT_JSON.name}...")
with open(OUTPUT_JSON, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
f.write("\n")
# Summary
print(f"\n{'=' * 60}")
print(
f" Results: {len(results)} computed, {len(not_found)} not found, {len(failed)} failed"
)
if done > 0:
mean_ms = 1000 * total_time / done
print(f" Timing: {total_time:.1f}s total, {mean_ms:.0f}ms mean per process")
if not_found:
print(f"\n Not found ({len(not_found)}):")
for db, display, name in not_found[:10]:
print(f" [{db}] {display}")
if len(not_found) > 10:
print(f" ... and {len(not_found) - 10} more")
print()
if __name__ == "__main__":
main() Feeding your own application?
The same pattern - one launcher, exports shaped for your consumer - works for any frontend that needs environmental scores.