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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion pyvolca/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-------|------|---------|
Expand Down
5 changes: 4 additions & 1 deletion pyvolca/src/volca/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/API/MCP.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions src/API/MCP/Columnar.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
4 changes: 3 additions & 1 deletion src/API/Resources.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand Down
3 changes: 2 additions & 1 deletion src/API/Routes.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
)
Expand Down
7 changes: 7 additions & 0 deletions src/Config.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/Database/Manager.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/Method/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions test/ConfigSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
17 changes: 10 additions & 7 deletions test/MCPColumnarSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions test/ScoringLabelSpec.hs
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 2 additions & 0 deletions volca.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ test-suite lca-tests
, PropertiesSpec
, NativeActivityTypeSpec
, WasteCrossDBSpec
, ScoringLabelSpec
build-depends: base >=4.14 && <5
, volca
, hspec
Expand Down Expand Up @@ -336,6 +337,7 @@ test-suite lca-tests
, optparse-applicative
, QuickCheck
, network
, toml-reader
build-tool-depends: hspec-discover:hspec-discover

default-language: Haskell2010
Expand Down
Loading