diff --git a/CHANGELOG.md b/CHANGELOG.md index 94a391f1..3e4e8472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [Unreleased] + +### Added +- Scoring-set breakdowns can now show a human-readable name for computed + indicators (for example "Ecotoxicity, freshwater" instead of the raw key + `etf`), via an optional `[methods.scoring.labels]` table in the scoring + configuration. The dominant indicator of batch scoring summaries carries + the same display name. A label naming an unknown scoring variable is + rejected when the configuration loads instead of being silently ignored. + ## [0.8.1] - 2026-06-24 ### Fixed diff --git a/pyvolca/README.md b/pyvolca/README.md index 315b7810..756c10d1 100644 --- a/pyvolca/README.md +++ b/pyvolca/README.md @@ -1367,7 +1367,10 @@ One per-variable entry inside ``LCIABatchResult.scoring_indicators``. ``value`` is pre-multiplied by the scoring set's ``displayMultiplier`` (configured in the scoring TOML) and expressed in the set's display unit. -``category`` names the impact category the variable was resolved from. +``category`` is the indicator's display name: the scoring set's +``labels`` entry when one is configured (typically for computed +variables), otherwise the impact category the variable was resolved +from, or as a last resort the raw variable key. | Field | Type | Default | |-------|------|---------| diff --git a/pyvolca/src/volca/types.py b/pyvolca/src/volca/types.py index 72cdb4bb..cea8eb5d 100644 --- a/pyvolca/src/volca/types.py +++ b/pyvolca/src/volca/types.py @@ -268,7 +268,10 @@ class ScoringIndicator(FromJson): ``value`` is pre-multiplied by the scoring set's ``displayMultiplier`` (configured in the scoring TOML) and expressed in the set's display unit. - ``category`` names the impact category the variable was resolved from. + ``category`` is the indicator's display name: the scoring set's + ``labels`` entry when one is configured (typically for computed + variables), otherwise the impact category the variable was resolved + from, or as a last resort the raw variable key. """ category: str diff --git a/src/API/MCP.hs b/src/API/MCP.hs index e795832a..deb5cb3e 100644 --- a/src/API/MCP.hs +++ b/src/API/MCP.hs @@ -1942,6 +1942,7 @@ callListScoringSets dbManager rid args = do , "unit" .= ssUnit ss , "variables" .= ssVariables ss , "computed" .= ssComputed ss + , "labels" .= ssLabels ss , "normalization" .= ssNormalization ss , "weighting" .= ssWeighting ss , "scores" .= ssScores ss diff --git a/src/API/MCP/Columnar.hs b/src/API/MCP/Columnar.hs index c2070b7f..8a5f6e53 100644 --- a/src/API/MCP/Columnar.hs +++ b/src/API/MCP/Columnar.hs @@ -185,9 +185,10 @@ toColumnarBatch summaryOnly mBaseUrl dbName coll ss bir = ++ [total] ++ tailCells -{- | Format the dominant indicator of a row as a @{key, share_pct}@ -object. Returns 'Null' when the row has no total, the total is zero -(share is undefined), or the indicator map is empty. +{- | Format the dominant indicator of a row as a @{key, label, share_pct}@ +object — @label@ is the indicator's display name ('siCategory'), so clients +never have to show the raw variable key. Returns 'Null' when the row has no +total, the total is zero (share is undefined), or the indicator map is empty. -} dominantIndicatorCell :: Maybe Double -> M.Map Text ScoringIndicator -> Value dominantIndicatorCell mTotal indMap @@ -199,5 +200,5 @@ dominantIndicatorCell mTotal indMap (comparing (abs . siValue . snd)) (M.toList indMap) share = abs (siValue ind) / abs t * 100 - in object ["key" .= k, "share_pct" .= share] + in object ["key" .= k, "label" .= siCategory ind, "share_pct" .= share] | otherwise = Null diff --git a/src/API/Resources.hs b/src/API/Resources.hs index 614057ca..1119949b 100644 --- a/src/API/Resources.hs +++ b/src/API/Resources.hs @@ -427,7 +427,9 @@ description r = case r of \values (e.g. an overall single score plus per-area-of-protection \ \sub-scores). For each set returns: name, display unit, variables \ \referenced (with the impact category each binds to), computed \ - \intermediates, normalization and weighting factors, and the score \ + \intermediates, display labels (variable → human-readable indicator \ + \name shown in score breakdowns), normalization and weighting \ + \factors, and the score \ \formulas. Use the returned set names as keys when interpreting \ \score_activity / score_activities responses." diff --git a/src/API/Routes.hs b/src/API/Routes.hs index 9bf2e98d..769b5856 100644 --- a/src/API/Routes.hs +++ b/src/API/Routes.hs @@ -2060,10 +2060,11 @@ computeAllScoringSets scoringSets rawScoreMap = do -- `scores.*` formula) are hidden from the breakdown. toIndicators ss e = let displayed = S.fromList (concatMap (Expr.collectIdentifiers '.') (M.elems (ssScores ss))) + names = ssLabels ss <> ssVariables ss in M.mapWithKey ( \var val -> ScoringIndicator - { siCategory = M.findWithDefault var var (ssVariables ss) + { siCategory = M.findWithDefault var var names , siValue = val } ) diff --git a/src/Config.hs b/src/Config.hs index cd420c7a..f96e8cbe 100644 --- a/src/Config.hs +++ b/src/Config.hs @@ -146,6 +146,7 @@ data ScoringSetConfig = ScoringSetConfig , sscUnit :: !Text -- Display unit (e.g., "Pts") , sscVariables :: !(M.Map Text Text) -- var → impact category name , sscComputed :: !(M.Map Text Text) -- var → formula string + , sscLabels :: !(M.Map Text Text) -- var → display label (for computed vars) , sscNormalization :: !(M.Map Text Double) -- var → normalization factor , sscWeighting :: !(M.Map Text Double) -- var → weight , sscScores :: !(M.Map Text Text) -- score name → formula @@ -263,10 +264,16 @@ instance DecodeTOML ScoringSetConfig where sscUnit <- fromMaybe "Pt" <$> getFieldOpt "unit" sscVariables <- fromMaybe M.empty <$> getFieldOpt "variables" sscComputed <- fromMaybe M.empty <$> getFieldOpt "computed" + sscLabels <- fromMaybe M.empty <$> getFieldOpt "labels" sscNormalization <- fromMaybe M.empty <$> getFieldOpt "normalization" sscWeighting <- fromMaybe M.empty <$> getFieldOpt "weighting" sscScores <- fromMaybe M.empty <$> getFieldOpt "scores" sscDisplayMultiplier <- getFieldOpt "displayMultiplier" + let orphanLabels = M.keysSet sscLabels S.\\ (M.keysSet sscComputed <> M.keysSet sscVariables) + unless (S.null orphanLabels) $ + fail $ + "labels: unknown scoring variable(s): " + <> T.unpack (T.intercalate ", " (S.toList orphanLabels)) pure ScoringSetConfig{..} instance DecodeTOML RefDataConfig where diff --git a/src/Database/Manager.hs b/src/Database/Manager.hs index 6806a160..fb89a8c3 100644 --- a/src/Database/Manager.hs +++ b/src/Database/Manager.hs @@ -1211,6 +1211,7 @@ configToScoringSet ssc = , ssUnit = sscUnit ssc , ssVariables = sscVariables ssc , ssComputed = sscComputed ssc + , ssLabels = sscLabels ssc , ssNormalization = sscNormalization ssc , ssWeighting = sscWeighting ssc , ssScores = sscScores ssc diff --git a/src/Method/Types.hs b/src/Method/Types.hs index a13955cf..da205c19 100644 --- a/src/Method/Types.hs +++ b/src/Method/Types.hs @@ -201,6 +201,12 @@ data ScoringSet = ScoringSet -- ^ var → impact category name , ssComputed :: !(M.Map Text Text) -- ^ var → formula (e.g., "2 * etfo + etfi") + , ssLabels :: !(M.Map Text Text) + {- ^ var → display label, overriding the breakdown name. Needed for + computed variables, whose raw key would otherwise leak; on a primitive + variable it deliberately overrides the 'ssVariables' category name. + Keys are validated against declared variables when the config decodes. + -} , ssNormalization :: !(M.Map Text Double) -- ^ var → normalization factor (divisor) , ssWeighting :: !(M.Map Text Double) diff --git a/test/ConfigSpec.hs b/test/ConfigSpec.hs index 7ff44cec..470c65b8 100644 --- a/test/ConfigSpec.hs +++ b/test/ConfigSpec.hs @@ -6,10 +6,13 @@ import Config ( Config (..), MethodConfig (..), RefDataConfig (..), + ScoringSetConfig (..), applyDataDir, defaultConfig, redirectIntoDataDir, ) +import qualified Data.Map.Strict as M +import Data.Text (Text) import qualified TOML import Test.Hspec @@ -75,3 +78,38 @@ spec = do it "is a no-op when the env var is unset" $ applyDataDir Nothing cfg `shouldBe` cfg + + describe "ScoringSetConfig labels" $ do + let decodeSet :: Text -> Either TOML.TOMLError ScoringSetConfig + decodeSet = TOML.decode + + it "accepts a label on a computed variable" $ do + let toml = + "name = \"ECS\"\n\ + \[computed]\n\ + \etf = \"2 * etfo + etfi\"\n\ + \[labels]\n\ + \etf = \"Ecotoxicity, freshwater\"\n" + fmap sscLabels (decodeSet toml) + `shouldBe` Right (M.singleton "etf" "Ecotoxicity, freshwater") + + it "accepts a label on a primitive variable" $ do + let toml = + "name = \"ECS\"\n\ + \[variables]\n\ + \cch = \"Climate change\"\n\ + \[labels]\n\ + \cch = \"Changement climatique\"\n" + fmap sscLabels (decodeSet toml) + `shouldBe` Right (M.singleton "cch" "Changement climatique") + + it "rejects a label whose key matches no scoring variable" $ do + let toml = + "name = \"ECS\"\n\ + \[computed]\n\ + \etf = \"2 * etfo + etfi\"\n\ + \[labels]\n\ + \eft = \"Ecotoxicity, freshwater\"\n" + case decodeSet toml of + Right _ -> expectationFailure "orphan label key must be rejected" + Left err -> show err `shouldContain` "eft" diff --git a/test/MCPColumnarSpec.hs b/test/MCPColumnarSpec.hs index 801f9a96..754fab90 100644 --- a/test/MCPColumnarSpec.hs +++ b/test/MCPColumnarSpec.hs @@ -41,6 +41,7 @@ pefSet = , ssUnit = "µPts PEF" , ssVariables = M.fromList [("acd", "Acidification"), ("cch", "Climate change")] , ssComputed = M.empty + , ssLabels = M.empty , ssNormalization = M.empty , ssWeighting = M.empty , ssScores = M.fromList [("total", "acd + cch")] @@ -247,12 +248,12 @@ spec = do ) ) - it "fills the cell with a {key, share_pct} object, not a delimited string" $ do + it "fills the cell with a {key, label, share_pct} object, not a delimited string" $ do case KM.lookup (fromText "rows") km of Just (Array rs) -> case V.toList rs of [Array a] -> last (V.toList a) - `shouldBe` object ["key" .= ("cch" :: Text), "share_pct" .= (80.0 :: Double)] + `shouldBe` object ["key" .= ("cch" :: Text), "label" .= ("cch" :: Text), "share_pct" .= (80.0 :: Double)] other -> expectationFailure ("expected one row, got " <> show other) other -> expectationFailure ("expected rows array, got " <> show other) @@ -308,17 +309,19 @@ spec = do KM.lookup (fromText "rows") km `shouldBe` Just (Array (V.fromList [])) describe "dominantIndicatorCell" $ do - let inds = M.fromList [("a", ScoringIndicator "a" 1.0), ("b", ScoringIndicator "b" 9.0)] + -- The indicator's siCategory is its display name — it rides along as + -- 'label' so clients never have to show the raw variable key. + let inds = M.fromList [("a", ScoringIndicator "a" 1.0), ("b", ScoringIndicator "Big impact" 9.0)] - it "picks the indicator with the largest absolute share" $ + it "picks the indicator with the largest absolute share, labelled with its display name" $ dominantIndicatorCell (Just 10.0) inds - `shouldBe` object ["key" .= ("b" :: Text), "share_pct" .= (90.0 :: Double)] + `shouldBe` object ["key" .= ("b" :: Text), "label" .= ("Big impact" :: Text), "share_pct" .= (90.0 :: Double)] it "uses absolute value so negative contributors can win" $ dominantIndicatorCell (Just 10.0) - (M.fromList [("a", ScoringIndicator "a" 1.0), ("b", ScoringIndicator "b" (-9.0))]) - `shouldBe` object ["key" .= ("b" :: Text), "share_pct" .= (90.0 :: Double)] + (M.fromList [("a", ScoringIndicator "a" 1.0), ("b", ScoringIndicator "Big impact" (-9.0))]) + `shouldBe` object ["key" .= ("b" :: Text), "label" .= ("Big impact" :: Text), "share_pct" .= (90.0 :: Double)] it "returns Null when the total is zero (share undefined)" $ dominantIndicatorCell (Just 0.0) inds `shouldBe` Null diff --git a/test/ScoringLabelSpec.hs b/test/ScoringLabelSpec.hs new file mode 100644 index 00000000..2fd28c06 --- /dev/null +++ b/test/ScoringLabelSpec.hs @@ -0,0 +1,58 @@ +{-# LANGUAGE OverloadedStrings #-} + +{- | Display names for scoring-set breakdown indicators: primitive variables +take their impact-category name, computed variables take their entry in +@labels@, and only as a last resort does the raw variable key leak out. +-} +module ScoringLabelSpec (spec) where + +import API.Routes (computeAllScoringSets) +import API.Types (ScoringIndicator (..)) +import qualified Data.Map.Strict as M +import Data.Text (Text) +import Method.Types (ScoringSet (..)) +import Test.Hspec + +scoringSet :: ScoringSet +scoringSet = + ScoringSet + { ssName = "SingleScore" + , ssUnit = "Pts" + , ssVariables = + M.fromList + [ ("cch", "Climate change") + , ("etfo", "Ecotoxicity, freshwater_organics") + , ("etfi", "Ecotoxicity, freshwater_inorganics") + ] + , ssComputed = M.fromList [("etf", "2 * etfo + etfi")] + , ssLabels = M.fromList [("etf", "Ecotoxicity, freshwater")] + , ssNormalization = M.empty + , ssWeighting = M.fromList [("cch", 1.0), ("etf", 1.0)] + , ssScores = M.fromList [("total", "cch + etf")] + , ssDisplayMultiplier = Nothing + } + +rawScores :: M.Map Text Double +rawScores = + M.fromList + [ ("Climate change", 10.0) + , ("Ecotoxicity, freshwater_organics", 2.0) + , ("Ecotoxicity, freshwater_inorganics", 3.0) + ] + +-- | Display name of one breakdown indicator, straight from the scoring pass. +categoryOf :: ScoringSet -> Text -> IO (Maybe Text) +categoryOf ss var = do + (_, indicators) <- computeAllScoringSets [ss] rawScores + pure (siCategory <$> (M.lookup (ssName ss) indicators >>= M.lookup var)) + +spec :: Spec +spec = describe "scoring indicator display names" $ do + it "labels a computed variable via [methods.scoring.labels]" $ + categoryOf scoringSet "etf" >>= (`shouldBe` Just "Ecotoxicity, freshwater") + + it "keeps impact-category names for primitive variables" $ + categoryOf scoringSet "cch" >>= (`shouldBe` Just "Climate change") + + it "falls back to the raw key for a computed variable with no label" $ + categoryOf scoringSet{ssLabels = M.empty} "etf" >>= (`shouldBe` Just "etf") diff --git a/volca.cabal b/volca.cabal index 3f8500b1..6b7eaae5 100644 --- a/volca.cabal +++ b/volca.cabal @@ -307,6 +307,7 @@ test-suite lca-tests , PropertiesSpec , NativeActivityTypeSpec , WasteCrossDBSpec + , ScoringLabelSpec build-depends: base >=4.14 && <5 , volca , hspec @@ -336,6 +337,7 @@ test-suite lca-tests , optparse-applicative , QuickCheck , network + , toml-reader build-tool-depends: hspec-discover:hspec-discover default-language: Haskell2010