From ec9e806460b4c8d01ab72f471baff9a9725ea81c Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Sun, 21 Jun 2026 19:27:03 +0200 Subject: [PATCH 1/4] refactor(api)!: name activity fields activity_name / product_name An activity can yield several products, so its *name* is shared across them while the *product* tells them apart. The old `name`/`product` (plus `referenceProduct`, the snake-case get_path_to / columnar keys, and an exchange's `targetActivity`) invited mixing the two. Standardize the wire on `process_id` + `activity_name` + `product_name`: a process is the single-output `(activity, product)` unit addressed by `process_id` and has no name of its own. - DTOs: ActivitySummary, ConsumerResult, SupplyChainEntry, ActivityForAPI (referenceProduct* -> product*), ExchangeWithUnit.targetActivityName - get_path_to and the columnar score_activities identity keys converged to camelCase - get_contributing_activities: the hand-rolled MCP object now matches the REST DTO's keys - AGENTS.md glossary defines activity (grouping) vs process (unit) Breaking wire change; pre-1.0, no compatibility shim. --- AGENTS.md | 7 +++++- src/API/MCP.hs | 10 ++++---- src/API/MCP/Columnar.hs | 6 ++--- src/API/Types.hs | 20 ++++++++-------- src/Service.hs | 52 ++++++++++++++++++++-------------------- src/Service/Aggregate.hs | 4 ++-- test/MCPColumnarSpec.hs | 16 ++++++------- test/SupplyChainSpec.hs | 4 ++-- 8 files changed, 62 insertions(+), 57 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6e33c34c..3236312a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,12 @@ See `README.md` for the full feature spec. - **LCI** — life-cycle inventory: the raw physical flows (emissions, resources) for a product. - **LCIA** — impact assessment: LCI flows × characterization factors → impact scores. - **CF** — characterization factor: one flow's contribution to one impact category. -- **Activity / Exchange / Flow** — a process / one input-output line / the substance or product exchanged. +- **Activity** — a real-world transformation (`activityUUID`); may be **multi-output**. Owns `activity_name` and `all_products`. A *grouping* — never scored directly. +- **Product** — a reference output (a technosphere flow). Owns `product_name`. +- **Process** — a **single-output** unit = one `(activity, product)` pair, addressed by `process_id = activityUUID_productUUID`. Carries `activity_name` + `product_name`; **it has no name of its own** — a display label is generated on demand (`activity_name` + `product_name`), never stored or emitted as `process_name`. **This is the unit you search, get, and score.** +- **Exchange / Flow** — one input-output line / the substance or product exchanged. + +**Field names: `process_id`, `activity_name`, `product_name`. There is no `process_name`** — it would be a derived value, so the consumer generates the label. The word "activity" is overloaded in the field: ecoinvent/Brightway call a single-output dataset an "activity" (with a reference product); SimaPro/ILCD/ISO call it a "(unit) process". VoLCA keeps both crisp — **activity = the grouping, process = the (activity, product) unit.** The API *verbs* are activity-named by ecoinvent convention but all operate on **processes**: **`search_activities` / `get_activity` / `score_activities` / `get_contributing_activities` return one process per `(activity, product)`.** `search_activities` filters by `activity_name` and/or `product_name` and always returns processes — to find the lowest-impact way to make a product, `search_activities(product=X)` → processes → `score_activities` → compare. (SimaPro conflates the two: its process name is often empty and the activity name leaks into the product string; VoLCA keeps the two fields separate and surfaces the gap honestly rather than fabricating a name.) ## Commands diff --git a/src/API/MCP.hs b/src/API/MCP.hs index 9af5be17..62c42f39 100644 --- a/src/API/MCP.hs +++ b/src/API/MCP.hs @@ -609,7 +609,7 @@ callGetActivity rid args (db, _) = runTool rid $ do Success ai -> -- Single resolve: take the activity name from the 'ActivityInfo' -- already in hand instead of asking the engine to resolve the PID again. - let attach = attachMarketHintByName (pfaName (piActivity ai)) + let attach = attachMarketHintByName (pfaActivityName (piActivity ai)) payload | noFilters = val | otherwise = @@ -1464,12 +1464,12 @@ mkMcpCrossDBEntry dbManager rootDbName mBaseUrl colName methodIdText flowDB unit ) pure $ object $ - [ "process_id" .= pidText - , "activity_name" .= actName - , "product_name" .= prodName + [ "processId" .= pidText + , "activityName" .= actName + , "productName" .= prodName , "location" .= actLoc , "contribution" .= c - , "contribution_percent" .= (if score /= 0 then c / score * 100 else 0 :: Double) + , "contributionPercent" .= (if score /= 0 then c / score * 100 else 0 :: Double) ] ++ webUrlPair diff --git a/src/API/MCP/Columnar.hs b/src/API/MCP/Columnar.hs index 732f6136..64124608 100644 --- a/src/API/MCP/Columnar.hs +++ b/src/API/MCP/Columnar.hs @@ -92,7 +92,7 @@ the columnar JSON shape: { "scoring_set": "PEF" , "scoring_unit": "µPts PEF" , "functional_unit": "1.00 cubic meter of ..." -- only when all rows agree -, "columns": ["name", "process_id", "web_url", "total", "acd", ...] +, "columns": ["activityName", "processId", "web_url", "total", "acd", ...] , "rows": [[...], [...], ...] , "not_found": [...] , "invalid": [...] @@ -151,8 +151,8 @@ toColumnarBatch summaryOnly mBaseUrl dbName coll ss bir = webUrlCol = ["web_url" | isJust mBaseUrl] fixedColumns :: [Text] fixedColumns - | isHeterogeneous = ["name", "process_id"] ++ webUrlCol ++ ["functional_unit", "total"] - | otherwise = ["name", "process_id"] ++ webUrlCol ++ ["total"] + | isHeterogeneous = ["activityName", "processId"] ++ webUrlCol ++ ["functional_unit", "total"] + | otherwise = ["activityName", "processId"] ++ webUrlCol ++ ["total"] columns :: [Text] columns | summaryOnly = fixedColumns ++ ["dominant_indicator"] diff --git a/src/API/Types.hs b/src/API/Types.hs index 37afd91c..b4cd683f 100644 --- a/src/API/Types.hs +++ b/src/API/Types.hs @@ -177,9 +177,9 @@ deriving via (Stripped (SearchResults a)) instance (ToSchema a) => ToSchema (Sea -- | Minimal activity information for navigation data ActivitySummary = ActivitySummary { prsProcessId :: Text -- ProcessId format: activity_uuid_product_uuid - , prsName :: Text + , prsActivityName :: Text , prsLocation :: Text - , prsProduct :: Text -- Reference product name + , prsProductName :: Text -- Reference product name , prsProductAmount :: Double -- Reference product amount , prsProductUnit :: Text -- Reference product unit name , prsAllocationPercent :: Maybe Double -- SimaPro coproduct allocation (%, 0..100); Nothing for non-allocated bases @@ -192,9 +192,9 @@ data ActivitySummary = ActivitySummary -- | Consumer result — ActivitySummary enriched with BFS depth from the queried supplier data ConsumerResult = ConsumerResult { crProcessId :: Text - , crName :: Text + , crActivityName :: Text , crLocation :: Text - , crProduct :: Text + , crProductName :: Text , crProductAmount :: Double , crProductUnit :: Text , crDepth :: Int -- hops from the queried supplier (1 = direct consumer) @@ -839,7 +839,7 @@ dedicated field so the UI can render a Database column without parsing. data SupplyChainEntry = SupplyChainEntry { sceProcessId :: Text , sceDatabaseName :: Text -- database the entry lives in - , sceName :: Text + , sceActivityName :: Text , sceLocation :: Text , sceQuantity :: Double -- scalingFactor × root reference product amount (physical amount per functional unit) , sceUnit :: Text @@ -1045,7 +1045,7 @@ data ExchangeWithUnit = ExchangeWithUnit , ewuUnitName :: Text -- Unit name for the exchange , ewuFlowName :: Text -- Name of the flow being exchanged , ewuCompartment :: Maybe Compartment -- Biosphere compartment, Nothing for technosphere - , ewuTargetActivity :: Maybe Text -- For technosphere: name of target activity + , ewuTargetActivityName :: Maybe Text -- For technosphere: name of target activity , ewuTargetLocation :: Maybe Text -- For technosphere: location of target activity , ewuTargetProcessId :: Maybe Text -- For technosphere: ProcessId for navigation (activityUUID_productUUID) , ewuExComment :: Maybe Text -- Free-text per-exchange comment (mirrors exchangeComment) @@ -1057,15 +1057,15 @@ data ExchangeWithUnit = ExchangeWithUnit -- | Activity information optimized for API responses data ActivityForAPI = ActivityForAPI { pfaProcessId :: Text -- ProcessId format: "activityUUID_productUUID" - , pfaName :: Text + , pfaActivityName :: Text , pfaDescription :: [Text] -- Description par paragraphes , pfaSynonyms :: M.Map Text (S.Set Text) -- Synonymes par langue , pfaClassifications :: M.Map Text Text -- Classifications (ISIC, CPC, etc.) , pfaLocation :: Text , pfaUnit :: Text -- Unité de référence - , pfaReferenceProduct :: Maybe Text -- Name of the reference product (output) - , pfaReferenceProductAmount :: Maybe Double -- Amount of reference product - , pfaReferenceProductUnit :: Maybe Text -- Unit of reference product + , pfaProductName :: Maybe Text -- Name of the reference product (output) + , pfaProductAmount :: Maybe Double -- Amount of reference product + , pfaProductUnit :: Maybe Text -- Unit of reference product , pfaAllProducts :: [ActivitySummary] -- All products from same activityUUID , pfaExchanges :: [ExchangeWithUnit] -- Exchanges with unit names , pfaNativeType :: Maybe NativeActivityType -- Source-native activity type diff --git a/src/Service.hs b/src/Service.hs index 663ec3f9..4c60daf2 100644 --- a/src/Service.hs +++ b/src/Service.hs @@ -247,9 +247,9 @@ convertToInventoryExport db bioFlowDB unitDB processId rootActivity inventory = { imRootActivity = ActivitySummary { prsProcessId = processIdToText db processId - , prsName = activityName rootActivity + , prsActivityName = activityName rootActivity , prsLocation = activityLocation rootActivity - , prsProduct = prodName + , prsProductName = prodName , prsProductAmount = prodAmount , prsProductUnit = prodUnit , prsAllocationPercent = activityAllocationPercent rootActivity @@ -1070,15 +1070,15 @@ convertActivityForAPI unitCfg db processId activity = linkMap = buildCrossDBLinkMap db processId in ActivityForAPI { pfaProcessId = processIdToText db processId - , pfaName = activityName activity + , pfaActivityName = activityName activity , pfaDescription = activityDescription activity , pfaSynonyms = activitySynonyms activity , pfaClassifications = activityClassification activity , pfaLocation = activityLocation activity , pfaUnit = activityUnit activity - , pfaReferenceProduct = if T.null refProdName then Nothing else Just refProdName - , pfaReferenceProductAmount = if T.null refProdName then Nothing else Just refProdAmount - , pfaReferenceProductUnit = if T.null refProdName then Nothing else Just refProdUnit + , pfaProductName = if T.null refProdName then Nothing else Just refProdName + , pfaProductAmount = if T.null refProdName then Nothing else Just refProdAmount + , pfaProductUnit = if T.null refProdName then Nothing else Just refProdUnit , pfaAllProducts = allProducts , pfaExchanges = map (toExchangeWithUnit unitCfg db linkMap) (exchanges activity) , pfaNativeType = activityNativeType activity @@ -1194,7 +1194,7 @@ toExchangeWithUnit cfg db links exchange = , ewuUnitName = getUnitNameForExchange (dbUnits db) exchange , ewuFlowName = flowName , ewuCompartment = compartment - , ewuTargetActivity = trName <$> target + , ewuTargetActivityName = trName <$> target , ewuTargetLocation = trLocation <$> target , ewuTargetProcessId = trProcessId <$> target , ewuExComment = exchangeComment exchange @@ -1232,9 +1232,9 @@ mkActivitySummary db processId activity = let (prodName, prodAmount, prodUnit) = getReferenceProductInfo (dbTechFlows db) (dbUnits db) activity in ActivitySummary { prsProcessId = processIdToText db processId - , prsName = activityName activity + , prsActivityName = activityName activity , prsLocation = activityLocation activity - , prsProduct = prodName + , prsProductName = prodName , prsProductAmount = prodAmount , prsProductUnit = prodUnit , prsAllocationPercent = activityAllocationPercent activity @@ -1250,9 +1250,9 @@ unknownActivitySummary :: Database -> ProcessId -> ActivitySummary unknownActivitySummary db pid = ActivitySummary { prsProcessId = processIdToText db pid - , prsName = "Unknown" + , prsActivityName = "Unknown" , prsLocation = "" - , prsProduct = "Unknown" + , prsProductName = "Unknown" , prsProductAmount = 1.0 , prsProductUnit = "" , prsAllocationPercent = Nothing @@ -1324,9 +1324,9 @@ crossDBLinkToSummary link = <> UUID.toText (cdlSupplierActUUID link) <> "_" <> UUID.toText (cdlSupplierProdUUID link) - , prsName = cdlFlowName link + , prsActivityName = cdlFlowName link , prsLocation = cdlLocation link - , prsProduct = cdlFlowName link + , prsProductName = cdlFlowName link , prsProductAmount = 1.0 , prsProductUnit = cdlExchangeUnit link , prsAllocationPercent = Nothing @@ -1492,16 +1492,16 @@ getPathTo db solver pidText target = do let act = dbActivities db V.! i sf = scalingOf i base = - [ "process_id" .= processIdToText db (fromIntegral i) - , "name" .= activityName act + [ "processId" .= processIdToText db (fromIntegral i) + , "activityName" .= activityName act , "location" .= activityLocation act , "unit" .= activityUnit act - , "cumulative_quantity" .= (sf * rootRefAmount) - , "scaling_factor" .= sf + , "cumulativeQuantity" .= (sf * rootRefAmount) + , "scalingFactor" .= sf ] in object $ case mRatio of Nothing -> base - Just r -> base ++ ["local_step_ratio" .= r] + Just r -> base ++ ["localStepRatio" .= r] steps = mkStep firstPid (Nothing :: Maybe Double) : [ mkStep c (Just ratio) @@ -1621,7 +1621,7 @@ collectSupplyChainEntries db dbName mRootPid supplyVec scf includeEdges qualifyP in SupplyChainEntry { sceProcessId = qualify pid , sceDatabaseName = dbName - , sceName = activityName activity + , sceActivityName = activityName activity , sceLocation = activityLocation activity , sceQuantity = scalingFactor * quantityMult , sceUnit = activityUnit activity @@ -1670,7 +1670,7 @@ sortAndPaginate core entries = offset = fromMaybe 0 (afcOffset core) isDesc = afcOrder core == Just "desc" comparator = case afcSort core of - Just "name" -> \a b -> compare (sceName a) (sceName b) + Just "name" -> \a b -> compare (sceActivityName a) (sceActivityName b) Just "location" -> \a b -> compare (sceLocation a) (sceLocation b) Just "unit" -> \a b -> compare (sceUnit a) (sceUnit b) Just "depth" -> \a b -> compare (sceDepth a) (sceDepth b) @@ -1709,9 +1709,9 @@ buildSupplyChainFromScalingVector db dbName processId supplyVec scf includeEdges rootSummary = ActivitySummary { prsProcessId = processIdToText db processId - , prsName = activityName rootActivity + , prsActivityName = activityName rootActivity , prsLocation = activityLocation rootActivity - , prsProduct = + , prsProductName = fromMaybe (activityName rootActivity) (getReferenceProductName (dbTechFlows db) rootActivity) @@ -1771,9 +1771,9 @@ buildSupplyChainFromScalingVectorCrossDB unitCfg depLookup rootDb rootDbName roo rootSummary = ActivitySummary { prsProcessId = processIdToText rootDb rootPid - , prsName = activityName rootActivity + , prsActivityName = activityName rootActivity , prsLocation = activityLocation rootActivity - , prsProduct = + , prsProductName = fromMaybe (activityName rootActivity) (getReferenceProductName (dbTechFlows rootDb) rootActivity) @@ -2812,11 +2812,11 @@ getConsumers db dbName processIdText cnf = do isDesc = afcOrder core == Just "desc" crCmp = case afcSort core of - Just "name" -> \a b -> compare (crName a) (crName b) + Just "name" -> \a b -> compare (crActivityName a) (crActivityName b) Just "location" -> \a b -> compare (crLocation a) (crLocation b) Just "amount" -> \a b -> compare (crProductAmount a) (crProductAmount b) Just "unit" -> \a b -> compare (crProductUnit a) (crProductUnit b) - Just "product" -> \a b -> compare (crProduct a) (crProduct b) + Just "product" -> \a b -> compare (crProductName a) (crProductName b) _ -> \a b -> compare (crDepth a) (crDepth b) allResults = L.sortBy (if isDesc then flip crCmp else crCmp) rawResults diff --git a/src/Service/Aggregate.hs b/src/Service/Aggregate.hs index 6d9fc7e4..5e03edbd 100644 --- a/src/Service/Aggregate.hs +++ b/src/Service/Aggregate.hs @@ -220,7 +220,7 @@ rowsFromDirect db act = , rowQuantity = exchangeAmount ex , rowIsInput = Just (exchangeIsInput ex) , rowIsReference = Just (exchangeIsReference ex) - , rowTargetName = fmap prsName target + , rowTargetName = fmap prsActivityName target , rowLocation = fmap prsLocation target , rowExchangeType = Just (exchangeKindOf ex) , rowClassifications = M.empty @@ -232,7 +232,7 @@ rowsFromSupplyChain response = where mkRow e = AggRow - { rowName = sceName e + { rowName = sceActivityName e , rowFlowId = sceProcessId e , rowUnit = sceUnit e , rowQuantity = sceQuantity e diff --git a/test/MCPColumnarSpec.hs b/test/MCPColumnarSpec.hs index ec4982f3..60517121 100644 --- a/test/MCPColumnarSpec.hs +++ b/test/MCPColumnarSpec.hs @@ -147,8 +147,8 @@ spec = do `shouldBe` Just ( Array ( V.fromList - [ String "name" - , String "process_id" + [ String "activityName" + , String "processId" , String "web_url" , String "total" , String "acd" @@ -202,8 +202,8 @@ spec = do `shouldBe` Just ( Array ( V.fromList - [ String "name" - , String "process_id" + [ String "activityName" + , String "processId" , String "web_url" , String "functional_unit" , String "total" @@ -238,8 +238,8 @@ spec = do `shouldBe` Just ( Array ( V.fromList - [ String "name" - , String "process_id" + [ String "activityName" + , String "processId" , String "web_url" , String "total" , String "dominant_indicator" @@ -272,8 +272,8 @@ spec = do `shouldBe` Just ( Array ( V.fromList - [ String "name" - , String "process_id" + [ String "activityName" + , String "processId" , String "total" , String "acd" , String "cch" diff --git a/test/SupplyChainSpec.hs b/test/SupplyChainSpec.hs index 7461feac..7d1b7a5e 100644 --- a/test/SupplyChainSpec.hs +++ b/test/SupplyChainSpec.hs @@ -137,7 +137,7 @@ spec = do supplyVec <- computeScalingVector db rootPid -- Only pid 1's name contains the token "y". let entries = scrSupplyChain (buildWithName db rootPid supplyVec (Just "Y")) - map sceName entries `shouldBe` ["production of product Y"] + map sceActivityName entries `shouldBe` ["production of product Y"] it "accepts typos via edit-distance expansion" $ do db <- loadWithIndex @@ -195,7 +195,7 @@ spec = do case getConsumers db "test-db" pidZ (mapConsumerCore (\c -> c{afcName = Just "X"}) emptyConsumer) of Left err -> expectationFailure $ "getConsumers failed: " ++ show err Right cr -> do - map crName (srResults (crrResults cr)) `shouldBe` ["production of product X"] + map crActivityName (srResults (crrResults cr)) `shouldBe` ["production of product X"] srTotal (crrResults cr) `shouldBe` 1 it "accepts typos in the name filter" $ do From 30c54ca57a8f282be96eb2f7326f797dcc5a50e3 Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Sun, 21 Jun 2026 19:27:03 +0200 Subject: [PATCH 2/4] refactor(pyvolca)!: rename activity fields to activity_name / product_name Mirror the engine wire rename. Activity / ConsumerResult / SupplyChainEntry / PathStep / ActivityDetail now expose `activity_name` + `product_name` (ActivityDetail's `reference_product*` -> `product_*`), and a technosphere exchange's `target_activity` -> `target_activity_name`. A process is an `(activity, product)` pair with no name of its own. Bumps to 0.6.0 (breaking change, 0.x minor) with a migration note. --- pyvolca/CHANGELOG.md | 10 +++ pyvolca/README.md | 53 +++++++------- pyvolca/examples/explore_classifications.py | 6 +- pyvolca/examples/feed_ratio.py | 12 ++-- pyvolca/examples/ingredient_substitution.py | 12 ++-- .../examples/raw_ingredient_substitution.py | 8 +-- pyvolca/pyproject.toml | 2 +- pyvolca/src/volca/agribalyse.py | 16 ++--- pyvolca/src/volca/types.py | 69 ++++++++++--------- pyvolca/tests/conftest.py | 24 +++---- pyvolca/tests/test_dispatch.py | 12 ++-- pyvolca/tests/test_exchange_comment.py | 2 +- pyvolca/tests/test_search_results.py | 24 +++---- pyvolca/tests/test_typed_returns.py | 10 +-- pyvolca/tests/test_waste_exchange.py | 18 ++--- 15 files changed, 145 insertions(+), 133 deletions(-) diff --git a/pyvolca/CHANGELOG.md b/pyvolca/CHANGELOG.md index 6196fa90..7885b042 100644 --- a/pyvolca/CHANGELOG.md +++ b/pyvolca/CHANGELOG.md @@ -18,6 +18,16 @@ git cliff --unreleased --tag pyvolca-v0.X.Y # render as a released section Then paste the rendered block at the top of this file and tighten wording. +## [0.6.0] - 2026-06-21 + +Breaking: an activity's name fields are renamed for clarity. Needs the companion engine release (the wire keys changed too). + +### Breaking changes + +- `Activity.name` → `activity_name` and `Activity.product` → `product_name` — and the same on `ConsumerResult`, `SupplyChainEntry`, `PathStep`, and `ActivityDetail` (whose `reference_product` / `reference_product_amount` / `reference_product_unit` become `product_name` / `product_amount` / `product_unit`). A technosphere exchange's `target_activity` is now `target_activity_name`. +- Why: an activity can yield several products, so the *name* belongs to the activity (shared across its products) while the *product* is what tells them apart — `name` / `product` invited mixing the two. A "process" is an `(activity, product)` pair, addressed by `process_id`; it has no name of its own (compose a label from `activity_name` + `product_name`). +- Migration: `.name` → `.activity_name`, `.product` → `.product_name`, `.reference_product` → `.product_name`, `.target_activity` → `.target_activity_name`. + ## [0.5.1] - 2026-05-31 Two bug fixes for engine 0.7.0. No API change — just upgrade. diff --git a/pyvolca/README.md b/pyvolca/README.md index 751be063..4220186b 100644 --- a/pyvolca/README.md +++ b/pyvolca/README.md @@ -105,10 +105,10 @@ Other listings: `c.list_classifications()` returns the classification systems an plants = c.search_activities(name="wheat flour, at plant", page_size=5) print(f"{len(plants)} matches; showing page 1 ({plants.page_size} items)") for a in plants: - print(f"{a.process_id} {a.name} ({a.location})") + print(f"{a.process_id} {a.activity_name} → {a.product_name} ({a.location})") ``` -`search_activities` returns a `SearchResults[Activity]` — a paginated wire envelope. Iterate it to walk every match across all pages (subsequent pages fetched on demand, then cached so re-iteration is free); `len(results)` is the server-reported total. Use `results.page(n, page_size=M)` for explicit page access, or pass `page=N` + `page_size=M` to jump straight to a page (both are required together — `page=` alone is rejected since the offset can't be derived without committing to a page size). Each `Activity` carries `process_id`, `name`, `location`, `product`, `product_amount`, `product_unit`. Narrow the query with `geo="FR"`, `classification=`/`classification_value=` (ISIC/CPC), or set `exact=True` for an exact-name match. To search by flow name (technosphere products and biosphere flows) instead of activity name, use `c.search_flows(query=...)`. +`search_activities` returns a `SearchResults[Activity]` — a paginated wire envelope. Iterate it to walk every match across all pages (subsequent pages fetched on demand, then cached so re-iteration is free); `len(results)` is the server-reported total. Use `results.page(n, page_size=M)` for explicit page access, or pass `page=N` + `page_size=M` to jump straight to a page (both are required together — `page=` alone is rejected since the offset can't be derived without committing to a page size). Each `Activity` is a process — an `(activity, product)` pair — carrying `process_id`, `activity_name`, `location`, `product_name`, `product_amount`, `product_unit`. A process has no name of its own; compose a label from `activity_name` + `product_name`. Narrow the query with `geo="FR"`, `classification=`/`classification_value=` (ISIC/CPC), or set `exact=True` for an exact-name match. To search by flow name (technosphere products and biosphere flows) instead of activity name, use `c.search_flows(query=...)`. ## Inspect an activity @@ -117,7 +117,7 @@ for a in plants: ```python detail = c.get_activity(plants[0].process_id) for ex in detail.technosphere_inputs: - print(f"{ex.amount:.4g} {ex.unit} of {ex.flow_name} ← {ex.target_activity}") + print(f"{ex.amount:.4g} {ex.unit} of {ex.flow_name} ← {ex.target_activity_name}") ``` `get_activity` returns a typed `ActivityDetail`. Use `.inputs` / `.outputs` / `.technosphere_inputs` to filter the exchanges; each entry is an `Exchange` — either a `TechnosphereExchange` (an input or output of an intermediate product) or a `BiosphereExchange` (resource extracted or pollutant emitted). @@ -130,7 +130,7 @@ for ex in detail.technosphere_inputs: chain = c.get_supply_chain(plants[0].process_id, name="at farm", limit=20) print(f"{chain.filtered_activities} of {chain.total_activities} upstream activities match 'at farm'") for entry in chain.entries[:5]: - print(f" {entry.quantity:.4g} {entry.unit} of {entry.name} ({entry.location})") + print(f" {entry.quantity:.4g} {entry.unit} of {entry.activity_name} ({entry.location})") ``` For *"how exactly does this root reach a specific upstream supplier?"*, use `get_path_to(process_id, target=...)` — returns a `PathResult` of ordered `PathStep`s root → target with cumulative quantities and step ratios. @@ -142,7 +142,7 @@ For *"how exactly does this root reach a specific upstream supplier?"*, use `get ```python result = c.get_consumers(plants[0].process_id, max_depth=2, page_size=10) for cons in result.consumers: - print(f" depth={cons.depth} {cons.name} ({cons.location})") + print(f" depth={cons.depth} {cons.activity_name} ({cons.location})") ``` Returns a `ConsumersResponse` whose `consumers` field is a `SearchResults[ConsumerResult]` — same paginated iterator semantics as `search_activities`. When `include_edges=True`, `result.edges` carries the technosphere edges so callers can reconstruct supplier→consumer paths without a second round trip. Pass `classification_filters=[...]` to restrict to a category. @@ -799,19 +799,20 @@ One activity in a database — the row returned by /activities search. ``process_id`` is the engine's canonical address (``activityUUID_productUUID``) and is what you pass to every detail endpoint (:meth:`Client.get_activity`, -:meth:`Client.get_supply_chain`, :meth:`Client.get_impacts`, …). ``name`` is -the activity name (e.g. ``"wheat flour, at plant"``); ``product`` is the -reference output product (e.g. ``"wheat flour"``); ``product_amount`` and -``product_unit`` describe the functional unit (typically ``1.0`` of -``"kg"`` / ``"MJ"`` / etc.). ``location`` is the geography code -(``"FR"``, ``"GLO"``, ``"RoW"``…). +:meth:`Client.get_supply_chain`, :meth:`Client.get_impacts`, …). +``activity_name`` is the activity name (e.g. ``"wheat flour, at plant"``); +``product_name`` is the reference output product (e.g. ``"wheat flour"``); +``product_amount`` and ``product_unit`` describe the functional unit +(typically ``1.0`` of ``"kg"`` / ``"MJ"`` / etc.). ``location`` is the +geography code (``"FR"``, ``"GLO"``, ``"RoW"``…). A process has no name of +its own — compose a label from ``activity_name`` + ``product_name``. | Field | Type | Default | |-------|------|---------| | `process_id` | `str` | — | -| `name` | `str` | — | +| `activity_name` | `str` | — | | `location` | `str` | — | -| `product` | `str` | — | +| `product_name` | `str` | — | | `product_amount` | `float` | — | | `product_unit` | `str` | — | @@ -841,14 +842,14 @@ instead of walking the raw exchanges list. | Field | Type | Default | |-------|------|---------| | `process_id` | `str` | — | -| `name` | `str` | — | +| `activity_name` | `str` | — | | `location` | `str` | — | | `unit` | `str` | — | | `description` | `list[str]` | — | | `classifications` | `dict[str, str]` | — | -| `reference_product` | `str \| None` | — | -| `reference_product_amount` | `float \| None` | — | -| `reference_product_unit` | `str \| None` | — | +| `product_name` | `str \| None` | — | +| `product_amount` | `float \| None` | — | +| `product_unit` | `str \| None` | — | | `all_products` | `list[Activity]` | — | | `exchanges` | `list[Union[TechnosphereExchange, BiosphereExchange, WasteExchange]]` | — | @@ -1003,9 +1004,9 @@ Activity that consumes a given supplier, with BFS depth. | Field | Type | Default | |-------|------|---------| | `process_id` | `str` | — | -| `name` | `str` | — | +| `activity_name` | `str` | — | | `location` | `str` | — | -| `product` | `str` | — | +| `product_name` | `str` | — | | `product_amount` | `float` | — | | `product_unit` | `str` | — | | `depth` | `int` | — | @@ -1288,14 +1289,14 @@ Shortest upstream path from a root process to a matching activity. One step in the supply chain path returned by get_path_to. -Note: the /path endpoint emits snake_case JSON directly (built via -aeson's `object [...]` rather than generic ToJSON), so it bypasses -the engine's stripLowerPrefix transform. +Note: the /path endpoint is hand-built (aeson `object [...]`) but now +emits camelCase keys (``processId``, ``activityName``, +``cumulativeQuantity``, …) like the rest of the API. | Field | Type | Default | |-------|------|---------| | `process_id` | `str` | — | -| `name` | `str` | — | +| `activity_name` | `str` | — | | `location` | `str` | — | | `unit` | `str` | — | | `cumulative_quantity` | `float` | — | @@ -1453,7 +1454,7 @@ second :meth:`Client.get_activity` round trip. | Field | Type | Default | |-------|------|---------| | `process_id` | `str` | — | -| `name` | `str` | — | +| `activity_name` | `str` | — | | `location` | `str` | — | | `quantity` | `float` | — | | `unit` | `str` | — | @@ -1471,7 +1472,7 @@ producing activity's classifications describe the product taxonomy. | `amount` | `float` | — | | `unit` | `str` | — | | `role` | `TechRole` | — | -| `target_activity` | `str \| None` | — | +| `target_activity_name` | `str \| None` | — | | `target_location` | `str \| None` | — | | `target_process_id` | `str \| None` | — | | `comment` | `str \| None` | None | @@ -1493,7 +1494,7 @@ product input. Orphan waste (no linked treatment) contributes zero impact | `amount` | `float` | — | | `unit` | `str` | — | | `is_input` | `bool` | — | -| `target_activity` | `str \| None` | — | +| `target_activity_name` | `str \| None` | — | | `target_location` | `str \| None` | — | | `target_process_id` | `str \| None` | — | | `comment` | `str \| None` | None | diff --git a/pyvolca/examples/explore_classifications.py b/pyvolca/examples/explore_classifications.py index dbc62eb1..455053bc 100644 --- a/pyvolca/examples/explore_classifications.py +++ b/pyvolca/examples/explore_classifications.py @@ -42,7 +42,7 @@ def main(): for i, act in enumerate(all_activities[:sample_size]): detail = c.get_activity(act.process_id) classifications = detail.get("activity", {}).get("classifications", {}) - name_vs_class.append((act.name, classifications)) + name_vs_class.append((act.activity_name, classifications)) for k, v in classifications.items(): key_counts[k] += 1 @@ -97,7 +97,7 @@ def main(): flour_results = c.search_activities(name="Wheat flour, type 55", limit=5) if flour_results: flour = flour_results[0] - print(f" Root: {flour.name} ({flour.location})") + 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)}") @@ -122,7 +122,7 @@ def main(): 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.name}") + print(f" {entry.quantity:.4f} {entry.unit} {entry.activity_name}") if cls_str: print(f" Classifications: {cls_str}") else: diff --git a/pyvolca/examples/feed_ratio.py b/pyvolca/examples/feed_ratio.py index 6caa8cd8..b95dec9b 100644 --- a/pyvolca/examples/feed_ratio.py +++ b/pyvolca/examples/feed_ratio.py @@ -59,8 +59,8 @@ def print_supply_chain( print(f"\n {label} ({len(entries)} activities, {per_kg:.3f} kg/kg)") for e in sorted(entries, key=lambda x: -x.quantity): e_per_kg = e.quantity / ref_amount if ref_amount > 0 else e.quantity - print(f" {e_per_kg:.4f} {e.unit}/kg {e.name} ({e.location})") - csv_rows.append({"category": path, "name": e.name, "location": e.location, + print(f" {e_per_kg:.4f} {e.unit}/kg {e.activity_name} ({e.location})") + csv_rows.append({"category": path, "name": e.activity_name, "location": e.location, "quantity": e.quantity, "unit": e.unit, "per_kg_product": round(e_per_kg, 6)}) @@ -100,9 +100,9 @@ def main(): if args.name: idx = 0 else: - idx = select_from_list(products, "Select product", lambda p: f"{p.name} ({p.location})") + idx = select_from_list(products, "Select product", lambda p: f"{p.activity_name} ({p.location})") product = products[idx] - print(f"\nAnalyzing: {product.name}") + print(f"\nAnalyzing: {product.activity_name}") # Step 2: Get supply chain grouped by classification print("\nFetching supply chain...") @@ -133,8 +133,8 @@ def main(): all_products = activity.get("activity", {}).get("allProducts", []) ref_amount = float(all_products[0]["productAmount"]) if all_products else 1.0 - csv_rows = print_supply_chain(groups, ref_amount, product.name, filter_text if not args.prefix else (args.prefix or "")) - safe_name = "".join(c if c.isalnum() or c in "._-" else "_" for c in product.name)[:50] + csv_rows = print_supply_chain(groups, ref_amount, product.activity_name, filter_text if not args.prefix else (args.prefix or "")) + safe_name = "".join(c if c.isalnum() or c in "._-" else "_" for c in product.activity_name)[:50] export_csv(csv_rows, f"supply_chain_{safe_name}.csv") diff --git a/pyvolca/examples/ingredient_substitution.py b/pyvolca/examples/ingredient_substitution.py index b414f937..35da4746 100644 --- a/pyvolca/examples/ingredient_substitution.py +++ b/pyvolca/examples/ingredient_substitution.py @@ -54,7 +54,7 @@ def main(): return flour = wheat_flour[0] - print(f" Found: {flour.name} ({flour.location}) [{flour.process_id}]") + print(f" Found: {flour.activity_name} ({flour.location}) [{flour.process_id}]") chain = c.get_supply_chain(flour.process_id, name="at farm", limit=100, include_edges=True) print(f" Total upstream activities: {chain.total_activities}") @@ -68,7 +68,7 @@ def main(): ) print(" Top 5 'at farm' ingredients:") for entry in chain.entries[:5]: - print(f" {entry.quantity:.4f} {entry.unit} of {entry.name} ({entry.location})") + 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)...") @@ -81,7 +81,7 @@ def main(): print(f" ERROR: conventional wheat not found (query: {conv_query!r})") return from_activity = conv_fr[0] - print(f" From: {from_activity.name} ({from_activity.location}) [{from_activity.process_id}]") + 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" @@ -91,12 +91,12 @@ def main(): print(f" ERROR: organic wheat not found (query: {org_query!r})") return to_activity = org_fr[0] - print(f" To: {to_activity.name} ({to_activity.location}) [{to_activity.process_id}]") + 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.name} in supply chain 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}") @@ -108,7 +108,7 @@ def main(): 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.name} ({entry.location})") + print(f" {entry.quantity:.6f} {entry.unit} of {entry.activity_name} ({entry.location})") if __name__ == "__main__": diff --git a/pyvolca/examples/raw_ingredient_substitution.py b/pyvolca/examples/raw_ingredient_substitution.py index 6d277a71..b677c915 100644 --- a/pyvolca/examples/raw_ingredient_substitution.py +++ b/pyvolca/examples/raw_ingredient_substitution.py @@ -58,7 +58,7 @@ def resolve_substitutions(client: Client): if not tgt: print(f" WARNING: target not found: {tgt_query!r} ({tgt_loc})") continue - print(f" {src.name[:60]} → {tgt.name[:60]}") + print(f" {src.activity_name[:60]} → {tgt.activity_name[:60]}") pairs.append((src, tgt)) return pairs @@ -94,7 +94,7 @@ def main(): for src, tgt in sub_pairs: resp = client.get_consumers(src.process_id, limit=10000) - print(f" {src.name[:50]}: {len(resp.consumers)} downstream consumers") + print(f" {src.activity_name[:50]}: {len(resp.consumers)} downstream consumers") for consumer in resp.consumers: applicable.append((consumer, src, tgt)) @@ -109,14 +109,14 @@ def main(): writer = csv.writer(f) writer.writerow(["process_id", "name", "location", "raw_ingredient"]) for product, src, _tgt in applicable: - writer.writerow([product.process_id, product.name, product.location, src.name]) + 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.name[:70]}") + 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) diff --git a/pyvolca/pyproject.toml b/pyvolca/pyproject.toml index 1212abc7..cc8af6c3 100644 --- a/pyvolca/pyproject.toml +++ b/pyvolca/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyvolca" -version = "0.5.1" +version = "0.6.0" description = "Python client for VoLCA — Life Cycle Assessment engine" requires-python = ">=3.10" license = "Apache-2.0" diff --git a/pyvolca/src/volca/agribalyse.py b/pyvolca/src/volca/agribalyse.py index 34132d89..80e69f68 100644 --- a/pyvolca/src/volca/agribalyse.py +++ b/pyvolca/src/volca/agribalyse.py @@ -149,7 +149,7 @@ def detect_pattern(activity: ActivityDetail) -> str: wfldb_inputs = [ e for e in tech_inputs - if e.target_activity and "WFLDB" in e.target_activity + if e.target_activity_name and "WFLDB" in e.target_activity_name ] if len(wfldb_inputs) == 1: return "wrapper_wfldb" @@ -261,8 +261,8 @@ def _find_layered_operations( ClassificationFilter("Category type", "processing", MatchMode.EXACT), ], ).entries - real = [e for e in ops if not e.name.startswith("[Dummy]")] - dummy_op = not real and any(e.name.startswith("[Dummy]") for e in ops) + real = [e for e in ops if not e.activity_name.startswith("[Dummy]")] + dummy_op = not real and any(e.activity_name.startswith("[Dummy]") for e in ops) return real, dummy_op @@ -306,7 +306,7 @@ def decompose(client: "Client", process_id: str) -> Decomposition: wfldb_e = next( ( e for e in act.technosphere_inputs - if e.target_activity and "WFLDB" in e.target_activity + if e.target_activity_name and "WFLDB" in e.target_activity_name ), None, ) @@ -343,7 +343,7 @@ def decompose(client: "Client", process_id: str) -> Decomposition: ( e for e in act.technosphere_inputs if e.target_process_id - and not (e.target_activity or "").startswith("[Dummy]") + and not (e.target_activity_name or "").startswith("[Dummy]") ), None, ) @@ -351,10 +351,10 @@ def decompose(client: "Client", process_id: str) -> Decomposition: raw_entry = _find_layered_raw(client, parent.target_process_id) if raw_entry is not None: - raw_name = raw_entry.name + raw_name = raw_entry.activity_name raw_kg = _kg_equiv(raw_entry) if operations: - operation_name = "; ".join(o.name for o in operations) + operation_name = "; ".join(o.activity_name for o in operations) if target_pattern == "layered": sc_scope, sc_depth = "supply_chain", 2 @@ -421,7 +421,7 @@ def agg_total(**kw: object) -> float: # wrapping activity name) become the co-product list. allocation = parse_allocation(target_description) if not co_products and allocation is not None and allocation.factors: - act_name_l = act.name.lower() + act_name_l = act.activity_name.lower() main_key = max( allocation.factors, key=lambda k: sum(1 for w in k.split() if w in act_name_l), diff --git a/pyvolca/src/volca/types.py b/pyvolca/src/volca/types.py index 3cc6c238..1716b827 100644 --- a/pyvolca/src/volca/types.py +++ b/pyvolca/src/volca/types.py @@ -431,18 +431,19 @@ class Activity(FromJson): ``process_id`` is the engine's canonical address (``activityUUID_productUUID``) and is what you pass to every detail endpoint (:meth:`Client.get_activity`, - :meth:`Client.get_supply_chain`, :meth:`Client.get_impacts`, …). ``name`` is - the activity name (e.g. ``"wheat flour, at plant"``); ``product`` is the - reference output product (e.g. ``"wheat flour"``); ``product_amount`` and - ``product_unit`` describe the functional unit (typically ``1.0`` of - ``"kg"`` / ``"MJ"`` / etc.). ``location`` is the geography code - (``"FR"``, ``"GLO"``, ``"RoW"``…). + :meth:`Client.get_supply_chain`, :meth:`Client.get_impacts`, …). + ``activity_name`` is the activity name (e.g. ``"wheat flour, at plant"``); + ``product_name`` is the reference output product (e.g. ``"wheat flour"``); + ``product_amount`` and ``product_unit`` describe the functional unit + (typically ``1.0`` of ``"kg"`` / ``"MJ"`` / etc.). ``location`` is the + geography code (``"FR"``, ``"GLO"``, ``"RoW"``…). A process has no name of + its own — compose a label from ``activity_name`` + ``product_name``. """ process_id: str - name: str + activity_name: str location: str - product: str + product_name: str product_amount: float product_unit: str @@ -467,9 +468,9 @@ class Flow(FromJson): class ConsumerResult(FromJson): """Activity that consumes a given supplier, with BFS depth.""" process_id: str - name: str + activity_name: str location: str - product: str + product_name: str product_amount: float product_unit: str depth: int # hops from the queried supplier (1 = direct consumer) @@ -490,7 +491,7 @@ class SupplyChainEntry(FromJson): """ process_id: str - name: str + activity_name: str location: str quantity: float unit: str @@ -502,12 +503,12 @@ class SupplyChainEntry(FromJson): class PathStep: """One step in the supply chain path returned by get_path_to. - Note: the /path endpoint emits snake_case JSON directly (built via - aeson's `object [...]` rather than generic ToJSON), so it bypasses - the engine's stripLowerPrefix transform. + Note: the /path endpoint is hand-built (aeson `object [...]`) but now + emits camelCase keys (``processId``, ``activityName``, + ``cumulativeQuantity``, …) like the rest of the API. """ process_id: str - name: str + activity_name: str location: str unit: str cumulative_quantity: float @@ -517,13 +518,13 @@ class PathStep: @classmethod def from_json(cls, d: dict) -> "PathStep": return cls( - process_id=d["process_id"], - name=d["name"], + process_id=d["processId"], + activity_name=d["activityName"], location=d["location"], unit=d["unit"], - cumulative_quantity=d["cumulative_quantity"], - scaling_factor=d["scaling_factor"], - local_step_ratio=d.get("local_step_ratio"), + cumulative_quantity=d["cumulativeQuantity"], + scaling_factor=d["scalingFactor"], + local_step_ratio=d.get("localStepRatio"), ) @@ -720,7 +721,7 @@ class TechnosphereExchange: amount: float unit: str role: TechRole - target_activity: str | None + target_activity_name: str | None target_location: str | None target_process_id: str | None comment: str | None = None @@ -754,7 +755,7 @@ def from_json(cls, ewu: dict) -> "TechnosphereExchange": amount=inner["amount"], unit=ewu["unitName"], role=TechRole(inner["role"]), - target_activity=ewu.get("targetActivity"), + target_activity_name=ewu.get("targetActivityName"), target_location=ewu.get("targetLocation"), target_process_id=ewu.get("targetProcessId"), comment=_exchange_comment(ewu, inner), @@ -835,7 +836,7 @@ class WasteExchange: amount: float unit: str is_input: bool # True = consumed by treatment process; False = generated (typical case) - target_activity: str | None + target_activity_name: str | None target_location: str | None target_process_id: str | None comment: str | None = None @@ -860,7 +861,7 @@ def from_json(cls, ewu: dict) -> "WasteExchange": amount=inner["amount"], unit=ewu["unitName"], is_input=inner["isInput"], - target_activity=ewu.get("targetActivity"), + target_activity_name=ewu.get("targetActivityName"), target_location=ewu.get("targetLocation"), target_process_id=ewu.get("targetProcessId"), comment=_exchange_comment(ewu, inner), @@ -913,7 +914,7 @@ def parse_exchange_detail(ed: dict) -> Exchange: amount=inner["amount"], unit=unit, role=TechRole(inner["role"]), - target_activity=target.get("name"), + target_activity_name=target.get("activityName"), target_location=target.get("location"), target_process_id=target.get("processId"), comment=comment, @@ -942,7 +943,7 @@ def parse_exchange_detail(ed: dict) -> Exchange: amount=inner["amount"], unit=unit, is_input=inner["isInput"], - target_activity=target.get("name"), + target_activity_name=target.get("activityName"), target_location=target.get("location"), target_process_id=target.get("processId"), comment=comment, @@ -963,14 +964,14 @@ class ActivityDetail: """ process_id: str - name: str + activity_name: str location: str unit: str description: list[str] classifications: dict[str, str] - reference_product: str | None - reference_product_amount: float | None - reference_product_unit: str | None + product_name: str | None + product_amount: float | None + product_unit: str | None all_products: list[Activity] exchanges: list[Exchange] @@ -980,14 +981,14 @@ def from_json(cls, d: dict) -> "ActivityDetail": pfa = d["activity"] return cls( process_id=pfa["processId"], - name=pfa["name"], + activity_name=pfa["activityName"], location=pfa["location"], unit=pfa["unit"], description=pfa.get("description", []), classifications=pfa.get("classifications", {}), - reference_product=pfa.get("referenceProduct"), - reference_product_amount=pfa.get("referenceProductAmount"), - reference_product_unit=pfa.get("referenceProductUnit"), + product_name=pfa.get("productName"), + product_amount=pfa.get("productAmount"), + product_unit=pfa.get("productUnit"), all_products=[Activity.from_json(a) for a in pfa.get("allProducts", [])], exchanges=[parse_exchange(e) for e in pfa.get("exchanges", [])], ) diff --git a/pyvolca/tests/conftest.py b/pyvolca/tests/conftest.py index 1d47de83..705bee7c 100644 --- a/pyvolca/tests/conftest.py +++ b/pyvolca/tests/conftest.py @@ -235,30 +235,30 @@ def readme_namespace() -> dict[str, Any]: activity_a = Activity( process_id="aaaa1111-aaaa-bbbb-cccc-111122223333_dddd2222-eeee-ffff-aaaa-444455556666", - name="Wheat flour, type 55, at plant", + activity_name="Wheat flour, type 55, at plant", location="FR", - product="wheat flour", + product_name="wheat flour", product_amount=1.0, product_unit="kg", ) activity_b = Activity( process_id="bbbb2222-aaaa-bbbb-cccc-111122223333_eeee3333-eeee-ffff-aaaa-444455556666", - name="Wheat flour, type 65, at plant", + activity_name="Wheat flour, type 65, at plant", location="FR", - product="wheat flour", + product_name="wheat flour", product_amount=1.0, product_unit="kg", ) activity_detail = ActivityDetail( process_id=activity_a.process_id, - name=activity_a.name, + activity_name=activity_a.activity_name, location=activity_a.location, unit="kg", description=["Bread-making wheat flour, soft variety, T55."], classifications={"ISIC rev.4 ecoinvent": "1061: Manufacture of grain mill products"}, - reference_product="wheat flour", - reference_product_amount=1.0, - reference_product_unit="kg", + product_name="wheat flour", + product_amount=1.0, + product_unit="kg", all_products=[activity_a], exchanges=[ TechnosphereExchange( @@ -266,7 +266,7 @@ def readme_namespace() -> dict[str, Any]: amount=1.31, unit="kg", role=TechRole.INPUT, - target_activity="Soft wheat grain production, FR", + target_activity_name="Soft wheat grain production, FR", target_location="FR", target_process_id="cccc3333-aaaa-bbbb-cccc-111122223333_aaaa4444-eeee-ffff-aaaa-444455556666", ), @@ -286,7 +286,7 @@ def readme_namespace() -> dict[str, Any]: entries=[ SupplyChainEntry( process_id="cccc3333-aaaa-bbbb-cccc-111122223333_aaaa4444-eeee-ffff-aaaa-444455556666", - name="Soft wheat grain, at farm", + activity_name="Soft wheat grain, at farm", location="FR", quantity=1.31, unit="kg", @@ -296,9 +296,9 @@ def readme_namespace() -> dict[str, Any]: ) consumer_b = ConsumerResult( process_id=activity_b.process_id, - name="Sandwich bread, sliced, at plant", + activity_name="Sandwich bread, sliced, at plant", location="FR", - product="bread", + product_name="bread", product_amount=1.0, product_unit="kg", depth=1, diff --git a/pyvolca/tests/test_dispatch.py b/pyvolca/tests/test_dispatch.py index 8ef744bc..9391086b 100644 --- a/pyvolca/tests/test_dispatch.py +++ b/pyvolca/tests/test_dispatch.py @@ -161,8 +161,8 @@ def test_search_activities_returns_iterable_search_results(self, mocked_client, client, session = mocked_client page1 = { "results": [ - {"processId": "a", "name": "A", "location": "FR", "product": "p", "productAmount": 1.0, "productUnit": "kg"}, - {"processId": "b", "name": "B", "location": "FR", "product": "p", "productAmount": 1.0, "productUnit": "kg"}, + {"processId": "a", "activityName": "A", "location": "FR", "productName": "p", "productAmount": 1.0, "productUnit": "kg"}, + {"processId": "b", "activityName": "B", "location": "FR", "productName": "p", "productAmount": 1.0, "productUnit": "kg"}, ], "total": 3, "offset": 0, @@ -172,7 +172,7 @@ def test_search_activities_returns_iterable_search_results(self, mocked_client, } page2 = { "results": [ - {"processId": "c", "name": "C", "location": "FR", "product": "p", "productAmount": 1.0, "productUnit": "kg"}, + {"processId": "c", "activityName": "C", "location": "FR", "productName": "p", "productAmount": 1.0, "productUnit": "kg"}, ], "total": 3, "offset": 2, @@ -186,7 +186,7 @@ def test_search_activities_returns_iterable_search_results(self, mocked_client, assert len(results) == 3 assert results.page_size == 2 assert results.has_more is True - names = [a.name for a in results] + names = [a.activity_name for a in results] assert names == ["A", "B", "C"] # Page 2 was fetched lazily during iteration. assert session.get.call_count == 2 @@ -207,7 +207,7 @@ def test_kebab_case_query_param_translation(self, mocked_client, make_response): """``min_quantity`` Python → ``min-quantity`` wire.""" client, session = mocked_client session.get.return_value = make_response({ - "root": {"processId": "x", "name": "y", "location": "FR", "product": "p", "productAmount": 1.0, "productUnit": "kg"}, + "root": {"processId": "x", "name": "y", "location": "FR", "productName": "p", "productAmount": 1.0, "productUnit": "kg"}, "supplyChain": [], "edges": [], "totalActivities": 0, @@ -225,7 +225,7 @@ def test_supply_chain_forwards_preset(self, mocked_client, make_response): """preset= must reach the supply-chain endpoint as a query param.""" client, session = mocked_client session.get.return_value = make_response({ - "root": {"processId": "x", "name": "y", "location": "FR", "product": "p", "productAmount": 1.0, "productUnit": "kg"}, + "root": {"processId": "x", "name": "y", "location": "FR", "productName": "p", "productAmount": 1.0, "productUnit": "kg"}, "supplyChain": [], "edges": [], "totalActivities": 0, diff --git a/pyvolca/tests/test_exchange_comment.py b/pyvolca/tests/test_exchange_comment.py index 80b4b38e..9f5bcf26 100644 --- a/pyvolca/tests/test_exchange_comment.py +++ b/pyvolca/tests/test_exchange_comment.py @@ -42,7 +42,7 @@ def _ewu(tag: str, *, ex_comment: str | None, inner_comment: str | None) -> dict "exchange": inner, "flowName": "wheat", "unitName": "kg", - "targetActivity": None, + "targetActivityName": None, "targetLocation": None, "targetProcessId": None, } diff --git a/pyvolca/tests/test_search_results.py b/pyvolca/tests/test_search_results.py index 28a8ae5d..2d752e85 100644 --- a/pyvolca/tests/test_search_results.py +++ b/pyvolca/tests/test_search_results.py @@ -10,9 +10,9 @@ def _activity_dict(name: str) -> dict: return { "processId": name, - "name": name, + "activityName": name, "location": "FR", - "product": "p", + "productName": "p", "productAmount": 1.0, "productUnit": "kg", } @@ -43,8 +43,8 @@ def test_getitem_indexes_current_page(self): _page(["a", "b", "c"], offset=0, limit=3, total=3), parse=Activity.from_json, ) - assert sr[0].name == "a" - assert sr[2].name == "c" + assert sr[0].activity_name == "a" + assert sr[2].activity_name == "c" def test_page_size_mirrors_wire_limit(self): sr = SearchResults.from_raw( @@ -68,7 +68,7 @@ def test_getitem_supports_slice_on_current_page(self): ) sliced = sr[:2] assert isinstance(sliced, list) - assert [a.name for a in sliced] == ["a", "b"] + assert [a.activity_name for a in sliced] == ["a", "b"] def test_search_time_ms_preserved(self): sr = SearchResults.from_raw( @@ -85,7 +85,7 @@ def test_single_page_iteration(self): _page(["a", "b"], offset=0, limit=20, total=2), parse=Activity.from_json, ) - names = [a.name for a in sr] + names = [a.activity_name for a in sr] assert names == ["a", "b"] def test_lazy_multi_page_fetch(self): @@ -105,7 +105,7 @@ def fetch(offset: int, limit: int | None) -> dict: parse=Activity.from_json, fetch=fetch, ) - names = [a.name for a in sr] + names = [a.activity_name for a in sr] assert names == ["a", "b", "c", "d", "e"] # Two follow-up fetches: offsets 2 and 4. assert calls == [(2, 2), (4, 2)] @@ -155,8 +155,8 @@ def fetch(offset: int, limit: int | None) -> dict: parse=Activity.from_json, fetch=fetch, ) - first = [a.name for a in sr] - second = [a.name for a in sr] + first = [a.activity_name for a in sr] + second = [a.activity_name for a in sr] assert first == ["a", "b", "c", "d"] assert second == first # Exactly one follow-up fetch — the second iteration replays the cache. @@ -236,9 +236,9 @@ def test_consumers_iteration_walks_all_pages(self, mocked_client, make_response) def consumer_dict(pid: str) -> dict: return { "processId": pid, - "name": pid, + "activityName": pid, "location": "FR", - "product": "p", + "productName": "p", "productAmount": 1.0, "productUnit": "kg", "depth": 1, @@ -271,6 +271,6 @@ def envelope(items: list[str], offset: int, limit: int, total: int) -> dict: make_response(envelope(["c"], 2, 2, 3)), ] resp: ConsumersResponse = client.get_consumers("root_pid", page_size=2) - names = [c.name for c in resp.consumers] + names = [c.activity_name for c in resp.consumers] assert names == ["a", "b", "c"] assert len(resp.consumers) == 3 diff --git a/pyvolca/tests/test_typed_returns.py b/pyvolca/tests/test_typed_returns.py index 7d425f23..ca7753de 100644 --- a/pyvolca/tests/test_typed_returns.py +++ b/pyvolca/tests/test_typed_returns.py @@ -49,13 +49,13 @@ class TestSupplyChainHasMore: def _root(self) -> Activity: return Activity( - process_id="a", name="A", location="FR", - product="p", product_amount=1.0, product_unit="kg", + process_id="a", activity_name="A", location="FR", + product_name="p", product_amount=1.0, product_unit="kg", ) def _entry(self, name: str) -> SupplyChainEntry: return SupplyChainEntry( - process_id=f"{name}_pid", name=name, location="FR", + process_id=f"{name}_pid", activity_name=name, location="FR", quantity=1.0, unit="kg", scaling_factor=1.0, ) @@ -294,8 +294,8 @@ def test_from_json_unpacks_metadata_flows_statistics(self): result = InventoryResult.from_json({ "metadata": { "rootActivity": { - "processId": "p", "name": "n", "location": "FR", - "product": "p", "productAmount": 1.0, "productUnit": "kg", + "processId": "p", "activityName": "n", "location": "FR", + "productName": "p", "productAmount": 1.0, "productUnit": "kg", }, "totalFlows": 3, "emissionFlows": 2, "resourceFlows": 1, }, diff --git a/pyvolca/tests/test_waste_exchange.py b/pyvolca/tests/test_waste_exchange.py index 2a9aa9a7..fb1723e2 100644 --- a/pyvolca/tests/test_waste_exchange.py +++ b/pyvolca/tests/test_waste_exchange.py @@ -37,7 +37,7 @@ def _waste_ewu(*, is_input: bool, target: dict | None = None) -> dict: }, "flowName": "Organic carbon, placed in landfill", "unitName": "kg", - "targetActivity": (target or {}).get("name"), + "targetActivityName": (target or {}).get("activityName"), "targetLocation": (target or {}).get("location"), "targetProcessId": (target or {}).get("processId"), } @@ -70,20 +70,20 @@ def test_orphan_waste_output_maps_to_waste_variant(self): assert ex.amount == 2.5 assert ex.unit == "kg" assert ex.is_input is False - assert ex.target_activity is None + assert ex.target_activity_name is None assert ex.target_location is None assert ex.target_process_id is None def test_linked_waste_input_carries_treatment_target(self): target = { - "name": "Landfill of organic waste, FR", + "activityName": "Landfill of organic waste, FR", "location": "FR", "processId": "tttt0000-aaaa-bbbb-cccc-111122223333_pppp1111-eeee-ffff-aaaa-444455556666", } ex = parse_exchange(_waste_ewu(is_input=True, target=target)) assert isinstance(ex, WasteExchange) assert ex.is_input is True - assert ex.target_activity == "Landfill of organic waste, FR" + assert ex.target_activity_name == "Landfill of organic waste, FR" assert ex.target_location == "FR" assert ex.target_process_id == target["processId"] @@ -99,7 +99,7 @@ def test_other_variants_are_not_waste(self): tech = parse_exchange({ "exchange": {"tag": "TechnosphereExchange", "amount": 1.0, "role": "Input"}, "flowName": "wheat", "unitName": "kg", - "targetActivity": None, "targetLocation": None, "targetProcessId": None, + "targetActivityName": None, "targetLocation": None, "targetProcessId": None, }) bio = parse_exchange({ "exchange": {"tag": "BiosphereExchange", "amount": 1.0, "direction": "Emission"}, @@ -115,7 +115,7 @@ def test_is_reference_is_defined_on_every_variant(self): tech = parse_exchange({ "exchange": {"tag": "TechnosphereExchange", "amount": 1.0, "role": "Input"}, "flowName": "wheat", "unitName": "kg", - "targetActivity": None, "targetLocation": None, "targetProcessId": None, + "targetActivityName": None, "targetLocation": None, "targetProcessId": None, }) bio = parse_exchange({ "exchange": {"tag": "BiosphereExchange", "amount": 1.0, "direction": "Emission"}, @@ -134,18 +134,18 @@ def test_waste_kind_flow_envelope_parses(self): assert isinstance(ex, WasteExchange) assert ex.flow_name == "Organic carbon, placed in landfill" assert ex.is_input is False - assert ex.target_activity is None + assert ex.target_activity_name is None def test_waste_with_linked_treatment_target(self): target = { - "name": "Treatment, municipal solid waste, sanitary landfill", + "activityName": "Treatment, municipal solid waste, sanitary landfill", "location": "CH", "processId": "wwww1111-aaaa-bbbb-cccc-111122223333_qqqq2222-eeee-ffff-aaaa-444455556666", } ex = parse_exchange_detail(_waste_ed(is_input=True, target=target)) assert isinstance(ex, WasteExchange) assert ex.is_input is True - assert ex.target_activity == target["name"] + assert ex.target_activity_name == target["activityName"] assert ex.target_location == "CH" def test_mismatched_flow_kind_rejected(self): From cedcd28e7f4314744ab1d04f6b5a16ccbdf32e34 Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Sun, 21 Jun 2026 21:36:17 +0200 Subject: [PATCH 3/4] fix(mcp): keep the MCP tool surface snake_case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field rename pushed camelCase identity keys into two MCP outputs: get_contributing_activities (processId/activityName/productName/contributionPercent) and the score_activities columnar columns (activityName/processId). But the MCP surface is snake_case everywhere else (flow_name, contribution_percent, total_score, web_url, functional_unit), so a single get_contributing_activities response mixed snake and camel and disagreed with the sibling get_contributing_flows. camelCase is the REST wire convention (consumed by pyvolca and the web UI); the canonical field name is snake_case. Bring these two outputs back to snake_case — the columnar columns keep the clarity gain (name -> activity_name) without the casing flip. get_path_to stays camelCase: it is a REST endpoint. --- src/API/MCP.hs | 8 ++++---- src/API/MCP/Columnar.hs | 6 +++--- test/MCPColumnarSpec.hs | 16 ++++++++-------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/API/MCP.hs b/src/API/MCP.hs index 62c42f39..bba40f99 100644 --- a/src/API/MCP.hs +++ b/src/API/MCP.hs @@ -1464,12 +1464,12 @@ mkMcpCrossDBEntry dbManager rootDbName mBaseUrl colName methodIdText flowDB unit ) pure $ object $ - [ "processId" .= pidText - , "activityName" .= actName - , "productName" .= prodName + [ "process_id" .= pidText + , "activity_name" .= actName + , "product_name" .= prodName , "location" .= actLoc , "contribution" .= c - , "contributionPercent" .= (if score /= 0 then c / score * 100 else 0 :: Double) + , "contribution_percent" .= (if score /= 0 then c / score * 100 else 0 :: Double) ] ++ webUrlPair diff --git a/src/API/MCP/Columnar.hs b/src/API/MCP/Columnar.hs index 64124608..c2070b7f 100644 --- a/src/API/MCP/Columnar.hs +++ b/src/API/MCP/Columnar.hs @@ -92,7 +92,7 @@ the columnar JSON shape: { "scoring_set": "PEF" , "scoring_unit": "µPts PEF" , "functional_unit": "1.00 cubic meter of ..." -- only when all rows agree -, "columns": ["activityName", "processId", "web_url", "total", "acd", ...] +, "columns": ["activity_name", "process_id", "web_url", "total", "acd", ...] , "rows": [[...], [...], ...] , "not_found": [...] , "invalid": [...] @@ -151,8 +151,8 @@ toColumnarBatch summaryOnly mBaseUrl dbName coll ss bir = webUrlCol = ["web_url" | isJust mBaseUrl] fixedColumns :: [Text] fixedColumns - | isHeterogeneous = ["activityName", "processId"] ++ webUrlCol ++ ["functional_unit", "total"] - | otherwise = ["activityName", "processId"] ++ webUrlCol ++ ["total"] + | isHeterogeneous = ["activity_name", "process_id"] ++ webUrlCol ++ ["functional_unit", "total"] + | otherwise = ["activity_name", "process_id"] ++ webUrlCol ++ ["total"] columns :: [Text] columns | summaryOnly = fixedColumns ++ ["dominant_indicator"] diff --git a/test/MCPColumnarSpec.hs b/test/MCPColumnarSpec.hs index 60517121..801f9a96 100644 --- a/test/MCPColumnarSpec.hs +++ b/test/MCPColumnarSpec.hs @@ -147,8 +147,8 @@ spec = do `shouldBe` Just ( Array ( V.fromList - [ String "activityName" - , String "processId" + [ String "activity_name" + , String "process_id" , String "web_url" , String "total" , String "acd" @@ -202,8 +202,8 @@ spec = do `shouldBe` Just ( Array ( V.fromList - [ String "activityName" - , String "processId" + [ String "activity_name" + , String "process_id" , String "web_url" , String "functional_unit" , String "total" @@ -238,8 +238,8 @@ spec = do `shouldBe` Just ( Array ( V.fromList - [ String "activityName" - , String "processId" + [ String "activity_name" + , String "process_id" , String "web_url" , String "total" , String "dominant_indicator" @@ -272,8 +272,8 @@ spec = do `shouldBe` Just ( Array ( V.fromList - [ String "activityName" - , String "processId" + [ String "activity_name" + , String "process_id" , String "total" , String "acd" , String "cch" From 73d543d26337622d02a0bcfadb6b6cc9fe613c81 Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Sun, 21 Jun 2026 21:36:17 +0200 Subject: [PATCH 4/4] test(pyvolca): align stale root key in supply-chain fixtures The two get_supply_chain query-param tests kept a "name" root key after the field rename; the wire now emits "activityName". The tests only assert query params (they swallow the parse), so this was harmless but no longer matched the real shape. --- pyvolca/tests/test_dispatch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyvolca/tests/test_dispatch.py b/pyvolca/tests/test_dispatch.py index 9391086b..6a63ee38 100644 --- a/pyvolca/tests/test_dispatch.py +++ b/pyvolca/tests/test_dispatch.py @@ -207,7 +207,7 @@ def test_kebab_case_query_param_translation(self, mocked_client, make_response): """``min_quantity`` Python → ``min-quantity`` wire.""" client, session = mocked_client session.get.return_value = make_response({ - "root": {"processId": "x", "name": "y", "location": "FR", "productName": "p", "productAmount": 1.0, "productUnit": "kg"}, + "root": {"processId": "x", "activityName": "y", "location": "FR", "productName": "p", "productAmount": 1.0, "productUnit": "kg"}, "supplyChain": [], "edges": [], "totalActivities": 0, @@ -225,7 +225,7 @@ def test_supply_chain_forwards_preset(self, mocked_client, make_response): """preset= must reach the supply-chain endpoint as a query param.""" client, session = mocked_client session.get.return_value = make_response({ - "root": {"processId": "x", "name": "y", "location": "FR", "productName": "p", "productAmount": 1.0, "productUnit": "kg"}, + "root": {"processId": "x", "activityName": "y", "location": "FR", "productName": "p", "productAmount": 1.0, "productUnit": "kg"}, "supplyChain": [], "edges": [], "totalActivities": 0,