Merge pull request #10 from bjoernsteinhagen/bjorn/gro-116-write-ec-as-properties-on-objects

feat: write ec as properties on objects
This commit is contained in:
Björn Steinhagen
2025-02-25 15:40:07 +01:00
committed by GitHub
8 changed files with 409 additions and 211 deletions
+151 -20
View File
@@ -131,23 +131,24 @@ class RevitCarbonAnalyzer:
def __init__(
self,
steel_database: str,
timber_database: str,
concrete_database: str,
country: str,
reinforcement_rates: Dict[str, float],
material_processor: MaterialProcessor,
element_processor: ElementProcessor,
carbon_calculator: CarbonCalculator,
logger: Logging,
):
self.material_processor = MaterialProcessor()
self.element_processor = ElementProcessor(
material_processor=self.material_processor, logger=Logging()
)
self.carbon_calculator = CarbonCalculator(
steel_database=steel_database,
timber_database=timber_database,
concrete_database=concrete_database,
country=country,
custom_reinforcement_rates=reinforcement_rates,
)
"""
Initialize with injected dependencies.
Args:
material_processor: Service for processing raw materials
element_processor: Service for processing Revit elements
carbon_calculator: Service for calculating carbon emissions
logger: Logging service
"""
self.material_processor = material_processor
self.element_processor = element_processor
self.carbon_calculator = carbon_calculator
self.logger = logger
def analyze_model(self, model_root) -> dict:
"""Analyze a Revit model for carbon emissions."""
@@ -223,6 +224,119 @@ class RevitCarbonAnalyzer:
# Calculate carbon
try:
carbon_results = self.carbon_calculator.calculate_carbon(processed_element)
# Initialize Embodied Carbon Calculation dictionary
embodied_carbon_data = {}
for material_name, result in carbon_results.items():
# Create a dictionary for each material instead of an array
material_data = {}
if result.category == "Wood":
# For timber - use name/value/units format as dictionary entries
material_data = {
"volume": {
"name": "volume",
"value": result.quantity,
"units": "",
},
"database": {
"name": "database",
"value": result.database,
"units": None,
},
"ecf": {
"name": "ecf",
"value": result.factor,
"units": "kgCO₂e/m³",
},
"embodied carbon": {
"name": "embodied carbon",
"value": result.total_carbon,
"units": "kgCO₂e",
},
}
elif result.category == "Concrete":
# For concrete (include both concrete and reinforcement)
material_data = {
"volume": {
"name": "volume",
"value": result.concrete_volume,
"units": "",
},
"database": {
"name": "database",
"value": result.database,
"units": None,
},
"ecf": {
"name": "ecf",
"value": result.factor,
"units": "kgCO₂e/m³",
},
"concrete carbon": {
"name": "concrete carbon",
"value": result.concrete_carbon,
"units": "kgCO₂e",
},
"reinforcement mass": {
"name": "reinforcement mass",
"value": result.reinforcement_mass,
"units": "kg",
},
"reinforcement rate": {
"name": "reinforcement rate",
"value": result.reinforcement_rate,
"units": "kg/m³",
},
"reinforcement ecf": {
"name": "reinforcement ecf",
"value": result.reinforcement_factor,
"units": "kgCO₂e/kg",
},
"reinforcement carbon": {
"name": "reinforcement carbon",
"value": result.reinforcement_carbon,
"units": "kgCO₂e",
},
"embodied carbon": {
"name": "embodied carbon",
"value": result.total_carbon,
"units": "kgCO₂e",
},
}
elif result.category == "Metal":
# For metal
material_data = {
"mass": {
"name": "mass",
"value": result.quantity,
"units": "kg",
},
"database": {
"name": "database",
"value": result.database,
"units": None,
},
"ecf": {
"name": "ecf",
"value": result.factor,
"units": "kgCO₂e/kg",
},
"embodied carbon": {
"name": "embodied carbon",
"value": result.total_carbon,
"units": "kgCO₂e",
},
}
# Add this material's data to the main dictionary
embodied_carbon_data[material_name] = material_data
# Attach the data to the original element
if hasattr(element, "properties"):
element.properties["Embodied Carbon Calculation"] = embodied_carbon_data
return {
"id": element_id,
"status": "processed",
@@ -233,7 +347,6 @@ class RevitCarbonAnalyzer:
"name": m.properties.name,
"type": m.type.value,
"volume": m.properties.volume,
# Add other material properties as needed
}
for m in processed_element.materials
],
@@ -292,13 +405,26 @@ def automate_function(
"Topping Slabs": function_inputs.reinforcement_topping_slab,
}
# Initialize analyzer
analyzer = RevitCarbonAnalyzer(
# Create dependencies with proper DI
logger = Logging()
material_processor = MaterialProcessor()
element_processor = ElementProcessor(
material_processor=material_processor, logger=logger
)
carbon_calculator = CarbonCalculator(
steel_database=steel_db,
timber_database=timber_db,
concrete_database=concrete_db,
country=country,
reinforcement_rates=custom_reinforcement_rates,
custom_reinforcement_rates=custom_reinforcement_rates,
)
# Initialize analyzer with injected dependencies
analyzer = RevitCarbonAnalyzer(
material_processor=material_processor,
element_processor=element_processor,
carbon_calculator=carbon_calculator,
logger=logger,
)
# Get commit root
@@ -357,6 +483,11 @@ def automate_function(
"\nNOTE: All materials successfully matched with emission factors."
)
# Upload mutated model
automate_context.create_new_version_in_project(
model_root, f"{commit_root.branchName}_embodied_carbon"
)
# Mark success with detailed message
automate_context.mark_run_success(success_message)
@@ -12,9 +12,9 @@ class ConcreteEmissionDatabase(EmissionFactorDatabase):
super().__init__()
self._database_name = database_name
self._factors = {}
self._init_factors()
self._load_emission_factors_from_database()
def _init_factors(self):
def _load_emission_factors_from_database(self):
"""Initialize factors based on the specific database."""
# Define mappings from database type to strength values
database_strength_values = {
@@ -0,0 +1,81 @@
from typing import Dict, Type
from src.domain.carbon.databases.base import EmissionFactorDatabase
from src.domain.carbon.databases.enums import (
TimberDatabase,
SteelDatabase,
ConcreteDatabase,
)
# Import timber databases
from src.domain.carbon.databases.timber.athena import Athena
from src.domain.carbon.databases.timber.structurlam import Structurlam
from src.domain.carbon.databases.timber.awc_cwc import AwcCwc
from src.domain.carbon.databases.timber.katerra import Katerra
from src.domain.carbon.databases.timber.nordic_structures import NordicStructures
from src.domain.carbon.databases.timber.binderholz import Binderholz
from src.domain.carbon.databases.timber.structuralam_abbotsford import (
StructuralamAbbotsford,
)
from src.domain.carbon.databases.timber.clf_baseline_document import CLFBaselineDocument
from src.domain.carbon.databases.timber.industry_average import IndustryAverage
# Import steel databases
from src.domain.carbon.databases.steel.steel_350_mpa import Steel350MPa
# Import concrete databases
from src.domain.carbon.databases.concrete.metric import ConcreteEmissionDatabase
class DatabaseFactory:
"""Factory for creating emission factor database instances."""
_timber_database_classes: Dict[str, Type[EmissionFactorDatabase]] = {
TimberDatabase.Athena2021.value: Athena,
TimberDatabase.Structurlam2020.value: Structurlam,
TimberDatabase.AwcCwc2018.value: AwcCwc,
TimberDatabase.Katerra2020.value: Katerra,
TimberDatabase.NordicStructures2018.value: NordicStructures,
TimberDatabase.Binderholz2019.value: Binderholz,
TimberDatabase.StructuralamAbbotsford.value: StructuralamAbbotsford,
TimberDatabase.CLFBaselineDocument.value: CLFBaselineDocument,
TimberDatabase.IndustryAverage.value: IndustryAverage,
}
_steel_database_classes: Dict[str, Type[EmissionFactorDatabase]] = {
SteelDatabase.Type350MPa.value: Steel350MPa,
}
@classmethod
def create_timber_database(cls, database_name: str) -> EmissionFactorDatabase:
"""Create a timber database instance by name."""
if database_name not in cls._timber_database_classes:
raise ValueError(
f"Unknown timber database: '{database_name}'. "
f"Available databases: {', '.join(cls._timber_database_classes.keys())}"
)
return cls._timber_database_classes[database_name]()
@classmethod
def create_steel_database(cls, database_name: str) -> EmissionFactorDatabase:
"""Create a steel database instance by name."""
if database_name not in cls._steel_database_classes:
raise ValueError(
f"Unknown steel database: '{database_name}'. "
f"Available databases: {', '.join(cls._steel_database_classes.keys())}"
)
return cls._steel_database_classes[database_name]()
@classmethod
def create_concrete_database(cls, database_name: str) -> EmissionFactorDatabase:
"""Create a concrete database instance by name."""
# For concrete, we create a new instance with the database name
try:
return ConcreteEmissionDatabase(database_name)
except ValueError as e:
# Re-raise with more context
available_databases = [db.value for db in ConcreteDatabase]
raise ValueError(
f"Error creating concrete database: {str(e)}. "
f"Available databases: {', '.join(available_databases)}"
)
+59 -169
View File
@@ -1,169 +1,62 @@
from typing import Optional, Dict
from functools import lru_cache
from typing import Optional, Dict, List, cast
from src.domain.carbon.databases.base import EmissionFactorDatabase
from src.domain.carbon.databases.concrete.metric import ConcreteEmissionDatabase
from src.domain.carbon.schema import EmissionFactor
from src.domain.carbon.databases.enums import (
TimberDatabase,
SteelDatabase,
ConcreteDatabase,
)
# Import timber databases
from src.domain.carbon.databases.timber.athena import Athena
from src.domain.carbon.databases.timber.structurlam import Structurlam
from src.domain.carbon.databases.timber.awc_cwc import AwcCwc
from src.domain.carbon.databases.timber.katerra import Katerra
from src.domain.carbon.databases.timber.nordic_structures import NordicStructures
from src.domain.carbon.databases.timber.binderholz import Binderholz
from src.domain.carbon.databases.timber.structuralam_abbotsford import (
StructuralamAbbotsford,
)
from src.domain.carbon.databases.timber.clf_baseline_document import CLFBaselineDocument
from src.domain.carbon.databases.timber.industry_average import IndustryAverage
# Import steel databases
from src.domain.carbon.databases.steel.steel_350_mpa import Steel350MPa
from src.domain.carbon.schema import EmissionFactor
from src.domain.carbon.material_alias_service import MaterialAliasService
from src.domain.carbon.databases.database_factory import DatabaseFactory
class EmissionFactorRegistry:
"""Registry of available emission factor databases"""
"""Registry of available emission factor databases with lazy loading."""
def __init__(self):
self._timber_databases = {}
self._steel_databases = {}
self._concrete_databases = {}
self._timber_databases: Dict[str, EmissionFactorDatabase] = {}
self._steel_databases: Dict[str, EmissionFactorDatabase] = {}
self._concrete_databases: Dict[str, ConcreteEmissionDatabase] = {}
# Material aliases for normalization
# NOTE: Purely demonstrative → add aliases as needed.
self._timber_aliases = {
"clt": ["cross laminated timber", "cross-laminated timber"],
"glulam": [
"glue laminated timber",
"glued laminated timber",
"glulam beam",
],
"lvl": ["laminated veneer lumber"],
"softwood lumber": ["dimensional lumber", "sawn lumber", "softwood"],
"softwood plywood": ["plywood", "softwood ply"],
"oriented strand board": ["osb", "osb board"],
"glt/nlt/dlt": [
"glt",
"nlt",
"dlt",
"nail laminated timber",
"dowel laminated timber",
],
}
# Create the alias service
self._alias_service = MaterialAliasService()
self._steel_aliases = {
"hot rolled": [
"hot-rolled",
"hot_rolled",
"hotrolled",
"345 MPa", # NOTE: Needed!
"350W", # NOTE: Needed!
"350W(1)", # NOTE: Needed!
],
"hss": ["hollow structural section", "hollow section", "tube"],
"plate": ["flat plate"],
"rebar": ["reinforcing bar", "reinforcement"],
"owsj": ["open web steel joist", "steel joist"],
"fasteners": ["bolts", "screws", "nails", "rivets"],
"metal deck": ["deck", "decking"],
}
def _get_timber_database(self, database_name: str) -> EmissionFactorDatabase:
"""Get or create a timber database instance."""
if database_name not in self._timber_databases:
self._timber_databases[
database_name
] = DatabaseFactory.create_timber_database(database_name)
return self._timber_databases[database_name]
self._concrete_aliases = {
# To be added when concrete implementation is needed
}
def _get_steel_database(self, database_name: str) -> EmissionFactorDatabase:
"""Get or create a steel database instance."""
if database_name not in self._steel_databases:
self._steel_databases[
database_name
] = DatabaseFactory.create_steel_database(database_name)
return self._steel_databases[database_name]
# Initialize all database instances
self._init_timber_databases()
self._init_steel_databases()
self._init_concrete_databases()
def _init_timber_databases(self) -> None:
"""Initialize timber database implementations"""
self._timber_databases = {
TimberDatabase.Athena2021.value: Athena(),
TimberDatabase.Structurlam2020.value: Structurlam(),
TimberDatabase.AwcCwc2018.value: AwcCwc(),
TimberDatabase.Katerra2020.value: Katerra(),
TimberDatabase.NordicStructures2018.value: NordicStructures(),
TimberDatabase.Binderholz2019.value: Binderholz(),
TimberDatabase.StructuralamAbbotsford.value: StructuralamAbbotsford(),
TimberDatabase.CLFBaselineDocument.value: CLFBaselineDocument(),
TimberDatabase.IndustryAverage.value: IndustryAverage(),
}
def _init_steel_databases(self) -> None:
"""Initialize steel database implementations"""
self._steel_databases = {
SteelDatabase.Type350MPa.value: Steel350MPa(),
}
def _init_concrete_databases(self) -> None:
"""Initialize concrete database implementations"""
self._concrete_databases = {
ConcreteDatabase.GulLowAir.value: ConcreteEmissionDatabase(
ConcreteDatabase.GulLowAir.value
),
ConcreteDatabase.GulHighAir.value: ConcreteEmissionDatabase(
ConcreteDatabase.GulHighAir.value
),
ConcreteDatabase.GuLowAir.value: ConcreteEmissionDatabase(
ConcreteDatabase.GuLowAir.value
),
ConcreteDatabase.GuHighAir.value: ConcreteEmissionDatabase(
ConcreteDatabase.GuHighAir.value
),
}
@staticmethod
def _normalize_material_name(name: str, aliases: Dict[str, list]) -> str:
"""Normalize material name using centralized aliases with enhanced matching.
This improved version handles:
- Case insensitivity
- Direct matches (exact)
- Substring matches (contains)
- Special known cases
"""
# Convert to lowercase for case-insensitive comparison
name = name.lower().strip()
# Special case handling
if any(
steel_name in name
for steel_name in ["345 mpa", "350w", "steel 345", "default_steel"]
):
return "Hot Rolled" # Map all these variants to Hot Rolled steel
# Check for direct match with standard names
for standard_name in aliases.keys():
if standard_name.lower() == name:
return standard_name
# Check for standard name appearing as substring
for standard_name in aliases.keys():
if standard_name.lower() in name:
return standard_name
# Check aliases
for standard_name, variations in aliases.items():
for variation in variations:
if variation.lower() == name or variation.lower() in name:
return standard_name
return name
def _get_concrete_database(self, database_name: str) -> ConcreteEmissionDatabase:
"""Get or create a concrete database instance."""
if database_name not in self._concrete_databases:
# We need to cast here because the factory returns the base type
concrete_db = cast(
ConcreteEmissionDatabase,
DatabaseFactory.create_concrete_database(database_name),
)
self._concrete_databases[database_name] = concrete_db
return self._concrete_databases[database_name]
@lru_cache(maxsize=128)
def get_timber_factor(
self, material_name: str, database: str
) -> Optional[EmissionFactor]:
"""Get emission factor for timber from specified database with name normalization"""
db = self._timber_databases.get(database)
if not db:
raise ValueError(f"Unknown timber database: {database}")
"""Get emission factor for timber from specified database with name normalization."""
db = self._get_timber_database(database)
# Try direct lookup first
factor = db.get_factor(material_name)
@@ -171,18 +64,15 @@ class EmissionFactorRegistry:
return factor
# If not found, try normalized name
normalized_name = self._normalize_material_name(
material_name, self._timber_aliases
)
normalized_name = self._alias_service.normalize_timber_name(material_name)
return db.get_factor(normalized_name)
@lru_cache(maxsize=128)
def get_steel_factor(
self, material_name: str, database: str
) -> Optional[EmissionFactor]:
"""Get emission factor for steel from specified database with name normalization"""
db = self._steel_databases.get(database)
if not db:
raise ValueError(f"Unknown steel database: {database}")
"""Get emission factor for steel from specified database with name normalization."""
db = self._get_steel_database(database)
# Try direct lookup first
factor = db.get_factor(material_name)
@@ -190,30 +80,30 @@ class EmissionFactorRegistry:
return factor
# If not found, try normalized name
normalized_name = self._normalize_material_name(
material_name, self._steel_aliases
)
normalized_name = self._alias_service.normalize_steel_name(material_name)
return db.get_factor(normalized_name)
@lru_cache(maxsize=128)
def get_concrete_factor(
self, strength: str, element_type: str, database: str
) -> Optional[EmissionFactor]:
"""Get emission factor for concrete from specified database based on strength and element type."""
db = self._concrete_databases.get(database)
if not db:
raise ValueError(f"Unknown concrete database: {database}")
db = self._get_concrete_database(database)
# Concrete database requires both strength and element type
# Now we can safely call this method since we've ensured the correct type
return db.get_factor_by_strength_and_element(strength, element_type)
def list_timber_databases(self) -> list[str]:
"""List all registered timber databases"""
return list(self._timber_databases.keys())
@staticmethod
def list_timber_databases() -> List[str]:
"""List all available timber databases."""
return [db.value for db in TimberDatabase]
def list_steel_databases(self) -> list[str]:
"""List all registered steel databases"""
return list(self._steel_databases.keys())
@staticmethod
def list_steel_databases() -> List[str]:
"""List all available steel databases."""
return [db.value for db in SteelDatabase]
def list_concrete_databases(self) -> list[str]:
"""List all registered concrete databases"""
return list(self._concrete_databases.keys())
@staticmethod
def list_concrete_databases() -> List[str]:
"""List all available concrete databases."""
return [db.value for db in ConcreteDatabase]
@@ -0,0 +1,89 @@
from typing import Dict, List, Optional
class MaterialAliasService:
"""Service for managing and resolving material name aliases"""
def __init__(self):
# Material aliases used for normalization
self._timber_aliases = {
"clt": ["cross laminated timber", "cross-laminated timber"],
"glulam": [
"glue laminated timber",
"glued laminated timber",
"glulam beam",
],
"lvl": ["laminated veneer lumber"],
"softwood lumber": ["dimensional lumber", "sawn lumber", "softwood"],
"softwood plywood": ["plywood", "softwood ply"],
"oriented strand board": ["osb", "osb board"],
"glt/nlt/dlt": [
"glt",
"nlt",
"dlt",
"nail laminated timber",
"dowel laminated timber",
],
}
self._steel_aliases = {
"hot rolled": [
"hot-rolled",
"hot_rolled",
"hotrolled",
"345 MPa",
"350W",
"350W(1)",
],
"hss": ["hollow structural section", "hollow section", "tube"],
"plate": ["flat plate"],
"rebar": ["reinforcing bar", "reinforcement"],
"owsj": ["open web steel joist", "steel joist"],
"fasteners": ["bolts", "screws", "nails", "rivets"],
"metal deck": ["deck", "decking"],
}
self._concrete_aliases = {
# To be added when concrete implementation is needed
}
def normalize_timber_name(self, name: str) -> str:
return self._normalize_material_name(name, self._timber_aliases)
def normalize_steel_name(self, name: str) -> str:
return self._normalize_material_name(name, self._steel_aliases)
def normalize_concrete_name(self, name: str) -> str:
return self._normalize_material_name(name, self._concrete_aliases)
@staticmethod
def _normalize_material_name(name: str, aliases: Dict[str, List[str]]) -> str:
"""Normalize material name using centralized aliases with enhanced matching."""
# Convert to lowercase for case-insensitive comparison
name = name.lower().strip()
# Special case handling
if any(
steel_name in name
for steel_name in ["345 mpa", "350w", "steel 345", "default_steel"]
):
return "Hot Rolled" # Map all these variants to Hot Rolled steel
# Check for direct match with standard names
for standard_name in aliases.keys():
if standard_name.lower() == name:
return standard_name
# Check for standard name appearing as substring
for standard_name in aliases.keys():
if standard_name.lower() in name:
return standard_name
# Check aliases
for standard_name, variations in aliases.items():
for variation in variations:
if variation.lower() == name or variation.lower() in name:
return standard_name
return name
-11
View File
@@ -14,14 +14,3 @@ class EmissionFactor:
valid_until: Optional[str] = None
manufacturer: Optional[str] = None
plant_location: Optional[str] = None
@dataclass
class CarbonResult:
"""Result of a carbon calculation"""
material_name: str
emission_factor: EmissionFactor
quantity: float
total_carbon: float
category: str
+14 -1
View File
@@ -37,8 +37,21 @@ class BuildingElement:
materials: List[Material]
carbon_data: Optional[Dict] = None
@dataclass
class CarbonResult:
factor: float # kgCO2e/kg for metals, kgCO2e/m3 for wood
total_carbon: float # kgCO2e
category: str
category: str
# Additional fields for detailed output
quantity: float = None # volume (m³) for concrete/wood, mass (kg) for metal
database: str = None # database source
# Concrete-specific fields
concrete_volume: float = None # m³
concrete_carbon: float = None # kgCO2e
reinforcement_mass: float = None # kg
reinforcement_rate: float = None # kg/m³
reinforcement_factor: float = None # kgCO2e/kg
reinforcement_carbon: float = None # kgCO2e
+13 -8
View File
@@ -121,6 +121,8 @@ class CarbonCalculator:
factor=factor.value,
total_carbon=material.mass * factor.value,
category="Metal",
quantity=material.mass,
database=self._steel_database,
)
def _calculate_wood_carbon(self, material: Material) -> CarbonResult:
@@ -148,6 +150,8 @@ class CarbonCalculator:
factor=factor.value,
total_carbon=material.properties.volume * factor.value,
category="Wood",
quantity=material.properties.volume,
database=self._timber_database,
)
def _calculate_concrete_carbon(
@@ -220,19 +224,20 @@ class CarbonCalculator:
total_carbon = concrete_carbon + reinforcement_carbon
# Create result with additional metadata
result = CarbonResult(
return CarbonResult(
factor=concrete_factor.value,
total_carbon=total_carbon,
category="Concrete",
quantity=concrete_volume,
database=self._concrete_database,
concrete_volume=concrete_volume,
concrete_carbon=concrete_carbon,
reinforcement_mass=reinforcement_mass,
reinforcement_rate=reinforcement_rate,
reinforcement_factor=rebar_factor.value,
reinforcement_carbon=reinforcement_carbon,
)
# TODO: Add extra metadata (not in original CarbonResult class, would need extension)
# TODO: result.concrete_carbon = concrete_carbon
# TODO: result.reinforcement_carbon = reinforcement_carbon
# TODO: result.reinforcement_rate = reinforcement_rate
return result
@staticmethod
def _map_element_category_to_concrete_type(
element_category: ElementCategory,