Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

## [Unreleased]

### Added
- A characterization method declared in the TOML configuration can carry
`[[methods.patches]]` blocks: declarative adjustments that rescale or
replace the matched characterization factors every time the collection
loads. A patch selects factors by impact category, flow name or prefix,
CAS number, or subcompartment, and is re-applied to the freshly parsed
source file on each load — so reloading never compounds it. A patch that
matches no factor is reported at load time instead of being silently
ignored.
- Bulk impact scoring — the `POST /db/{db}/impacts/{collection}` endpoint and
the `score_activities` MCP tool — can now exclude long-term emissions, as
scoring a single activity already could.

### Changed
- Database exports download as raw bytes instead of base64-encoded JSON,
matching how uploads already work — a third less data on the wire and far
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,16 @@ path = "DBs/EF-v3.1.zip" # ILCD method package (ZIP or directory)
# their region-tagged CFs are dropped so the method's global (unlocated)
# CF is the single answer — for matching references that flattened
# spatial factors. A name matching no method logs a warning.
#
# Optional patches adjust matched characterization factors at load time.
# Selector fields combine with AND; at least one is required. Exactly one
# of `scale` (multiply) or `set-value` (replace) per patch. A patch that
# matches no factor logs a warning at load time.
# [[methods.patches]]
# description = "example: -40% on Uranium in Resource use, fossils"
# match = { category = "Resource use, fossils", flow-name-prefix = "Uranium" }
# # other selectors: flow-name (exact), cas, subcompartment-contains
# scale = 0.6

[[flow-synonyms]]
name = "Default flow synonyms"
Expand Down
7 changes: 5 additions & 2 deletions src/API/BatchImpacts.hs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Database.Manager (DatabaseManager (..))
import Method.Mapping (LongTermMode)
import Method.Mapping (LongTermMode (..))
import Servant (ServerError (..))
import qualified Servant

Expand Down Expand Up @@ -107,10 +107,12 @@ runBatchImpacts ::
Text ->
-- | per-(activity, method) top contributors (default 0 — see batchImpactsH)
Maybe Int ->
-- | whether to keep or drop delayed long-term emissions
LongTermMode ->
-- | process_ids to score
[Text] ->
IO (Either BatchError BatchImpactsResponse)
runBatchImpacts dbm dbName coll topFlows pids = do
runBatchImpacts dbm dbName coll topFlows ltMode pids = do
let env =
AppEnv
{ aeDbManager = dbm
Expand All @@ -126,6 +128,7 @@ runBatchImpacts dbm dbName coll topFlows pids = do
dbName
coll
topFlows
ltMode
(BatchImpactsRequest{birProcessIds = pids})
case res of
Right r -> pure (Right r)
Expand Down
1 change: 1 addition & 0 deletions src/API/DatabaseHandlers.hs
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,7 @@ uploadMethodHandler mName mDesc src =
, mcFormat = Just $ formatToText $ urFormat uploadResult
, mcScoringSets = []
, mcGlobalMethods = []
, mcPatches = []
}
liftIO $ addMethodCollection dbManager mc

Expand Down
3 changes: 2 additions & 1 deletion src/API/MCP.hs
Original file line number Diff line number Diff line change
Expand Up @@ -1892,9 +1892,10 @@ callScoreActivities dbManager mBaseUrl rid args =
pids <- except (parseArrayArg "process_ids" (Just "'process_ids' required (array of strings)") args :: Either Text [Text])
wantedSets <- except (parseArrayArg "scoring_sets" Nothing args :: Either Text [Text])
let summaryOnly = fromMaybe False (boolArg "summary_only" args)
ltMode = longTermModeFromExclude (fromMaybe False (boolArg "exclude_long_term" args))
configured <- liftIO $ configuredScoringSets dbManager coll
chosen <- except (resolveSingleScoringSet wantedSets configured)
res <- liftIO $ BI.runBatchImpacts dbManager dbName coll Nothing pids
res <- liftIO $ BI.runBatchImpacts dbManager dbName coll Nothing ltMode pids
case res of
Left e -> throwE (batchErrorMsg e)
Right bir -> pure (toolSuccessJson rid (toColumnarBatch summaryOnly mBaseUrl dbName coll chosen bir))
Expand Down
7 changes: 4 additions & 3 deletions src/API/Resources.hs
Original file line number Diff line number Diff line change
Expand Up @@ -491,9 +491,9 @@ pSubstitutions =
\When empty or absent, the call behaves as a plain GET."

