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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
dist-newstyle
dist/
weeder.toml
.hie
*.o
*\.bin
Expand Down
13 changes: 13 additions & 0 deletions .hlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# HLint configuration for the VoLCA engine.
#
# Its presence activates the hlint step in CI, which otherwise skips when no config is
# found. Run locally with: hlint src/
#
# The hygiene pass either *fixes* a hint or *baselines* it here with a one-line reason.
# All default hints on src/ are fixed; the single baseline below is a deliberate
# exception where the suggested rewrite conflicts with a stronger code-safety rule.

# `cells !! 0` is guarded by `length cells > colIdx + 2`, so it never fails — but the
# suggested `head cells` is a partial function we ban project-wide. Keep the guarded
# indexing rather than reintroduce `head`.
- ignore: {name: "Use head", within: Method.ParserCSV}
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
## [Unreleased]

### Added
- A loaded database can be exported to any of the five supported formats —
SimaPro CSV, EcoSpold 1, EcoSpold 2, ILCD, and Brightway Excel — from both the
API and the CLI, so a database can be moved between tools or re-saved after edits.
- A loaded database can be edited in place: copy it under a new name, delete a
filtered selection of activities, or relink it against a dependency through a
name-to-name alias map.
- Activity records now carry separate `activity_name` and `product_name` fields,
instead of the old `name`/`product` that blurred an activity and its reference
product.
- A partial EcoSpold2 import (a handful of `.spold` files cut from a full
database) now becomes analyzable by loading its matching ecoinvent background
as a dependency: each input is linked to the exact background activity it
Expand All @@ -19,6 +28,27 @@
connect time to confirm they speak this engine's JSON format, so a version
mismatch fails with a clear message instead of a confusing decode error.

### Changed
- Far broader EF 3.1 impact coverage: flows are matched by CAS number across
naming schemes, a substance registry bridges nomenclatures, sub-compartments
fall back sensibly, energy-carrier flows are characterized from their energy
content, land use is regionalized with per-country factors, and a large synonym
set links ecoinvent and Agribalyse flows to the method. Many products that
previously scored short are now characterized.
- Large database uploads stream as raw bytes instead of base64-encoded JSON,
cutting memory use and time on big files.
- A supplier substitution applies across every consumer of the substituted
product, not only the process you queried.

### Fixed
- ecoinvent waste-treatment activities import and score correctly: the reference
flow stays in the technosphere (these activities are no longer dropped) and
cross-database waste keeps the correct sign.
- EcoSpold packages whose datasets live in a sub-directory now load.
- Requesting a well-formed but non-existent process returns a clear "activity not
found" instead of a confusing error.
- A missing reference CSV is reported as an error instead of crashing the load.

## [0.7.0] - 2026-05-29

A month of engine work: a third flow kind for waste, regionalized impact
Expand Down
3 changes: 2 additions & 1 deletion bench/Bench/Helpers.hs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import Data.Text (Text)

import Database (buildDatabaseWithMatrices)
import qualified Database.Loader as Loader
import Types (Database, sdbActivities, sdbBioFlows, sdbTechFlows, sdbUnits)
import Types (Database, sdbActivities, sdbBioFlows, sdbTechFlows, sdbUnits, sdbWasteFlows)
import qualified UnitConversion as UC

{- | Parse a fixture path and build the indexed 'Database' with matrices.
Expand All @@ -36,6 +36,7 @@ loadFullDatabase path = do
(sdbActivities sdb)
(sdbTechFlows sdb)
(sdbBioFlows sdb)
(sdbWasteFlows sdb)
(sdbUnits sdb)
case built of
Left err -> pure (Left ("buildDatabaseWithMatrices failed: " <> err))
Expand Down
8 changes: 4 additions & 4 deletions bench/Bench/Lcia.hs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ import Types (
Indexes (..),
ProductIndex (..),
Unit (..),
emptyCrossDBLinkingStats,
)
import qualified UnitConversion as UC

Expand Down Expand Up @@ -145,6 +144,7 @@ emptyDatabase =
, dbActivities = V.empty
, dbTechFlows = M.empty
, dbBioFlows = M.empty
, dbWasteFlows = M.empty
, dbUnits = M.empty
, dbIndexes = Indexes M.empty M.empty M.empty M.empty
, dbTechnosphereTriples = U.empty
Expand All @@ -155,7 +155,7 @@ emptyDatabase =
, dbBiosphereCount = 0
, dbCrossDBLinks = []
, dbDependsOn = []
, dbLinkingStats = emptyCrossDBLinkingStats
, dbLinkingStats = mempty
, dbSynonymDB = Nothing
, dbFlowsByName = M.empty
, dbFlowsByCAS = M.empty
Expand Down Expand Up @@ -197,7 +197,7 @@ buildFixture =
]

methods = [mkMethod m [] | m <- [1 .. nMethods]]
rawTables = [buildMethodTables M.empty (mappingsFor s) | s <- [1 .. nMethods]]
rawTables = [buildMethodTables M.empty M.empty (mappingsFor s) | s <- [1 .. nMethods]]
filledTables = map (fillBroadcastVector unitCfg unitDB flowDB) rawTables
setTables = buildMethodSetTables (zip methods filledTables)

Expand Down Expand Up @@ -338,7 +338,7 @@ registerReal = do
mappings <- mapMethodToFlows Builtin.defaultMappers db method
let unitDB = dbUnits db
flowDB = dbBioFlows db
tables0 = buildMethodTables M.empty mappings
tables0 = buildMethodTables M.empty M.empty mappings
!tables = fillBroadcastVector UC.defaultUnitConfig unitDB flowDB tables0
!nCFs = length (methodFactors method)
putStrLn "[bench] lcia.real.score_method: computing inventory for first product..."
Expand Down
4 changes: 3 additions & 1 deletion bench/Bench/Loader.hs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import qualified Data.Text as T
import qualified Database.CrossLinking as CL
import qualified Database.Loader as Loader
import qualified SynonymDB.Types as Syn
import Types (cdlTotalInputs, sdbActivities)
import Types (GeographyPolicy (..), cdlTotalInputs, sdbActivities)
import qualified UnitConversion as UC