{- | Optional switch to drop delayed long-term emissions before scoring.
Shared by 'get_impacts' and 'score_activity'. Long-term flows are always
emissions (never resources), so excluding them never touches regionalized
water/land categories.
Shared by 'get_impacts', 'score_activity' and 'score_activities'. Long-term
flows are always emissions (never resources), so excluding them never
touches regionalized water/land categories.
-}
pExcludeLongTerm :: Param
pExcludeLongTerm =
Expand Down Expand Up @@ -730,6 +730,7 @@ params r = case r of
, Param "process_ids" "array" Required "Process IDs to score (activityUUID_productUUID). All resolved in one multi-RHS solve."
, pScoringSetsFilter
, pSummaryOnly
, pExcludeLongTerm
]
ListScoringSets ->
[ Param "collection" "string" Optional "Method collection name. If omitted, returns scoring sets across all loaded collections, grouped by collection."
Expand Down
13 changes: 8 additions & 5 deletions src/API/Routes.hs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ type LCAAPI =
:<|> "db" :> Capture "dbName" Text :> "flows" :> QueryParam "q" Text :> QueryParam "lang" Text :> QueryParam "limit" Int :> QueryParam "offset" Int :> QueryParam "sort" Text :> QueryParam "order" Text :> Get '[JSON] (SearchResults FlowSearchResult)
:<|> "db" :> Capture "dbName" Text :> "activities" :> QueryParam "name" Text :> QueryParam "geo" Text :> QueryParam "product" Text :> QueryParam "exact" Bool :> QueryParam "preset" Text :> QueryParams "classification" Text :> QueryParams "classification-value" Text :> QueryParams "classification-mode" Text :> QueryParam "limit" Int :> QueryParam "offset" Int :> QueryParam "sort" Text :> QueryParam "order" Text :> Get '[JSON] (SearchResults ActivitySummary)
:<|> "db" :> Capture "dbName" Text :> "classifications" :> Get '[JSON] [ClassificationSystem]
:<|> "db" :> Capture "dbName" Text :> "impacts" :> Capture "collection" Text :> QueryParam "top-flows" Int :> ReqBody '[JSON] BatchImpactsRequest :> Post '[JSON] BatchImpactsResponse
:<|> "db" :> Capture "dbName" Text :> "impacts" :> Capture "collection" Text :> QueryParam "top-flows" Int :> QueryParam "exclude-long-term" Bool :> ReqBody '[JSON] BatchImpactsRequest :> Post '[JSON] BatchImpactsResponse
-- Database management endpoints
:<|> "db" :> Get '[JSON] DatabaseListResponse
-- Load/Unload/Delete endpoints
Expand Down Expand Up @@ -858,9 +858,10 @@ batchImpactsH ::
Text ->
Text ->
Maybe Int ->
LongTermMode ->
BatchImpactsRequest ->
AppM BatchImpactsResponse
batchImpactsH dbName collectionName topFlowsParam req = do
batchImpactsH dbName collectionName topFlowsParam ltMode req = do
dbManager <- asks aeDbManager
(db, sharedSolver) <- requireDatabaseByName dbName
loadedCollections <- liftIO $ readTVarIO (dmLoadedMethods dbManager)
Expand All @@ -876,7 +877,8 @@ batchImpactsH dbName collectionName topFlowsParam req = do
invalid = [pidText | (pidText, Left (Service.InvalidProcessId _)) <- resolved]
validPidNums = [pidNum | (_, pidNum, _) <- valid]
t0 <- liftIO getCurrentTime
sols <- solutionsWithDeps dbName db sharedSolver validPidNums
sols0 <- solutionsWithDeps dbName db sharedSolver validPidNums
sols <- liftIO $ mapM (applyLongTermToSolution dbManager ltMode) sols0
t1 <- liftIO getCurrentTime
ctxs <- liftIO $ mapConcurrently (prepMethodCtx dbManager dbName collectionName db) (mcMethods collection)
let topFlows = max 0 (fromMaybe 0 topFlowsParam)
Expand Down Expand Up @@ -1911,8 +1913,9 @@ getClassifications dbName = do
(db, _) <- requireDatabaseByName dbName
return $ Service.getClassifications db

postImpactsBatch :: Text -> Text -> Maybe Int -> BatchImpactsRequest -> AppM BatchImpactsResponse
postImpactsBatch = batchImpactsH
postImpactsBatch :: Text -> Text -> Maybe Int -> Maybe Bool -> BatchImpactsRequest -> AppM BatchImpactsResponse
postImpactsBatch dbName collectionName topFlowsParam mExcludeLT =
batchImpactsH dbName collectionName topFlowsParam (longTermModeFromExclude (fromMaybe False mExcludeLT))

-- ---------------------------------------------------------------------------
-- Servant server
Expand Down
1 change: 1 addition & 0 deletions src/CLI/Command.hs
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@ executeMcUpload fmt manager args = do
, mcFormat = Nothing
, mcScoringSets = []
, mcGlobalMethods = []
, mcPatches = []
}
addMethodCollection manager mc
reportProgress Info $ "Method uploaded: " ++ T.unpack slug
Expand Down
88 changes: 86 additions & 2 deletions src/Config.hs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ module Config (
DatabaseConfig (..),
MethodConfig (..),
ScoringSetConfig (..),
MethodPatch (..),
MethodPatchMatch (..),
CFPatchOp (..),
RefDataConfig (..),
HostingConfig (..),
ClassificationPreset (..),
Expand All @@ -33,7 +36,7 @@ import Control.Monad (forM_, unless, when)
import Data.List (isPrefixOf)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe)
import Data.Maybe (fromMaybe, isNothing)
import qualified Data.Set as S
import Data.Text (Text)
import qualified Data.Text as T
Expand All @@ -42,7 +45,7 @@ import GHC.Generics (Generic)
import System.Directory (doesFileExist)
import System.Environment (lookupEnv)
import System.FilePath (takeFileName)
import TOML (DecodeTOML (..), Decoder, decodeFile, getArrayOf, getField, getFieldOpt, getFieldOptWith)
import TOML (DecodeTOML (..), Decoder, decodeFile, getArrayOf, getField, getFieldOpt, getFieldOptWith, getFieldWith)
import Types (GeographyPolicy (..))

-- | A single classification filter entry (system + value)
Expand Down Expand Up @@ -135,6 +138,62 @@ data MethodConfig = MethodConfig
CFs are *all* region-tagged would be left with none. Empty = keep every
method's native regionalization.
-}
, mcPatches :: ![MethodPatch]
{- ^ Declarative adjustments applied to this collection's characterization
factors right after parsing, before the collection is registered. The
equivalent of a Brightway import "strategy", but data instead of code: a
pure, idempotent transform of the freshly parsed factors, re-derived from
the untouched source file on every reload rather than mutating a persisted
store. Empty = the collection is used exactly as parsed.
-}
}
deriving (Show, Eq, Generic)

{- | What a 'MethodPatch' does to a matched CF's value. A sum so a patch is
either a rescale or a hard override, never an ambiguous combination of both.
-}
data CFPatchOp
= -- | Multiply the matched CF's value (TOML: @scale = 0.6@).
ScaleBy !Double
| -- | Replace the matched CF's value outright (TOML: @set-value = 0.0@).
SetValueTo !Double
deriving (Show, Eq, Generic)

{- | Selector picking which characterization factors a 'MethodPatch' touches.
Every present field must match (conjunction); a selector with no field set
is rejected by the decoder — a patch that would touch every CF in every
method is almost certainly a mistake, not an intent.
-}
data MethodPatchMatch = MethodPatchMatch
{ mpmCategory :: !(Maybe Text)
{- ^ Impact category name (TOML: @category@), e.g. \"Resource use, fossils\".
For a SimaPro CSV method export this is the per-category 'Method.methodName'
(each \"Impact category\" section becomes its own 'Method' sharing the
collection's overall methodology name, not this one).
-}
, mpmFlowName :: !(Maybe Text)
-- ^ Exact flow name (TOML: @flow-name@), matched against 'Method.mcfFlowName'.
, mpmFlowNamePrefix :: !(Maybe Text)
-- ^ Flow name prefix (TOML: @flow-name-prefix@), matched with 'Data.Text.isPrefixOf'.
, mpmCAS :: !(Maybe Text)
{- ^ CAS registry number (TOML: @cas@), matched against 'Method.mcfCAS' after
normalizing both sides the same way (leading zeros in each dash-separated
segment are insignificant), so either the raw or the normalized form works.
-}
, mpmSubcompartmentContains :: !(Maybe Text)
{- ^ Case-insensitive substring of the subcompartment (TOML:
@subcompartment-contains@), matched against 'Method.mcfCompartment'. A CF
with no compartment never matches this field.
-}
}
deriving (Show, Eq, Generic)

-- | One declarative adjustment to a method collection's characterization factors.
data MethodPatch = MethodPatch
{ mpDescription :: !(Maybe Text)
-- ^ Free-text note on why this patch exists, surfaced in load logs.
, mpMatch :: !MethodPatchMatch
, mpOp :: !CFPatchOp
}
deriving (Show, Eq, Generic)

Expand Down Expand Up @@ -252,8 +311,33 @@ instance DecodeTOML MethodConfig where
let mcFormat = Nothing -- Detected later from file content
mcScoringSets <- fromMaybe [] <$> getFieldOpt "scoring"
mcGlobalMethods <- fromMaybe [] <$> getFieldOpt "global-methods"
mcPatches <- fromMaybe [] <$> getFieldOpt "patches"
pure MethodConfig{..}

instance DecodeTOML MethodPatchMatch where
tomlDecoder = do
mpmCategory <- getFieldOpt "category"
mpmFlowName <- getFieldOpt "flow-name"
mpmFlowNamePrefix <- getFieldOpt "flow-name-prefix"
mpmCAS <- getFieldOpt "cas"
mpmSubcompartmentContains <- getFieldOpt "subcompartment-contains"
when (all isNothing [mpmCategory, mpmFlowName, mpmFlowNamePrefix, mpmCAS, mpmSubcompartmentContains]) $
fail "match: at least one selector field must be set (a patch matching every CF is almost certainly a mistake)"
pure MethodPatchMatch{..}

instance DecodeTOML MethodPatch where
tomlDecoder = do
mpDescription <- getFieldOpt "description"
mpMatch <- getFieldWith tomlDecoder "match"
mScale <- getFieldOpt "scale"
mSetValue <- getFieldOpt "set-value"
mpOp <- case (mScale, mSetValue) of
(Just s, Nothing) -> pure (ScaleBy s)
(Nothing, Just v) -> pure (SetValueTo v)
(Nothing, Nothing) -> fail "patch: exactly one of 'scale' or 'set-value' is required, neither was set"
(Just _, Just _) -> fail "patch: exactly one of 'scale' or 'set-value' is required, both were set"
pure MethodPatch{..}

instance DecodeTOML ScoringSetConfig where
tomlDecoder = do
sscName <- getField "name"
Expand Down
39 changes: 34 additions & 5 deletions src/Database/Manager.hs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ import qualified Method.Parser
import qualified Method.Parser.OlcaSchema as OlcaSchema
import Method.ParserCSV (parseMethodCSVBytes, stripBOM)
import Method.ParserSimaPro (isSimaProMethodCSV, parseSimaProMethodCSVBytes)
import qualified Method.Patch
import qualified SimaPro.Parser as SimaPro
import SynonymDB.Extract (extractFromEcoSpold2, extractFromILCDFlows, synonymPairsToCSV)

Expand Down Expand Up @@ -1032,15 +1033,15 @@ initDatabaseManager config noCache configPath = do
result <- loadMethodCollectionFromConfig mc
case result of
Right (collection0, flowInfo) -> do
let scoringSets = map configToScoringSet (Config.mcScoringSets mc)
collection = collection0{Method.Types.mcScoringSets = scoringSets}
let (collection, patchStats) = applyMethodConfig mc collection0
atomically $ modifyTVar' loadedMethodsVar (M.insert (mcName mc) collection)
reportProgress Info $
" [OK] Loaded method: "
<> T.unpack (mcName mc)
<> " ("
<> show (length (mcMethods collection))
<> " impact categories)"
warnZeroTouchPatches (mcName mc) patchStats
-- Surface a 'global-methods' entry that matches no loaded method:
-- the de-regionalization is keyed by method name, so a typo or a
-- renamed method would otherwise be ignored in silence and the
Expand Down Expand Up @@ -1214,6 +1215,33 @@ configToScoringSet ssc =
, ssDisplayMultiplier = sscDisplayMultiplier ssc
}