import Bench.Json (BenchSpec (..), UnitOfWork (..))
Expand Down Expand Up @@ -132,6 +132,7 @@ registerCrossDbLinking = do
Syn.emptySynonymDB
UC.defaultUnitConfig
M.empty
GeoGlobal
fgPath
case probeRes of
Left err -> do
Expand Down Expand Up @@ -179,6 +180,7 @@ crossLinkBench bgIndex fgPath = nfIO $ do
Syn.emptySynonymDB
UC.defaultUnitConfig
M.empty
GeoGlobal
fgPath
case r of
Left err -> evaluate (T.length err)
Expand Down
4 changes: 2 additions & 2 deletions bench/Bench/Parsers.hs
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ registerSimaPro = do
Nothing -> pure []
Just path -> do
-- Parse once to learn N_actual; the bench measurement re-parses fresh.
(acts, _, _, _) <- SP.parseSimaProCSV UC.defaultUnitConfig path
(acts, _, _, _, _) <- SP.parseSimaProCSV UC.defaultUnitConfig path
let !n = length acts
pure
[ BenchSpec
Expand All @@ -192,7 +192,7 @@ registerSimaPro = do
, bsMetric = "seconds"
, bsFixture = J.Fixture{J.fSource = F.fixtureSourceLabel F.Agribalyse, J.fSlice = "whole file"}
, bsAction = nfIO $ do
(acts', _, _, _) <- SP.parseSimaProCSV UC.defaultUnitConfig path
(acts', _, _, _, _) <- SP.parseSimaProCSV UC.defaultUnitConfig path
evaluate (length acts')
}
]
Expand Down
7 changes: 2 additions & 5 deletions src/API/Auth.hs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,9 @@ authMiddleware expectedPassword app req respond =
isLoginEndpoint = requestMethod req == "POST" && path == "/api/v1/auth"
in -- Only protect /api/ routes; static files and SPA index are public
-- (so the browser can load the login page)
if not isApiRoute || isLoginEndpoint
if not isApiRoute || isLoginEndpoint || isAuthenticated expectedPassword req
then app req respond
else
if isAuthenticated expectedPassword req
then app req respond
else respond unauthorized
else respond unauthorized
where
unauthorized =
responseLBS
Expand Down
1 change: 0 additions & 1 deletion src/API/DatabaseHandlers.hs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeOperators #-}

{- |
Module : API.DatabaseHandlers
Expand Down
7 changes: 3 additions & 4 deletions src/API/MCP.hs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import qualified Service
import qualified Service.Aggregate as Agg
import SharedSolver (SharedSolver, computeInventoryMatrixWithDepsCached, crossDBProcessContributions)
import qualified SharedSolver
import Types (Activity (..), BioFlowDB, BiosphereFlow (..), Database (..), Indexes (..), ProcessId, UUID, UnitDB, activityLocation, activityName, bfCompartmentName, bfCompartmentSub, exchangeIsInput, getUnitNameForBioFlow, isTechnosphereExchange, processIdToText, unresolvedCount)
import Types (Activity (..), BiosphereFlow (..), Database (..), Indexes (..), ProcessId, UUID, UnitDB, activityLocation, activityName, bfCompartmentName, bfCompartmentSub, exchangeIsInput, getUnitNameForBioFlow, processIdToText, unresolvedCount)
import UnitConversion (defaultUnitConfig)

-- ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1429,13 +1429,12 @@ mkMcpCrossDBEntry ::
Text ->
-- | method UUID text
Text ->
BioFlowDB ->
UnitDB ->
-- | total score (for share %)
Double ->
((Text, ProcessId), Double) ->
IO Value
mkMcpCrossDBEntry dbManager rootDbName mBaseUrl colName methodIdText flowDB unitDB score ((depDbName, pid), c) = do
mkMcpCrossDBEntry dbManager rootDbName mBaseUrl colName methodIdText unitDB score ((depDbName, pid), c) = do
mLd <- getDatabase dbManager depDbName
let (actName, actLoc, prodName, pidText) = case mLd of
Just ld ->
Expand Down Expand Up @@ -1708,7 +1707,7 @@ callGetContributingActivities dbManager mBaseUrl rid args =
sorted = L.sortOn (\(_, c) -> negate (abs c)) (M.toList contributions)
top = take lim sorted
hasNeg = any (\(_, c) -> c < 0) top
rows <- liftIO $ mapM (mkMcpCrossDBEntry dbManager dbName mBaseUrl (lrCollection req) (lrMethodIdText req) mFlows mUnits score) top
rows <- liftIO $ mapM (mkMcpCrossDBEntry dbManager dbName mBaseUrl (lrCollection req) (lrMethodIdText req) mUnits score) top
pure $
toolSuccessJson rid $
object
Expand Down
21 changes: 16 additions & 5 deletions src/API/OpenApi.hs
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,22 @@ entry in 'API.Resources'. The actual spec derivation from 'LCAAPI'
lives in 'API.Routes' to break an otherwise-circular dependency
(API.Routes -> API.OpenApi -> API.Routes).
-}
module API.OpenApi (enrichWithResources) where
module API.OpenApi (enrichWithResources, stampInfo) where

import API.JsonOptions (strippedSchemaOptions)
import API.Resources (Resource)
import qualified API.Resources as R
import API.Types
import Control.Lens ((%~), (&), (?~), (^.))
import Data.Aeson (Value, toJSON)
import Control.Lens ((%~), (&), (.~), (?~), (^.))
import Data.Aeson (Value)
import qualified Data.HashMap.Strict.InsOrd as InsOrdHashMap
import Data.OpenApi
import qualified Data.OpenApi.Lens as OA
import Data.Text (Text)
import qualified Data.Text as T
import Database.Manager (DatabaseSetupInfo, DependencyChoice, DependencyStatus, MissingSupplier)
import Database.Manager (DatabaseSetupInfo)
import Network.HTTP.Types.Method (StdMethod (..))
import Types (LocationFallback, LocationKind, LocationUnresolved)
import qualified Version

{- | Orphan schema instance forward declaration for the login request body.
The real type lives in "API.Routes"; this is defined there and re-imported
Expand Down Expand Up @@ -75,6 +75,17 @@ instance ToSchema BinaryContent where
NamedSchema (Just "OctetStream") $
mempty & type_ ?~ OpenApiString & format ?~ "binary"

{- | Stamp the spec's @info@ block so each generated @openapi.json@ self-identifies:
@info.version@ = the engine version, @info.title@ = the engine name. servant-openapi3
leaves both blank, which makes a published spec un-anchorable; stamping them lets a
release's surface be diffed against the previous one.
-}
stampInfo :: OpenApi -> OpenApi
stampInfo spec =
spec
& OA.info . OA.title .~ "VoLCA"
& OA.info . OA.version .~ T.pack Version.version

{- | Walk the spec and stamp metadata from 'API.Resources' onto each
resource-backed operation. Operations without a matching 'Resource'
(e.g. infrastructure endpoints like @/auth@, @/logs@, @/version@) are
Expand Down
2 changes: 1 addition & 1 deletion src/API/Routes.hs
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ Built in two steps:
on @operationId@ (e.g. @"get_impacts"@).
-}
volcaOpenApi :: OpenApi
volcaOpenApi = API.OpenApi.enrichWithResources (toOpenApi (Proxy :: Proxy LCAAPI))
volcaOpenApi = API.OpenApi.stampInfo (API.OpenApi.enrichWithResources (toOpenApi (Proxy :: Proxy LCAAPI)))

{- | Expand a named classification preset from config into the
(system, value, exact) triples used by the Service/Aggregate layers.
Expand Down
11 changes: 6 additions & 5 deletions src/CLI/Client.hs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import qualified Data.ByteString.Base64 as B64
import qualified Data.ByteString.Char8 as C8
import qualified Data.ByteString.Lazy as BL
import qualified Data.ByteString.Lazy.Char8 as BSL
import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
import Data.List (intercalate, transpose)
import Data.Maybe (catMaybes, fromMaybe, mapMaybe)
import Data.Text (Text)
Expand Down Expand Up @@ -237,7 +238,7 @@ extractLoadedDbNames = fromMaybe [] . parseMaybe go
go :: Value -> Parser [Text]
go = withObject "resp" $ \obj -> do
dbs <- obj .: "dlrDatabases"
fmap catMaybes $ mapM getName dbs
catMaybes <$> mapM getName dbs
getName :: Value -> Parser (Maybe Text)
getName = withObject "db" $ \db -> do
status <- db .: "dsaStatus"
Expand Down Expand Up @@ -310,9 +311,9 @@ buildQuery params =
where
urlEncode = concatMap encodeChar
encodeChar c
| c >= 'A' && c <= 'Z' = [c]
| c >= 'a' && c <= 'z' = [c]
| c >= '0' && c <= '9' = [c]
| isAsciiUpper c = [c]
| isAsciiLower c = [c]
| isDigit c = [c]
| c `elem` ("-_.~" :: String) = [c]
| otherwise = '%' : showHex2 (fromEnum c)
showHex2 n = [hexDigit (n `div` 16), hexDigit (n `mod` 16)]
Expand Down Expand Up @@ -427,7 +428,7 @@ extractTable :: [Value] -> ([String], [[String]])
extractTable [] = ([], [])
extractTable rows@(Object first : _) =
let keys = map fst (KM.toList first)
headers = map (Key.toString) keys
headers = map Key.toString keys
dataRows = map (rowValues keys) rows
in (headers, dataRows)
extractTable rows = (["value"], map (\v -> [cellValue v]) rows)
Expand Down
21 changes: 9 additions & 12 deletions src/CLI/Command.hs
Original file line number Diff line number Diff line change
Expand Up @@ -314,10 +314,10 @@ executeExportMatricesCommand registry database outputDir = do
ehExport exporter ctx outputDir
Nothing -> Service.exportUniversalMatrixFormat outputDir database
reportProgress Info "Matrix export completed"
reportProgress Info $ " - ie_index.csv (activity index)"
reportProgress Info $ " - ee_index.csv (biosphere flow index)"
reportProgress Info $ " - A_public.csv (technosphere matrix)"
reportProgress Info $ " - B_public.csv (biosphere matrix)"
reportProgress Info " - ie_index.csv (activity index)"
reportProgress Info " - ee_index.csv (biosphere flow index)"
reportProgress Info " - A_public.csv (technosphere matrix)"
reportProgress Info " - B_public.csv (biosphere matrix)"

-- | Execute plugin list command
executePluginList :: PluginRegistry -> OutputFormat -> IO ()
Expand All @@ -338,14 +338,11 @@ executePluginList registry fmt = do
where
pluginEntry name typ backend mPriority =
object $
concat
[
[ "name" .= name
, "type" .= (typ :: T.Text)
, "backend" .= backendText backend
]
, maybe [] (\p -> ["priority" .= p]) mPriority
]
[ "name" .= name
, "type" .= (typ :: T.Text)
, "backend" .= backendText backend
]
++ maybe [] (\p -> ["priority" .= p]) mPriority
backendText Builtin = "builtin" :: T.Text
backendText (External p) = "external:" <> T.pack p

Expand Down
Loading
Loading