{- | Fold a 'MethodConfig's post-parse adjustments into a freshly parsed
collection: inject the configured scoring sets, then apply the declarative
CF patches ('Config.mcPatches'). Pure — reapplying the same config to the
same source file always yields the same result, so a reload never
compounds a patch. Also returns, per patch, how many CFs it touched (for
the zero-touch warning at the call site).
-}
applyMethodConfig :: MethodConfig -> MethodCollection -> (MethodCollection, [(Config.MethodPatch, Int)])
applyMethodConfig mc collection0 =
let scoringSets = map configToScoringSet (Config.mcScoringSets mc)
withScoring = collection0{Method.Types.mcScoringSets = scoringSets}
in Method.Patch.applyMethodPatches (Config.mcPatches mc) withScoring

{- | Surface a patch that matched no characterization factor: the selector is
almost certainly wrong (a typo'd category or flow name), and staying silent
would leave the collection scoring as if the patch were never declared.
-}
warnZeroTouchPatches :: Text -> [(Config.MethodPatch, Int)] -> IO ()
warnZeroTouchPatches collName stats =
forM_ [p | (p, n) <- stats, n == 0] $ \patch ->
reportProgress Warning $
" [patch] collection "
<> T.unpack collName
<> ": \""
<> T.unpack (Method.Patch.describePatch patch)
<> "\" touched 0 characterization factors — check the selector."

-- | Convert a DatabaseFormat to display text for methods
methodFormatText :: DatabaseFormat -> Text
methodFormatText SimaProCSV = "SimaPro CSV"
Expand All @@ -1237,6 +1265,7 @@ discoverUploadedMethodConfigs = do
, mcFormat = Just $ methodFormatText (UploadedDB.umFormat meta)
, mcScoringSets = []
, mcGlobalMethods = []
, mcPatches = []
}

-- | Get a database by name
Expand Down Expand Up @@ -2998,9 +3027,8 @@ loadMethodCollection manager name = do
reportProgress Error $ " [FAIL] " <> T.unpack name <> ": " <> T.unpack err
return $ Left err
Right (collection0, flowInfo) -> do
-- Inject scoring sets from TOML config
let scoringSets = map configToScoringSet (Config.mcScoringSets mc)
collection = collection0{Method.Types.mcScoringSets = scoringSets}
-- Inject scoring sets from TOML config, apply declarative CF patches
let (collection, patchStats) = applyMethodConfig mc collection0
atomically $ modifyTVar' (dmLoadedMethods manager) (M.insert name collection)
clearMethodMappingCache manager
let methods = mcMethods collection
Expand All @@ -3013,6 +3041,7 @@ loadMethodCollection manager name = do
<> " impact categories, "
<> show totalCFs
<> " characterization factors)"
warnZeroTouchPatches name patchStats
-- Auto-extract synonyms from ILCD flow definitions
let pairs = extractFromILCDFlows flowInfo
autoCreateFlowSynonyms
Expand Down
1 change: 1 addition & 0 deletions src/Method/ParserSimaPro.hs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module Method.ParserSimaPro (
parseSimaProMethodCSV,
parseSimaProMethodCSVBytes,
isSimaProMethodCSV,
normalizeCAS,
) where

import qualified Data.ByteString as BS
Expand Down
Loading
Loading