Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd4cec36c7 | |||
| ecd4740333 | |||
| 373814cc25 | |||
| 1fafb470e2 | |||
| b1c32bf5d3 | |||
| 98341cc99f | |||
| 53fbfc0139 | |||
| 6279d9569b | |||
| ce05ec6862 | |||
| 669c1372c3 | |||
| 70a5f06b2e | |||
| 0908d9332c | |||
| 94b4cf7072 | |||
| 914cc711b0 | |||
| f19e1f3a3c | |||
| 9509100f30 | |||
| 3c15953b76 | |||
| f1ebf4ce39 | |||
| c31ab5a303 | |||
| 2116e3afb7 | |||
| 6ea1ff65e2 | |||
| 726233334e | |||
| d609a0670e | |||
| e7c73d0681 | |||
| e92066a8de | |||
| 665b26e823 | |||
| 1010caf8a7 | |||
| 2020048dca | |||
| 973accd2be | |||
| 69799fb7f9 | |||
| 3ab35a6d31 | |||
| 907236e07f | |||
| 3e8d80dd21 | |||
| d130815f20 | |||
| 6f80f98409 | |||
| 6723aca9b8 | |||
| 9f1b715def | |||
| 6ee044a877 | |||
| 241c420655 | |||
| 7f4610ff42 | |||
| 5f9d9a519e | |||
| c00b56959d | |||
| e417d1e218 | |||
| c351ac5df6 | |||
| b09490fa78 | |||
| f0692247f6 | |||
| 0d6efac003 | |||
| f6815bdfd0 | |||
| 809bfa4102 | |||
| 9d46562419 | |||
| 9ca9a1910b | |||
| 25bd0093af | |||
| 7f49d0db3b | |||
| 1abd27a5f5 | |||
| fd22ed1872 |
@@ -11,8 +11,8 @@ jobs:
|
||||
FUNCTION_SCHEMA_FILE_NAME: functionSchema.json
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4.1.7
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v4.2.2
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install poetry
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
run: |
|
||||
python main.py generate_schema ${HOME}/${{ env.FUNCTION_SCHEMA_FILE_NAME }}
|
||||
- name: Speckle Automate Function - Build and Publish
|
||||
uses: specklesystems/speckle-automate-github-composite-action@0.8.1
|
||||
uses: specklesystems/speckle-automate-github-composite-action@0.9.0
|
||||
with:
|
||||
speckle_automate_url: ${{ env.SPECKLE_AUTOMATE_URL || vars.SPECKLE_AUTOMATE_URL || 'https://automate.speckle.dev' }}
|
||||
speckle_token: ${{ secrets.SPECKLE_FUNCTION_TOKEN }}
|
||||
|
||||
@@ -313,3 +313,4 @@ pyrightconfig.json
|
||||
.ionide
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,pycharm
|
||||
report.pdf
|
||||
|
||||
@@ -1,61 +1,668 @@
|
||||
from pydantic import Field, SecretStr
|
||||
from pydantic import Field
|
||||
from reportlab.platypus import SimpleDocTemplate
|
||||
from reportlab.platypus.tables import Table
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from specklepy.objects import Base
|
||||
from speckle_automate import (
|
||||
AutomateBase,
|
||||
AutomationContext,
|
||||
execute_automate_function,
|
||||
)
|
||||
|
||||
from src.processors.commit_processory import CommitProcessor
|
||||
from typing import Dict, Generator, Any, Iterable
|
||||
|
||||
from src.domain.carbon.databases.enums import (
|
||||
SteelDatabase,
|
||||
TimberDatabase,
|
||||
ConcreteDatabase,
|
||||
)
|
||||
from src.infrastructure.logging import Logging
|
||||
from src.services.carbon_calculator import CarbonCalculator
|
||||
from src.services.element_processor import ElementProcessor
|
||||
from src.services.material_processor import MaterialProcessor
|
||||
|
||||
|
||||
def create_one_of_enum(enum_cls):
|
||||
"""
|
||||
Helper function to create a JSON schema from an Enum class.
|
||||
This is used for generating user input forms in the UI.
|
||||
"""
|
||||
return [{"const": item.value, "title": item.name} for item in enum_cls]
|
||||
|
||||
|
||||
class FunctionInputs(AutomateBase):
|
||||
# An example of how to use secret values.
|
||||
whisper_message: SecretStr = Field(title="This is a secret message")
|
||||
forbidden_speckle_type: str = Field(
|
||||
title="Forbidden speckle type",
|
||||
description=(
|
||||
"If a object has the following speckle_type,"
|
||||
" it will be marked with an error."
|
||||
),
|
||||
"""User-defined function inputs."""
|
||||
|
||||
steel_database: str = Field(
|
||||
default=SteelDatabase.Type350MPa,
|
||||
title="Steel Database",
|
||||
description="Database used for the GWP of steel objects",
|
||||
json_schema_extra={"oneOf": create_one_of_enum(SteelDatabase)},
|
||||
)
|
||||
|
||||
timber_database: str = Field(
|
||||
default=TimberDatabase.Binderholz2019,
|
||||
title="Timber Database",
|
||||
description="Database used for the GWP of timber objects",
|
||||
json_schema_extra={"oneOf": create_one_of_enum(TimberDatabase)},
|
||||
)
|
||||
|
||||
concrete_database: str = Field(
|
||||
default=ConcreteDatabase.GulLowAir.value,
|
||||
title="Concrete Database",
|
||||
description="Database used for the GWP of concrete objects",
|
||||
json_schema_extra={"oneOf": create_one_of_enum(ConcreteDatabase)},
|
||||
)
|
||||
|
||||
country: str = Field(
|
||||
default="CAN",
|
||||
title="Country",
|
||||
description="Country for concrete strength units (CAN: MPa, USA: PSI)",
|
||||
json_schema_extra={
|
||||
"oneOf": [
|
||||
{"const": "CAN", "title": "Canada (MPa)"},
|
||||
{"const": "USA", "title": "United States (PSI)"},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
# Add reinforcement rates based on Image 1
|
||||
reinforcement_grade_beam: float = Field(
|
||||
default=100.0,
|
||||
title="Grade Beam Reinforcement (kg/m³)",
|
||||
)
|
||||
|
||||
reinforcement_slab_on_grade: float = Field(
|
||||
default=85.0,
|
||||
title="Slab on Grade Reinforcement (kg/m³)",
|
||||
)
|
||||
|
||||
reinforcement_pad_footing: float = Field(
|
||||
default=100.0,
|
||||
title="Pad Footing Reinforcement (kg/m³)",
|
||||
)
|
||||
|
||||
reinforcement_pile: float = Field(
|
||||
default=100.0,
|
||||
title="Pile Reinforcement (kg/m³)",
|
||||
)
|
||||
|
||||
reinforcement_strip_footing: float = Field(
|
||||
default=100.0,
|
||||
title="Strip Footing Reinforcement (kg/m³)",
|
||||
)
|
||||
|
||||
reinforcement_pile_cap: float = Field(
|
||||
default=100.0,
|
||||
title="Pile Cap Reinforcement (kg/m³)",
|
||||
)
|
||||
|
||||
reinforcement_gravity_wall: float = Field(
|
||||
default=150.0,
|
||||
title="Gravity Wall Reinforcement (kg/m³)",
|
||||
)
|
||||
|
||||
reinforcement_column: float = Field(
|
||||
default=450.0,
|
||||
title="Column Reinforcement (kg/m³)",
|
||||
)
|
||||
|
||||
reinforcement_shear_wall: float = Field(
|
||||
default=150.0,
|
||||
title="Shear Wall Reinforcement (kg/m³)",
|
||||
)
|
||||
|
||||
reinforcement_concrete_slab: float = Field(
|
||||
default=120.0,
|
||||
title="Concrete Slab Reinforcement (kg/m³)",
|
||||
)
|
||||
|
||||
reinforcement_beam: float = Field(
|
||||
default=220.0,
|
||||
title="Beam Reinforcement (kg/m³)",
|
||||
)
|
||||
|
||||
reinforcement_topping_slab: float = Field(
|
||||
default=85.0,
|
||||
title="Topping Slab Reinforcement (kg/m³)",
|
||||
)
|
||||
|
||||
|
||||
class RevitCarbonAnalyzer:
|
||||
"""Main application for analyzing carbon in Revit models."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
material_processor: MaterialProcessor,
|
||||
element_processor: ElementProcessor,
|
||||
carbon_calculator: CarbonCalculator,
|
||||
logger: Logging,
|
||||
):
|
||||
"""
|
||||
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."""
|
||||
results = {
|
||||
"processed_elements": [],
|
||||
"skipped_elements": [],
|
||||
"warning_elements": [],
|
||||
"errors": [],
|
||||
"total_carbon": 0.0,
|
||||
"missing_factors": {"timber": [], "steel": [], "concrete": []},
|
||||
}
|
||||
|
||||
# Process each element
|
||||
for element in self.iterate_elements(model_root):
|
||||
try:
|
||||
element_result = self._process_single_element(element)
|
||||
if element_result["status"] == "processed":
|
||||
results["processed_elements"].append(element_result)
|
||||
results["total_carbon"] += element_result["total_carbon"]
|
||||
elif element_result["status"] == "skipped":
|
||||
results["skipped_elements"].append(element_result)
|
||||
elif element_result["status"] == "warning":
|
||||
results["warning_elements"].append(element_result)
|
||||
else:
|
||||
results["errors"].append(element_result)
|
||||
except Exception as e:
|
||||
results["errors"].append(
|
||||
{
|
||||
"id": getattr(element, "id", "unknown"),
|
||||
"error": str(e),
|
||||
"status": "error",
|
||||
}
|
||||
)
|
||||
|
||||
# Get missing factors
|
||||
(
|
||||
missing_timber,
|
||||
missing_steel,
|
||||
missing_concrete,
|
||||
) = self.carbon_calculator.get_missing_factors()
|
||||
results["missing_factors"]["timber"] = missing_timber
|
||||
results["missing_factors"]["steel"] = missing_steel
|
||||
results["missing_factors"]["concrete"] = missing_concrete
|
||||
|
||||
# Log missing factors
|
||||
if missing_timber:
|
||||
print(f"Missing timber factors ({len(missing_timber)}):")
|
||||
for item in missing_timber:
|
||||
print(f" - {item}")
|
||||
|
||||
if missing_steel:
|
||||
print(f"Missing steel factors ({len(missing_steel)}):")
|
||||
for item in missing_steel:
|
||||
print(f" - {item}")
|
||||
|
||||
if missing_concrete:
|
||||
print(f"Missing concrete factors ({len(missing_concrete)}):")
|
||||
for item in missing_concrete:
|
||||
print(f" - {item}")
|
||||
|
||||
return results
|
||||
|
||||
def _process_single_element(self, element: Dict) -> Dict:
|
||||
"""Process a single element and return its results."""
|
||||
element_id = getattr(element, "id", "unknown")
|
||||
|
||||
# Check if this element should be skipped
|
||||
if self.element_processor.is_skipped(element):
|
||||
return {
|
||||
"id": element_id,
|
||||
"status": "skipped",
|
||||
"reason": "Element type or family in skip list",
|
||||
}
|
||||
|
||||
# Check if element is valid - mark as warning if not
|
||||
if not self.element_processor.is_valid_element(element):
|
||||
return {
|
||||
"id": element_id,
|
||||
"status": "warning",
|
||||
"reason": "Missing required properties",
|
||||
}
|
||||
|
||||
# Process element
|
||||
processed_element = self.element_processor.process_element(element)
|
||||
if not processed_element:
|
||||
return {
|
||||
"id": element_id,
|
||||
"status": "error",
|
||||
"reason": "Element processing failed",
|
||||
}
|
||||
|
||||
# Calculate carbon
|
||||
try:
|
||||
carbon_results, material_errors = self.carbon_calculator.calculate_carbon(
|
||||
processed_element
|
||||
)
|
||||
|
||||
if not carbon_results:
|
||||
error_details = "; ".join(
|
||||
[f"{e['material']}: {e['error']}" for e in material_errors]
|
||||
)
|
||||
return {
|
||||
"id": element_id,
|
||||
"status": "error",
|
||||
"reason": f"No carbon could be calculated: {error_details}",
|
||||
}
|
||||
|
||||
# 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": "m³",
|
||||
},
|
||||
"database": {
|
||||
"name": "database",
|
||||
"value": result.database,
|
||||
"units": None,
|
||||
},
|
||||
"embodiedCarbonFactor": {
|
||||
"name": "embodiedCarbonFactor",
|
||||
"value": result.factor,
|
||||
"units": "kgCO₂e/m³",
|
||||
},
|
||||
"embodiedCarbon": {
|
||||
"name": "embodiedCarbon",
|
||||
"value": result.total_carbon,
|
||||
"units": "kgCO₂e",
|
||||
},
|
||||
}
|
||||
elif result.category == "Concrete":
|
||||
# For concrete (include both concrete and reinforcement)
|
||||
material_data = {
|
||||
"concreteVolume": {
|
||||
"name": "concreteVolume",
|
||||
"value": result.concrete_volume,
|
||||
"units": "m³",
|
||||
},
|
||||
"database": {
|
||||
"name": "database",
|
||||
"value": result.database,
|
||||
"units": None,
|
||||
},
|
||||
"concreteEmbodiedCarbonFactor": {
|
||||
"name": "concreteEmbodiedCarbonFactor",
|
||||
"value": result.factor,
|
||||
"units": "kgCO₂e/m³",
|
||||
},
|
||||
"concreteEmbodiedCarbon": {
|
||||
"name": "concreteEmbodiedCarbon",
|
||||
"value": result.concrete_carbon,
|
||||
"units": "kgCO₂e",
|
||||
},
|
||||
"reinforcementMass": {
|
||||
"name": "reinforcementMass",
|
||||
"value": result.reinforcement_mass,
|
||||
"units": "kg",
|
||||
},
|
||||
"reinforcementRate": {
|
||||
"name": "reinforcementRate",
|
||||
"value": result.reinforcement_rate,
|
||||
"units": "kg/m³",
|
||||
},
|
||||
"reinforcementEmbodiedCarbonFactor": {
|
||||
"name": "reinforcementEmbodiedCarbonFactor",
|
||||
"value": result.reinforcement_factor,
|
||||
"units": "kgCO₂e/kg",
|
||||
},
|
||||
"reinforcementEmbodiedCarbon": {
|
||||
"name": "reinforcementEmbodiedCarbon",
|
||||
"value": result.reinforcement_carbon,
|
||||
"units": "kgCO₂e",
|
||||
},
|
||||
"embodiedCarbon": {
|
||||
"name": "embodiedCarbon",
|
||||
"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,
|
||||
},
|
||||
"embodiedCarbonFactor": {
|
||||
"name": "embodiedCarbonFactor",
|
||||
"value": result.factor,
|
||||
"units": "kgCO₂e/kg",
|
||||
},
|
||||
"embodiedCarbon": {
|
||||
"name": "embodiedCarbon",
|
||||
"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
|
||||
|
||||
element_result = {
|
||||
"id": element_id,
|
||||
"status": "processed" if not material_errors else "warning",
|
||||
"level": processed_element.level,
|
||||
"category": processed_element.category,
|
||||
"materials": [
|
||||
{
|
||||
"name": m.properties.name,
|
||||
"type": m.type.value,
|
||||
"volume": m.properties.volume,
|
||||
}
|
||||
for m in processed_element.materials
|
||||
],
|
||||
"carbon_results": carbon_results,
|
||||
"total_carbon": sum(r.total_carbon for r in carbon_results.values()),
|
||||
}
|
||||
|
||||
# If there were material errors, include them in the result
|
||||
if material_errors:
|
||||
element_result["material_errors"] = material_errors
|
||||
element_result[
|
||||
"reason"
|
||||
] = f"Issues with {len(material_errors)} materials"
|
||||
|
||||
return element_result
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"id": element_id,
|
||||
"status": "error",
|
||||
"reason": f"Carbon calculation failed: {str(e)}",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def iterate_elements(base: Base) -> Iterable[Base]:
|
||||
"""Iterate through all elements in the model."""
|
||||
elements = getattr(base, "elements", getattr(base, "@elements", None))
|
||||
if elements is not None:
|
||||
for element in elements:
|
||||
yield from RevitCarbonAnalyzer.iterate_elements(element)
|
||||
yield base
|
||||
|
||||
|
||||
def automate_function(
|
||||
automate_context: AutomationContext,
|
||||
function_inputs: FunctionInputs,
|
||||
) -> None:
|
||||
# TODO: Add method to automation_context for sourceApplication
|
||||
version_id = automate_context.automation_run_data.triggers[0].payload.version_id
|
||||
commit_root = automate_context.speckle_client.commit.get(
|
||||
automate_context.automation_run_data.project_id, version_id
|
||||
)
|
||||
"""Program entry point."""
|
||||
try:
|
||||
# Get string values from enums if needed
|
||||
steel_db = function_inputs.steel_database
|
||||
timber_db = function_inputs.timber_database
|
||||
concrete_db = function_inputs.concrete_database
|
||||
country = function_inputs.country
|
||||
|
||||
# ℹ️ sourceApplication value for v2: AppName + Version => Revit2024, Revit2023 etc.
|
||||
# ℹ️ sourceApplication value for v3: slug => revit
|
||||
# ⚠️ We're just working with v3 data - adapt commit_processor for v2 data structure if you want
|
||||
# ⚠️ Alternatively, write a model factory that injects the correct CommitProcessor()
|
||||
if commit_root.sourceApplication != "revit":
|
||||
automate_context.mark_run_failed(
|
||||
f"Automation built for v3 Revit commits. These are commits with a "
|
||||
f"case-sensitive sourceApplication == 'revit', not {commit_root.sourceApplication})"
|
||||
# Ensure we're working with string values, not enum objects
|
||||
if hasattr(steel_db, "value"):
|
||||
steel_db = steel_db.value
|
||||
if hasattr(timber_db, "value"):
|
||||
timber_db = timber_db.value
|
||||
if hasattr(concrete_db, "value"):
|
||||
concrete_db = concrete_db.value
|
||||
# Create custom reinforcement rates dictionary
|
||||
custom_reinforcement_rates = {
|
||||
"Grade Beam": function_inputs.reinforcement_grade_beam,
|
||||
"Slab on Grade": function_inputs.reinforcement_slab_on_grade,
|
||||
"Pad Footing": function_inputs.reinforcement_pad_footing,
|
||||
"Pile": function_inputs.reinforcement_pile,
|
||||
"Strip Footing": function_inputs.reinforcement_strip_footing,
|
||||
"Pile Cap": function_inputs.reinforcement_pile_cap,
|
||||
"Walls - wind/gravity": function_inputs.reinforcement_gravity_wall,
|
||||
"Column": function_inputs.reinforcement_column,
|
||||
"Shear Walls": function_inputs.reinforcement_shear_wall,
|
||||
"Concrete Slabs": function_inputs.reinforcement_concrete_slab,
|
||||
"Beams": function_inputs.reinforcement_beam,
|
||||
"Topping Slabs": function_inputs.reinforcement_topping_slab,
|
||||
}
|
||||
|
||||
# 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,
|
||||
custom_reinforcement_rates=custom_reinforcement_rates,
|
||||
)
|
||||
|
||||
# Process elements
|
||||
model_root = automate_context.receive_version() # TODO: This is a waste!
|
||||
processor = CommitProcessor()
|
||||
processor.process_elements(model_root)
|
||||
# Initialize analyzer with injected dependencies
|
||||
analyzer = RevitCarbonAnalyzer(
|
||||
material_processor=material_processor,
|
||||
element_processor=element_processor,
|
||||
carbon_calculator=carbon_calculator,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
compliance_summary = processor.logger.get_summary()
|
||||
for missing_property, elements in compliance_summary.items():
|
||||
# Get commit root
|
||||
version_id = automate_context.automation_run_data.triggers[0].payload.version_id
|
||||
commit_root = automate_context.speckle_client.commit.get(
|
||||
automate_context.automation_run_data.project_id, version_id
|
||||
)
|
||||
|
||||
# Get model root
|
||||
model_root = automate_context.receive_version()
|
||||
|
||||
# Validate Revit source
|
||||
if not _validate_revit_source(commit_root):
|
||||
automate_context.mark_run_failed("Model must be from Revit")
|
||||
return
|
||||
|
||||
# Validate Next-Gen
|
||||
if not _validate_next_gen(model_root):
|
||||
automate_context.mark_run_failed(
|
||||
"Revit model must be sent using the v3 connector (or adapt the "
|
||||
"automation for v2)."
|
||||
)
|
||||
return
|
||||
|
||||
# Run analysis - convert Speckle model to dict for processing
|
||||
results = analyzer.analyze_model(model_root)
|
||||
|
||||
# Process results
|
||||
_process_automation_results(automate_context, results)
|
||||
|
||||
# Generate PDF
|
||||
file_name = "report.pdf"
|
||||
doc = SimpleDocTemplate(file_name, pagesize=letter)
|
||||
|
||||
pdf_data = [["Element ID", "Material", "Embodied Carbon"]]
|
||||
for element in RevitCarbonAnalyzer.iterate_elements(model_root):
|
||||
if hasattr(element, "properties"):
|
||||
element_properties = element["properties"]
|
||||
|
||||
# elementId became an issue for linked models. don't know why. lazy fix below. hackady-hack
|
||||
if hasattr(element_properties, "elementId"):
|
||||
element_id = element_properties["elementId"]
|
||||
if "Embodied Carbon Calculation" in element_properties:
|
||||
for key, value in element_properties[
|
||||
"Embodied Carbon Calculation"
|
||||
].items():
|
||||
pdf_data.append(
|
||||
[
|
||||
element_id,
|
||||
key,
|
||||
"{:0.2f} {}".format(
|
||||
value["embodiedCarbon"]["value"],
|
||||
value["embodiedCarbon"]["units"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
table = Table(pdf_data)
|
||||
doc.build([table])
|
||||
|
||||
automate_context.store_file_result(file_name)
|
||||
|
||||
# Calculate success percentage (successful / (successful + errors))
|
||||
total_processed = (
|
||||
len(results["processed_elements"])
|
||||
+ len(results["errors"])
|
||||
+ len(results["warning_elements"])
|
||||
)
|
||||
success_percentage = (
|
||||
(len(results["processed_elements"]) / total_processed * 100)
|
||||
if total_processed > 0
|
||||
else 100
|
||||
)
|
||||
|
||||
# Prepare detailed success message
|
||||
success_message = (
|
||||
f"🚀 Analysis complete.\n\n"
|
||||
f"\tProcessed:\t\t{results['success_count']} elements\n"
|
||||
f"\tSkipped:\t\t\t{results['skipped_count']} elements\n"
|
||||
f"\tWarnings:\t\t{results['warning_count']} elements\n"
|
||||
f"\tErrors:\t\t\t\t{results['error_count']} elements\n"
|
||||
f"\tSuccess rate:\t{success_percentage:.1f}%\n\n"
|
||||
f"\tTotal carbon:\t{results['total_carbon']:.0f} kgCO₂e\n"
|
||||
)
|
||||
|
||||
# Add missing factors to message if any
|
||||
missing_timber = results["missing_factors"]["timber"]
|
||||
missing_steel = results["missing_factors"]["steel"]
|
||||
|
||||
if missing_timber or missing_steel:
|
||||
success_message += "\nMissing emission factors detected:\n"
|
||||
|
||||
if missing_timber:
|
||||
success_message += (
|
||||
f"- Timber ({len(missing_timber)}): {', '.join(missing_timber[:5])}"
|
||||
)
|
||||
if len(missing_timber) > 5:
|
||||
success_message += f" and {len(missing_timber) - 5} more"
|
||||
success_message += "\n"
|
||||
|
||||
if missing_steel:
|
||||
success_message += (
|
||||
f"- Steel ({len(missing_steel)}): {', '.join(missing_steel[:5])}"
|
||||
)
|
||||
if len(missing_steel) > 5:
|
||||
success_message += f" and {len(missing_steel) - 5} more"
|
||||
success_message += "\n"
|
||||
|
||||
success_message += "\nThese materials were assigned zero carbon. Consider updating the database."
|
||||
|
||||
else:
|
||||
success_message += (
|
||||
"\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)
|
||||
|
||||
except Exception as e:
|
||||
automate_context.mark_run_failed(f"Analysis failed: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
def _validate_revit_source(commit_root: Any) -> bool:
|
||||
"""Validate that the model is from Revit."""
|
||||
source_app = getattr(commit_root, "sourceApplication", "").lower()
|
||||
return source_app.startswith("revit")
|
||||
|
||||
|
||||
def _validate_next_gen(model_root: Any) -> bool:
|
||||
"""Validate that the model was sent using the v3 connector"""
|
||||
if not getattr(model_root, "version", None) == 3:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _process_automation_results(
|
||||
automate_context: AutomationContext, results: dict
|
||||
) -> None:
|
||||
"""Process results and attach them to the automation context."""
|
||||
# Process each category and attach to objects
|
||||
|
||||
# Successes with gradient metadata
|
||||
if results["processed_elements"]:
|
||||
# Create a dictionary mapping element IDs to their total carbon values
|
||||
embodied_carbon_values = {}
|
||||
|
||||
# Extract the total carbon for each element
|
||||
for element in results["processed_elements"]:
|
||||
element_id = element["id"]
|
||||
# The total carbon is already calculated and stored in each element result
|
||||
total_carbon = element["total_carbon"]
|
||||
embodied_carbon_values[element_id] = {"gradientValue": total_carbon}
|
||||
|
||||
automate_context.attach_success_to_objects(
|
||||
category="Carbon Analysis",
|
||||
metadata={"gradient": True, "gradientValues": embodied_carbon_values},
|
||||
object_ids=[e["id"] for e in results["processed_elements"]],
|
||||
message="Carbon calculations completed successfully for these elements!",
|
||||
)
|
||||
|
||||
# Skipped elements (info)
|
||||
if results["skipped_elements"]:
|
||||
automate_context.attach_info_to_objects(
|
||||
category="Skipped Elements",
|
||||
object_ids=[e["id"] for e in results["skipped_elements"]],
|
||||
message="Elements that were intentionally skipped.",
|
||||
)
|
||||
|
||||
# Warnings
|
||||
if results["warning_elements"]:
|
||||
automate_context.attach_warning_to_objects(
|
||||
category="Missing Revit Material Property",
|
||||
object_ids=elements,
|
||||
message=f"Missing {missing_property} on the object, preventing mass calculation. "
|
||||
f"Update Revit object to contain the necessary properties if element is critical. ",
|
||||
category="Missing Material Data",
|
||||
object_ids=[e["id"] for e in results["warning_elements"]],
|
||||
message="Elements missing material data required for carbon calculation.",
|
||||
)
|
||||
|
||||
# TODO: create_new_version_in_project
|
||||
automate_context.mark_run_success("Under development.")
|
||||
# Errors
|
||||
if results["errors"]:
|
||||
automate_context.attach_error_to_objects(
|
||||
category="Processing Errors",
|
||||
object_ids=[e["id"] for e in results["errors"]],
|
||||
message="Failure processing the following elements.",
|
||||
)
|
||||
|
||||
# Add statistics to results for use in success message
|
||||
results["success_count"] = len(results["processed_elements"])
|
||||
results["warning_count"] = len(results["warning_elements"])
|
||||
results["skipped_count"] = len(results["skipped_elements"])
|
||||
results["error_count"] = len(results["errors"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Generated
+818
-677
File diff suppressed because it is too large
Load Diff
+13
-12
@@ -1,36 +1,37 @@
|
||||
[tool.poetry]
|
||||
name = "speckle-automate-py"
|
||||
version = "0.1.0"
|
||||
description = "Example function for Speckle Automate using specklepy"
|
||||
authors = ["Gergő Jedlicska <gergo@jedlicska.com>"]
|
||||
readme = "README.md"
|
||||
description = "Example function for Speckle Automate using specklepy"
|
||||
name = "speckle-automate-py"
|
||||
package-mode = false
|
||||
readme = "README.md"
|
||||
version = "0.1.0"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
specklepy = "^2.21.0"
|
||||
pylint = "^3.3.4"
|
||||
python = "^3.11"
|
||||
reportlab = "^4.3.1"
|
||||
specklepy = "^2.21.4"
|
||||
structlog = "^25.1.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^23.3.0"
|
||||
mypy = "^1.3.0"
|
||||
ruff = "^0.0.271"
|
||||
pydantic-settings = "^2.3.0"
|
||||
pytest = "^7.4.2"
|
||||
ruff = "^0.0.271"
|
||||
# specklepy = { path = "../specklepy", develop = true }
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["poetry-core"]
|
||||
|
||||
[tool.ruff]
|
||||
select = [
|
||||
"E", # pycodestyle
|
||||
"F", # pyflakes
|
||||
"E", # pycodestyle
|
||||
"F", # pyflakes
|
||||
"UP", # pyupgrade
|
||||
"D", # pydocstyle
|
||||
"I", # isort
|
||||
"D", # pydocstyle
|
||||
"I", # isort
|
||||
]
|
||||
|
||||
[tool.ruff.pydocstyle]
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
from typing import Dict
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class MassAggregator:
|
||||
def __init__(self):
|
||||
self.totals = defaultdict(lambda: defaultdict(lambda: defaultdict(float)))
|
||||
|
||||
def add_mass(self, mass: float, level: str, type: str, material: str) -> None:
|
||||
if mass <= 1e-6:
|
||||
return
|
||||
self.totals[level][type][material] += mass
|
||||
|
||||
def get_totals(self) -> Dict:
|
||||
return {
|
||||
"by_level": {
|
||||
level: {
|
||||
"total": sum(
|
||||
sum(material_masses.values())
|
||||
for material_masses in types.values()
|
||||
),
|
||||
"by_type": {
|
||||
type_name: {
|
||||
"total": sum(material_masses.values()),
|
||||
"by_material": material_masses,
|
||||
}
|
||||
for type_name, material_masses in types.items()
|
||||
},
|
||||
}
|
||||
for level, types in self.totals.items()
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
from enum import Enum
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class ConcreteElementType(Enum):
|
||||
GRADE_BEAM = "Grade Beam"
|
||||
SLAB_ON_GRADE = "Slab on Grade"
|
||||
PAD_FOOTING = "Pad Footing"
|
||||
PILE = "Pile"
|
||||
STRIP_FOOTING = "Strip Footing"
|
||||
PILE_CAP = "Pile Cap"
|
||||
GRAVITY_WALL = "Walls - wind/gravity"
|
||||
COLUMN = "Column"
|
||||
SHEAR_WALL = "Shear Walls"
|
||||
CONCRETE_SLAB = "Concrete Slabs"
|
||||
BEAM = "Beams"
|
||||
TOPPING_SLAB = "Topping Slabs"
|
||||
|
||||
|
||||
class ReinforcementRates:
|
||||
"""Reinforcement rates for concrete elements by element type."""
|
||||
|
||||
def __init__(self, rates_dict: Dict[str, float]):
|
||||
"""Initialize with rates provided from function inputs."""
|
||||
self._rates = {}
|
||||
|
||||
# Convert string keys to enum values
|
||||
for type_str, rate in rates_dict.items():
|
||||
for enum_type in ConcreteElementType:
|
||||
if enum_type.value.lower() == type_str.lower():
|
||||
self._rates[enum_type] = rate
|
||||
break
|
||||
# If no match found, store with string key
|
||||
if type_str.lower() not in [e.value.lower() for e in ConcreteElementType]:
|
||||
self._rates[type_str] = rate
|
||||
|
||||
def get_rate(self, element_type: str) -> float:
|
||||
"""Get reinforcement rate for a concrete element type."""
|
||||
# Try direct match with enum values
|
||||
for enum_type in ConcreteElementType:
|
||||
if enum_type.value.lower() == element_type.lower():
|
||||
return self._rates.get(enum_type, 100.0) # Default if missing
|
||||
|
||||
# Try fuzzy matching with string keys
|
||||
element_lower = element_type.lower()
|
||||
if "beam" in element_lower and "grade" in element_lower:
|
||||
return self.get_rate_by_type(ConcreteElementType.GRADE_BEAM)
|
||||
elif "slab" in element_lower and "grade" in element_lower:
|
||||
return self.get_rate_by_type(ConcreteElementType.SLAB_ON_GRADE)
|
||||
# ... other fuzzy matching logic ...
|
||||
|
||||
# Default value if no match found
|
||||
return 100.0
|
||||
|
||||
def get_rate_by_type(self, element_type: ConcreteElementType) -> float:
|
||||
"""Get rate by concrete element type enum."""
|
||||
return self._rates.get(element_type, 100.0) # Default if missing
|
||||
@@ -0,0 +1,20 @@
|
||||
from abc import ABC
|
||||
from typing import Optional, Dict
|
||||
from src.domain.carbon.schema import EmissionFactor
|
||||
|
||||
|
||||
class EmissionFactorDatabase(ABC):
|
||||
"""Base class for emission factor databases"""
|
||||
|
||||
def __init__(self):
|
||||
self._factors: Dict[str, EmissionFactor] = {}
|
||||
|
||||
def get_factor(self, material_name: str) -> Optional[EmissionFactor]:
|
||||
"""Get emission factor for a material name"""
|
||||
material_name = material_name.lower()
|
||||
for name, factor in self._factors.items():
|
||||
if name.lower() == material_name:
|
||||
return factor
|
||||
|
||||
# If no direct match, return None
|
||||
return None
|
||||
@@ -0,0 +1,270 @@
|
||||
from src.domain.carbon.databases.base import EmissionFactorDatabase
|
||||
from src.domain.carbon.schema import EmissionFactor
|
||||
from src.domain.carbon.databases.enums import ConcreteDatabase
|
||||
|
||||
UNIT = "kgCO₂e/m³"
|
||||
|
||||
|
||||
class ConcreteEmissionDatabase(EmissionFactorDatabase):
|
||||
"""Database implementation for concrete emission factors based on cement type and strength."""
|
||||
|
||||
def __init__(self, database_name: str):
|
||||
super().__init__()
|
||||
self._database_name = database_name
|
||||
self._factors = {}
|
||||
self._load_emission_factors_from_database()
|
||||
|
||||
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 = {
|
||||
ConcreteDatabase.GulLowAir.value: {
|
||||
"25": {
|
||||
"Beam": 188,
|
||||
"Slab": 188,
|
||||
"Slab on Grade": 188,
|
||||
"Foundation": 151,
|
||||
"Column": 151,
|
||||
"Wall": 151,
|
||||
"Wall Foundation": 151,
|
||||
},
|
||||
"30": {
|
||||
"Beam": 220,
|
||||
"Slab": 220,
|
||||
"Slab on Grade": 220,
|
||||
"Foundation": 176,
|
||||
"Column": 176,
|
||||
"Wall": 176,
|
||||
"Wall Foundation": 176,
|
||||
},
|
||||
"35": {
|
||||
"Beam": 250,
|
||||
"Slab": 250,
|
||||
"Slab on Grade": 250,
|
||||
"Foundation": 200,
|
||||
"Column": 200,
|
||||
"Wall": 200,
|
||||
"Wall Foundation": 200,
|
||||
},
|
||||
"40": {
|
||||
"Beam": 280,
|
||||
"Slab": 280,
|
||||
"Slab on Grade": 280,
|
||||
"Foundation": 224,
|
||||
"Column": 224,
|
||||
"Wall": 224,
|
||||
"Wall Foundation": 224,
|
||||
},
|
||||
"45": {
|
||||
"Beam": 298,
|
||||
"Slab": 298,
|
||||
"Slab on Grade": 298,
|
||||
"Foundation": 238,
|
||||
"Column": 238,
|
||||
"Wall": 238,
|
||||
"Wall Foundation": 238,
|
||||
},
|
||||
"50": {
|
||||
"Beam": 320,
|
||||
"Slab": 320,
|
||||
"Slab on Grade": 320,
|
||||
"Foundation": 256,
|
||||
"Column": 256,
|
||||
"Wall": 256,
|
||||
"Wall Foundation": 256,
|
||||
},
|
||||
},
|
||||
ConcreteDatabase.GulHighAir.value: {
|
||||
"25": {
|
||||
"Beam": 201,
|
||||
"Slab": 197,
|
||||
"Slab on Grade": 197,
|
||||
"Foundation": 157,
|
||||
"Column": 157,
|
||||
"Wall": 157,
|
||||
"Wall Foundation": 157,
|
||||
},
|
||||
"30": {
|
||||
"Beam": 236,
|
||||
"Slab": 230,
|
||||
"Slab on Grade": 230,
|
||||
"Foundation": 184,
|
||||
"Column": 184,
|
||||
"Wall": 184,
|
||||
"Wall Foundation": 184,
|
||||
},
|
||||
"35": {
|
||||
"Beam": 268,
|
||||
"Slab": 264,
|
||||
"Slab on Grade": 264,
|
||||
"Foundation": 211,
|
||||
"Column": 211,
|
||||
"Wall": 211,
|
||||
"Wall Foundation": 211,
|
||||
},
|
||||
"40": {
|
||||
"Beam": 292,
|
||||
"Slab": 292,
|
||||
"Slab on Grade": 292,
|
||||
"Foundation": 234,
|
||||
"Column": 234,
|
||||
"Wall": 234,
|
||||
"Wall Foundation": 234,
|
||||
},
|
||||
"45": {
|
||||
"Beam": 316,
|
||||
"Slab": 316,
|
||||
"Slab on Grade": 316,
|
||||
"Foundation": 254,
|
||||
"Column": 254,
|
||||
"Wall": 254,
|
||||
"Wall Foundation": 254,
|
||||
},
|
||||
"50": {
|
||||
"Beam": 343,
|
||||
"Slab": 322,
|
||||
"Slab on Grade": 322,
|
||||
"Foundation": 257,
|
||||
"Column": 257,
|
||||
"Wall": 257,
|
||||
"Wall Foundation": 257,
|
||||
},
|
||||
},
|
||||
ConcreteDatabase.GuLowAir.value: {
|
||||
"25": {
|
||||
"Beam": 201,
|
||||
"Slab": 201,
|
||||
"Slab on Grade": 201,
|
||||
"Foundation": 161,
|
||||
"Column": 161,
|
||||
"Wall": 161,
|
||||
"Wall Foundation": 161,
|
||||
},
|
||||
"30": {
|
||||
"Beam": 236,
|
||||
"Slab": 236,
|
||||
"Slab on Grade": 236,
|
||||
"Foundation": 189,
|
||||
"Column": 189,
|
||||
"Wall": 189,
|
||||
"Wall Foundation": 189,
|
||||
},
|
||||
"35": {
|
||||
"Beam": 268,
|
||||
"Slab": 268,
|
||||
"Slab on Grade": 268,
|
||||
"Foundation": 214,
|
||||
"Column": 214,
|
||||
"Wall": 214,
|
||||
"Wall Foundation": 214,
|
||||
},
|
||||
"40": {
|
||||
"Beam": 300,
|
||||
"Slab": 300,
|
||||
"Slab on Grade": 300,
|
||||
"Foundation": 240,
|
||||
"Column": 240,
|
||||
"Wall": 240,
|
||||
"Wall Foundation": 240,
|
||||
},
|
||||
"45": {
|
||||
"Beam": 319,
|
||||
"Slab": 319,
|
||||
"Slab on Grade": 319,
|
||||
"Foundation": 256,
|
||||
"Column": 256,
|
||||
"Wall": 256,
|
||||
"Wall Foundation": 256,
|
||||
},
|
||||
"50": {
|
||||
"Beam": 343,
|
||||
"Slab": 343,
|
||||
"Slab on Grade": 343,
|
||||
"Foundation": 274,
|
||||
"Column": 274,
|
||||
"Wall": 274,
|
||||
"Wall Foundation": 274,
|
||||
},
|
||||
},
|
||||
ConcreteDatabase.GuHighAir.value: {
|
||||
"25": {
|
||||
"Beam": 210,
|
||||
"Slab": 210,
|
||||
"Slab on Grade": 210,
|
||||
"Foundation": 168,
|
||||
"Column": 168,
|
||||
"Wall": 168,
|
||||
"Wall Foundation": 168,
|
||||
},
|
||||
"30": {
|
||||
"Beam": 246,
|
||||
"Slab": 246,
|
||||
"Slab on Grade": 246,
|
||||
"Foundation": 197,
|
||||
"Column": 197,
|
||||
"Wall": 197,
|
||||
"Wall Foundation": 197,
|
||||
},
|
||||
"35": {
|
||||
"Beam": 283,
|
||||
"Slab": 283,
|
||||
"Slab on Grade": 283,
|
||||
"Foundation": 227,
|
||||
"Column": 227,
|
||||
"Wall": 227,
|
||||
"Wall Foundation": 227,
|
||||
},
|
||||
"40": {
|
||||
"Beam": 313,
|
||||
"Slab": 313,
|
||||
"Slab on Grade": 313,
|
||||
"Foundation": 251,
|
||||
"Column": 251,
|
||||
"Wall": 251,
|
||||
"Wall Foundation": 251,
|
||||
},
|
||||
"45": {
|
||||
"Beam": 339,
|
||||
"Slab": 339,
|
||||
"Slab on Grade": 339,
|
||||
"Foundation": 271,
|
||||
"Column": 271,
|
||||
"Wall": 271,
|
||||
"Wall Foundation": 271,
|
||||
},
|
||||
"50": {
|
||||
"Beam": 345,
|
||||
"Slab": 345,
|
||||
"Slab on Grade": 345,
|
||||
"Foundation": 276,
|
||||
"Column": 276,
|
||||
"Wall": 276,
|
||||
"Wall Foundation": 276,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Get the strength values for the selected database
|
||||
strength_values = database_strength_values.get(self._database_name)
|
||||
if not strength_values:
|
||||
raise ValueError(f"Unknown concrete database: {self._database_name}")
|
||||
|
||||
# Create factors from the strength values
|
||||
for strength, elements in strength_values.items():
|
||||
for element, value in elements.items():
|
||||
factor_key = f"{strength}_{element}"
|
||||
self._factors[factor_key] = EmissionFactor(
|
||||
value=value,
|
||||
unit=UNIT,
|
||||
database=self._database_name,
|
||||
epd_number=f"CONCRETE-{self._database_name}-{strength}-{element}",
|
||||
publication_date="2024-01-01",
|
||||
valid_until="2029-01-01",
|
||||
)
|
||||
|
||||
def get_factor_by_strength_and_element(
|
||||
self, strength: str, element_type: str
|
||||
) -> EmissionFactor:
|
||||
"""Get emission factor based on concrete strength and element type."""
|
||||
factor_key = f"{strength}_{element_type}"
|
||||
return self._factors.get(factor_key)
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TimberDatabase(Enum):
|
||||
Athena2021 = "ATHENA 2021"
|
||||
Structurlam2020 = "Structurlam, 2020"
|
||||
AwcCwc2018 = "AWC, CWC, 2018"
|
||||
Katerra2020 = "Katerra, 2020"
|
||||
NordicStructures2018 = "Nordic Structures, 2018"
|
||||
Binderholz2019 = "Binderholz, 2019"
|
||||
StructuralamAbbotsford = "Structuralam Abbotsford"
|
||||
CLFBaselineDocument = "CLF Baseline Document"
|
||||
IndustryAverage = "INDUSTRY AVERAGE"
|
||||
|
||||
|
||||
class SteelDatabase(Enum):
|
||||
Type350MPa = "Type 350 MPa"
|
||||
|
||||
|
||||
class ConcreteDatabase(Enum):
|
||||
GulLowAir = "GUL Cement, Low Air"
|
||||
GulHighAir = "GUL Cement, High Air"
|
||||
GuLowAir = "GU Cement, Low Air"
|
||||
GuHighAir = "GU Cement, High Air"
|
||||
@@ -0,0 +1,81 @@
|
||||
from src.domain.carbon.databases.base import EmissionFactorDatabase
|
||||
from src.domain.carbon.schema import EmissionFactor
|
||||
from src.domain.carbon.databases.enums import SteelDatabase
|
||||
|
||||
UNIT = "kgCO₂e/kg"
|
||||
|
||||
|
||||
class Steel350MPa(EmissionFactorDatabase):
|
||||
"""Database implementation for Type 350 MPa steel emission factors."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._factors = {
|
||||
"Hot Rolled": EmissionFactor(
|
||||
value=1.22,
|
||||
unit=UNIT,
|
||||
database=SteelDatabase.Type350MPa.value,
|
||||
epd_number="STEEL-350-HR",
|
||||
publication_date="2024-01-01",
|
||||
valid_until="2029-01-01",
|
||||
),
|
||||
"HSS": EmissionFactor(
|
||||
value=1.99,
|
||||
unit=UNIT,
|
||||
database=SteelDatabase.Type350MPa.value,
|
||||
epd_number="STEEL-350-HSS",
|
||||
publication_date="2024-01-01",
|
||||
valid_until="2029-01-01",
|
||||
),
|
||||
"Plate": EmissionFactor(
|
||||
value=1.73,
|
||||
unit=UNIT,
|
||||
database=SteelDatabase.Type350MPa.value,
|
||||
epd_number="STEEL-350-PL",
|
||||
publication_date="2024-01-01",
|
||||
valid_until="2029-01-01",
|
||||
),
|
||||
"Rebar": EmissionFactor(
|
||||
value=0.854,
|
||||
unit=UNIT,
|
||||
database=SteelDatabase.Type350MPa.value,
|
||||
epd_number="STEEL-350-RB",
|
||||
publication_date="2024-01-01",
|
||||
valid_until="2029-01-01",
|
||||
),
|
||||
"OWSJ": EmissionFactor(
|
||||
value=1.380,
|
||||
unit=UNIT,
|
||||
database=SteelDatabase.Type350MPa.value,
|
||||
epd_number="STEEL-350-OWSJ",
|
||||
publication_date="2024-01-01",
|
||||
valid_until="2029-01-01",
|
||||
),
|
||||
"Fasteners": EmissionFactor(
|
||||
value=1.730,
|
||||
unit=UNIT,
|
||||
database=SteelDatabase.Type350MPa.value,
|
||||
epd_number="STEEL-350-FST",
|
||||
publication_date="2024-01-01",
|
||||
valid_until="2029-01-01",
|
||||
),
|
||||
"Metal Deck": EmissionFactor(
|
||||
value=2.370,
|
||||
unit=UNIT,
|
||||
database=SteelDatabase.Type350MPa.value,
|
||||
epd_number="STEEL-350-MD",
|
||||
publication_date="2024-01-01",
|
||||
valid_until="2029-01-01",
|
||||
),
|
||||
}
|
||||
|
||||
# Set up common aliases for steel types
|
||||
self._material_aliases = {
|
||||
"hot rolled": ["hot-rolled", "hot_rolled", "hotrolled"],
|
||||
"hss": ["hollow structural section", "hollow section", "tube steel"],
|
||||
"plate": ["steel plate", "flat plate"],
|
||||
"rebar": ["reinforcing bar", "reinforcement"],
|
||||
"owsj": ["open web steel joist", "steel joist"],
|
||||
"fasteners": ["bolts", "screws", "nails", "rivets"],
|
||||
"metal deck": ["steel deck", "decking"],
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
from src.domain.carbon.databases.enums import TimberDatabase
|
||||
from src.domain.carbon.databases.base import EmissionFactorDatabase
|
||||
from src.domain.carbon.schema import EmissionFactor
|
||||
|
||||
UNIT = "kgCO₂e/m³"
|
||||
|
||||
|
||||
class Athena(EmissionFactorDatabase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._factors = {
|
||||
"Glulam": EmissionFactor(
|
||||
value=107,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.Athena2021.value,
|
||||
epd_number="ATHENA-2021-GL",
|
||||
publication_date="2021-01-01",
|
||||
valid_until="2026-01-01",
|
||||
),
|
||||
"CLT": EmissionFactor(
|
||||
value=69,
|
||||
unit="kgCO2e/m3",
|
||||
database=TimberDatabase.Athena2021.value,
|
||||
epd_number=UNIT,
|
||||
publication_date="2021-01-01",
|
||||
valid_until="2026-01-01",
|
||||
),
|
||||
"LVL": EmissionFactor(
|
||||
value=169,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.Athena2021.value,
|
||||
epd_number="ATHENA-2021-LVL",
|
||||
publication_date="2021-01-01",
|
||||
valid_until="2026-01-01",
|
||||
),
|
||||
"Softwood Lumber": EmissionFactor(
|
||||
value=48,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.Athena2021.value,
|
||||
epd_number="ATHENA-2021-SWL",
|
||||
publication_date="2021-01-01",
|
||||
valid_until="2026-01-01",
|
||||
),
|
||||
"Softwood Plywood": EmissionFactor(
|
||||
value=65,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.Athena2021.value,
|
||||
epd_number="ATHENA-2021-SWP",
|
||||
publication_date="2021-01-01",
|
||||
valid_until="2026-01-01",
|
||||
),
|
||||
"Oriented Strand Board": EmissionFactor(
|
||||
value=182,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.Athena2021.value,
|
||||
epd_number="ATHENA-2021-OSB",
|
||||
publication_date="2021-01-01",
|
||||
valid_until="2026-01-01",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
from src.domain.carbon.databases.enums import TimberDatabase
|
||||
from src.domain.carbon.databases.base import EmissionFactorDatabase
|
||||
from src.domain.carbon.schema import EmissionFactor
|
||||
|
||||
UNIT = "kgCO₂e/m³"
|
||||
|
||||
|
||||
class AwcCwc(EmissionFactorDatabase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._factors = {
|
||||
"Glulam": EmissionFactor(
|
||||
value=137,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.AwcCwc2018.value,
|
||||
epd_number="AWC-2018-GL",
|
||||
publication_date="2018-01-01",
|
||||
valid_until="2023-01-01",
|
||||
),
|
||||
"LVL": EmissionFactor(
|
||||
value=361,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.AwcCwc2018.value,
|
||||
epd_number="AWC-2018-LVL",
|
||||
publication_date="2018-01-01",
|
||||
valid_until="2023-01-01",
|
||||
),
|
||||
"Softwood Lumber": EmissionFactor(
|
||||
value=63,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.AwcCwc2018.value,
|
||||
epd_number="AWC-2018-SWL",
|
||||
publication_date="2018-01-01",
|
||||
valid_until="2023-01-01",
|
||||
),
|
||||
"Softwood Plywood": EmissionFactor(
|
||||
value=219,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.AwcCwc2018.value,
|
||||
epd_number="AWC-2018-SWP",
|
||||
publication_date="2018-01-01",
|
||||
valid_until="2023-01-01",
|
||||
),
|
||||
"Wood Joists": EmissionFactor(
|
||||
value=2,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.AwcCwc2018.value,
|
||||
epd_number="AWC-2018-WJ",
|
||||
publication_date="2018-01-01",
|
||||
valid_until="2023-01-01",
|
||||
),
|
||||
"Redwood Lumber": EmissionFactor(
|
||||
value=38,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.AwcCwc2018.value,
|
||||
epd_number="AWC-2018-RWL",
|
||||
publication_date="2018-01-01",
|
||||
valid_until="2023-01-01",
|
||||
),
|
||||
"Oriented Strand Board": EmissionFactor(
|
||||
value=243,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.AwcCwc2018.value,
|
||||
epd_number="AWC-2018-OSB",
|
||||
publication_date="2018-01-01",
|
||||
valid_until="2023-01-01",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
from src.domain.carbon.databases.base import EmissionFactorDatabase
|
||||
from src.domain.carbon.databases.enums import TimberDatabase
|
||||
from src.domain.carbon.schema import EmissionFactor
|
||||
|
||||
UNIT = "kgCO₂e/m³"
|
||||
|
||||
|
||||
class Binderholz(EmissionFactorDatabase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._factors = {
|
||||
"Glulam": EmissionFactor(
|
||||
value=118,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.Binderholz2019.value,
|
||||
epd_number="BH-2019-GL",
|
||||
publication_date="2019-01-01",
|
||||
valid_until="2024-01-01",
|
||||
),
|
||||
"CLT": EmissionFactor(
|
||||
value=200,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.Binderholz2019.value,
|
||||
epd_number="BH-2019-CLT",
|
||||
publication_date="2019-01-01",
|
||||
valid_until="2024-01-01",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
from src.domain.carbon.databases.base import EmissionFactorDatabase
|
||||
from src.domain.carbon.databases.enums import TimberDatabase
|
||||
from src.domain.carbon.schema import EmissionFactor
|
||||
|
||||
UNIT = "kgCO₂e/m³"
|
||||
|
||||
|
||||
class CLFBaselineDocument(EmissionFactorDatabase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._factors = {
|
||||
"CLT": EmissionFactor(
|
||||
value=137,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.CLFBaselineDocument.value,
|
||||
epd_number="CLF-CLT",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
"GLT/NLT/DLT": EmissionFactor(
|
||||
value=109,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.CLFBaselineDocument.value,
|
||||
epd_number="CLF-GLT",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
from src.domain.carbon.databases.base import EmissionFactorDatabase
|
||||
from src.domain.carbon.databases.enums import TimberDatabase
|
||||
from src.domain.carbon.schema import EmissionFactor
|
||||
|
||||
UNIT = "kgCO₂e/m³"
|
||||
|
||||
|
||||
class IndustryAverage(EmissionFactorDatabase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._factors = {
|
||||
"Glulam": EmissionFactor(
|
||||
value=113,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.IndustryAverage.value,
|
||||
epd_number="IA-GL",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
"CLT": EmissionFactor(
|
||||
value=135,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.IndustryAverage.value,
|
||||
epd_number="IA-CLT",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
"LVL": EmissionFactor(
|
||||
value=265,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.IndustryAverage.value,
|
||||
epd_number="IA-LVL",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
"Softwood Lumber": EmissionFactor(
|
||||
value=56,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.IndustryAverage.value,
|
||||
epd_number="IA-SWL",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
"Softwood Plywood": EmissionFactor(
|
||||
value=142,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.IndustryAverage.value,
|
||||
epd_number="IA-SWP",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
"Wood Joists": EmissionFactor(
|
||||
value=2,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.IndustryAverage.value,
|
||||
epd_number="IA-WJ",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
"Redwood Lumber": EmissionFactor(
|
||||
value=38,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.IndustryAverage.value,
|
||||
epd_number="IA-RWL",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
"Oriented Strand Board": EmissionFactor(
|
||||
value=212,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.IndustryAverage.value,
|
||||
epd_number="IA-OSB",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
"GLT/NLT/DLT": EmissionFactor(
|
||||
value=123,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.IndustryAverage.value,
|
||||
epd_number="IA-GLT",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
from src.domain.carbon.databases.enums import TimberDatabase
|
||||
from src.domain.carbon.databases.base import EmissionFactorDatabase
|
||||
from src.domain.carbon.schema import EmissionFactor
|
||||
|
||||
UNIT = "kgCO₂e/m³"
|
||||
|
||||
|
||||
class Katerra(EmissionFactorDatabase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._factors = {
|
||||
"CLT": EmissionFactor(
|
||||
value=158,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.Katerra2020.value,
|
||||
epd_number="KAT-2020-CLT",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
from src.domain.carbon.databases.base import EmissionFactorDatabase
|
||||
from src.domain.carbon.databases.enums import TimberDatabase
|
||||
from src.domain.carbon.schema import EmissionFactor
|
||||
|
||||
UNIT = "kgCO₂e/m³"
|
||||
|
||||
|
||||
class NordicStructures(EmissionFactorDatabase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._factors = {
|
||||
"Glulam": EmissionFactor(
|
||||
value=100,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.NordicStructures2018.value,
|
||||
epd_number="NS-2018-GL",
|
||||
publication_date="2018-01-01",
|
||||
valid_until="2023-01-01",
|
||||
),
|
||||
"CLT": EmissionFactor(
|
||||
value=122,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.NordicStructures2018.value,
|
||||
epd_number="NS-2018-CLT",
|
||||
publication_date="2018-01-01",
|
||||
valid_until="2023-01-01",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
from src.domain.carbon.databases.base import EmissionFactorDatabase
|
||||
from src.domain.carbon.databases.enums import TimberDatabase
|
||||
from src.domain.carbon.schema import EmissionFactor
|
||||
|
||||
UNIT = "kgCO₂e/m³"
|
||||
|
||||
|
||||
class StructuralamAbbotsford(EmissionFactorDatabase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._factors = {
|
||||
"Glulam": EmissionFactor(
|
||||
value=103,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.StructuralamAbbotsford.value,
|
||||
epd_number="SA-GL",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
from src.domain.carbon.databases.enums import TimberDatabase
|
||||
from src.domain.carbon.databases.base import EmissionFactorDatabase
|
||||
from src.domain.carbon.schema import EmissionFactor
|
||||
|
||||
UNIT = "kgCO₂e/m³"
|
||||
|
||||
|
||||
class Structurlam(EmissionFactorDatabase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._factors = {
|
||||
"Glulam": EmissionFactor(
|
||||
value=115,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.Structurlam2020.value,
|
||||
epd_number="STR-2020-GL",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
"CLT": EmissionFactor(
|
||||
value=124,
|
||||
unit=UNIT,
|
||||
database=TimberDatabase.Structurlam2020.value,
|
||||
epd_number="STR-2020-CLT",
|
||||
publication_date="2020-01-01",
|
||||
valid_until="2025-01-01",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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.databases.enums import (
|
||||
TimberDatabase,
|
||||
SteelDatabase,
|
||||
ConcreteDatabase,
|
||||
)
|
||||
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 with lazy loading."""
|
||||
|
||||
def __init__(self):
|
||||
self._timber_databases: Dict[str, EmissionFactorDatabase] = {}
|
||||
self._steel_databases: Dict[str, EmissionFactorDatabase] = {}
|
||||
self._concrete_databases: Dict[str, ConcreteEmissionDatabase] = {}
|
||||
|
||||
# Create the alias service
|
||||
self._alias_service = MaterialAliasService()
|
||||
|
||||
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]
|
||||
|
||||
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]
|
||||
|
||||
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._get_timber_database(database)
|
||||
|
||||
# Try direct lookup first
|
||||
factor = db.get_factor(material_name)
|
||||
if factor:
|
||||
return factor
|
||||
|
||||
# If not found, try normalized name
|
||||
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._get_steel_database(database)
|
||||
|
||||
# Try direct lookup first
|
||||
factor = db.get_factor(material_name)
|
||||
if factor:
|
||||
return factor
|
||||
|
||||
# If not found, try normalized name
|
||||
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._get_concrete_database(database)
|
||||
|
||||
# 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)
|
||||
|
||||
@staticmethod
|
||||
def list_timber_databases() -> List[str]:
|
||||
"""List all available timber databases."""
|
||||
return [db.value for db in TimberDatabase]
|
||||
|
||||
@staticmethod
|
||||
def list_steel_databases() -> List[str]:
|
||||
"""List all available steel databases."""
|
||||
return [db.value for db in SteelDatabase]
|
||||
|
||||
@staticmethod
|
||||
def list_concrete_databases() -> List[str]:
|
||||
"""List all available concrete databases."""
|
||||
return [db.value for db in ConcreteDatabase]
|
||||
@@ -0,0 +1,114 @@
|
||||
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",
|
||||
"GL36h",
|
||||
"GL36h(1)",
|
||||
"GL24h",
|
||||
"GL28h",
|
||||
"GL30h",
|
||||
"GL32h",
|
||||
"GL36c",
|
||||
"GL36c(1)",
|
||||
"GL24c",
|
||||
"GL28c",
|
||||
"GL30c",
|
||||
"GL32c",
|
||||
"softwood",
|
||||
],
|
||||
"lvl": ["laminated veneer lumber"],
|
||||
"softwood lumber": [
|
||||
"dimensional lumber",
|
||||
"sawn lumber",
|
||||
"softwood",
|
||||
"FE_Wood - Dimensional Lumber",
|
||||
],
|
||||
"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)",
|
||||
"Steel ASTM A500B-42",
|
||||
],
|
||||
"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", "metal - 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",
|
||||
"Steel ASTM A500B-42",
|
||||
]
|
||||
):
|
||||
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
|
||||
@@ -0,0 +1,16 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmissionFactor:
|
||||
"""Emission factor with metadata"""
|
||||
|
||||
value: float
|
||||
unit: str # e.g., "kgCO2e/kg" or "kgCO2e/m3"
|
||||
database: str
|
||||
epd_number: Optional[str] = None
|
||||
publication_date: Optional[str] = None
|
||||
valid_until: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
plant_location: Optional[str] = None
|
||||
@@ -0,0 +1,27 @@
|
||||
# TODO: Check that the constants only get used in the applications/revit/ level
|
||||
|
||||
REQUIRED_PROPERTIES = ["volume", "density", "structuralAsset"]
|
||||
|
||||
# Keys
|
||||
APPLICATION_ID = "applicationId"
|
||||
ARC = "Objects.Geometry.Arc"
|
||||
CIRCLE = "Objects.Geometry.Circle"
|
||||
COMPRESSIVE_STRENGTH = "compressiveStrength"
|
||||
DENSITY = "density"
|
||||
ELEMENTS = "elements"
|
||||
ID = "id"
|
||||
LINE = "Objects.Geometry.Line"
|
||||
MASS = "mass"
|
||||
MATERIAL_CATEGORY = "materialCategory"
|
||||
MATERIAL_CLASS = "materialClass"
|
||||
MATERIAL_NAME = "materialName"
|
||||
MATERIAL_QUANTITIES = "Material Quantities"
|
||||
MATERIAL_TYPE = "materialType"
|
||||
NAME = "name"
|
||||
PROPERTIES = "properties"
|
||||
SOURCE_APPLICATION = "sourceApplication"
|
||||
SPECKLE_TYPE = "speckle_type"
|
||||
STRUCTURAL_ASSET = "structuralAsset"
|
||||
UNITS = "units"
|
||||
VALUE = "value"
|
||||
VOLUME = "volume"
|
||||
@@ -0,0 +1,57 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
class MaterialType(Enum):
|
||||
CONCRETE = "Concrete"
|
||||
METAL = "Metal"
|
||||
WOOD = "Wood"
|
||||
|
||||
class ElementCategory(Enum):
|
||||
SLAB = "Slabs"
|
||||
WALL = "Walls"
|
||||
COLUMN = "Columns"
|
||||
BEAM = "Beams"
|
||||
FOUNDATION = "Foundations"
|
||||
|
||||
@dataclass
|
||||
class MaterialProperties:
|
||||
name: str
|
||||
volume: float
|
||||
density: Optional[float] = None
|
||||
structural_asset: Optional[str] = None
|
||||
compressive_strength: Optional[float] = None
|
||||
|
||||
@dataclass
|
||||
class Material:
|
||||
type: MaterialType
|
||||
properties: MaterialProperties
|
||||
grade: Optional[str] = None
|
||||
mass: Optional[float] = None
|
||||
|
||||
@dataclass
|
||||
class BuildingElement:
|
||||
id: str
|
||||
level: str
|
||||
category: ElementCategory
|
||||
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
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,83 @@
|
||||
import structlog
|
||||
from typing import Dict, Set, Optional
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class Logging:
|
||||
"""Implements Logger interface with category-based logging"""
|
||||
|
||||
def __init__(self):
|
||||
self._structlog = structlog.get_logger()
|
||||
self._errors: Dict[str, Set[str]] = defaultdict(set)
|
||||
self._warnings: Dict[str, Set[str]] = defaultdict(set)
|
||||
self._successes: Dict[str, Set[str]] = defaultdict(set)
|
||||
self._info: Dict[str, Set[str]] = defaultdict(set)
|
||||
|
||||
def log_error(
|
||||
self, object_id: str, category: str, message: Optional[str] = None
|
||||
) -> None:
|
||||
"""Log an error for a specific object under a category"""
|
||||
self._errors[category].add(object_id)
|
||||
if message:
|
||||
self._structlog.error(message, object_id=object_id, category=category)
|
||||
else:
|
||||
self._structlog.error(
|
||||
"Error logged", object_id=object_id, category=category
|
||||
)
|
||||
|
||||
def log_warning(
|
||||
self, object_id: str, category: str, message: Optional[str] = None
|
||||
) -> None:
|
||||
"""Log a warning for a specific object under a category"""
|
||||
self._warnings[category].add(object_id)
|
||||
if message:
|
||||
self._structlog.warning(message, object_id=object_id, category=category)
|
||||
else:
|
||||
self._structlog.warning(
|
||||
"Warning logged", object_id=object_id, category=category
|
||||
)
|
||||
|
||||
def log_success(
|
||||
self, object_id: str, category: str, message: Optional[str] = None
|
||||
) -> None:
|
||||
"""Log a success for a specific object under a category"""
|
||||
self._successes[category].add(object_id)
|
||||
if message:
|
||||
self._structlog.info(message, object_id=object_id, category=category)
|
||||
else:
|
||||
self._structlog.info(
|
||||
"Success logged", object_id=object_id, category=category
|
||||
)
|
||||
|
||||
def log_info(
|
||||
self, object_id: str, category: str, message: Optional[str] = None
|
||||
) -> None:
|
||||
"""Log information for a specific object under a category"""
|
||||
self._info[category].add(object_id)
|
||||
if message:
|
||||
self._structlog.info(message, object_id=object_id, category=category)
|
||||
else:
|
||||
self._structlog.info(
|
||||
"Information logged", object_id=object_id, category=category
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _convert_sets_to_lists(data: Dict[str, Set[str]]) -> Dict[str, list]:
|
||||
"""Convert set values to lists in dictionary"""
|
||||
return {category: list(objects) for category, objects in data.items()}
|
||||
|
||||
def get_warnings_summary(self) -> Dict[str, list]:
|
||||
"""Get all warnings grouped by category"""
|
||||
return self._convert_sets_to_lists(self._warnings)
|
||||
|
||||
def get_errors_summary(self) -> Dict[str, list]:
|
||||
"""Get all errors grouped by category"""
|
||||
return self._convert_sets_to_lists(self._errors)
|
||||
|
||||
def get_success_summary(self) -> Dict[str, list]:
|
||||
"""Get all successes grouped by category"""
|
||||
return self._convert_sets_to_lists(self._successes)
|
||||
|
||||
def get_info_summary(self) -> Dict[str, list]:
|
||||
"""Get all info logs grouped by category"""
|
||||
return self._convert_sets_to_lists(self._info)
|
||||
@@ -1,28 +0,0 @@
|
||||
import structlog
|
||||
from typing import Dict, DefaultDict
|
||||
from collections import defaultdict
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class ComplianceLogger:
|
||||
def __init__(self):
|
||||
self.missing_properties: DefaultDict[str, set] = defaultdict(set)
|
||||
|
||||
def log_missing_properties(self, object_id: str, missing_property: str) -> None:
|
||||
# Log to our collection for automation results
|
||||
self.missing_properties[missing_property].add(object_id)
|
||||
|
||||
# Still log individual cases for dev
|
||||
logger.warn(
|
||||
"non_compliant_element",
|
||||
object_id=object_id,
|
||||
property=missing_property,
|
||||
message=f"Missing: '{missing_property}' on object {object_id}. No computation on "
|
||||
f"for this object possible. Skipped.",
|
||||
)
|
||||
|
||||
def get_summary(self) -> Dict[str, list]:
|
||||
return {
|
||||
prop: list(elements) for prop, elements in self.missing_properties.items()
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
from typing import Dict, Any
|
||||
|
||||
from src.aggregators.carbon_totals import MassAggregator
|
||||
from src.logging.compliance_logger import ComplianceLogger
|
||||
from src.utils.constants import * # wildcard is a little dangerous
|
||||
|
||||
|
||||
class CommitProcessor:
|
||||
def __init__(self):
|
||||
self.logger = ComplianceLogger()
|
||||
self.mass_aggregator = MassAggregator()
|
||||
|
||||
def process_elements(
|
||||
self, model: "Base"
|
||||
) -> None: # No return needed, we're modifying in-place
|
||||
levels = getattr(model, ELEMENTS, None)
|
||||
if not levels: # First nesting => levels
|
||||
raise ValueError("Invalid commit: missing elements at the model root.")
|
||||
|
||||
for level in levels:
|
||||
type_groups = getattr(level, ELEMENTS, None)
|
||||
if not type_groups:
|
||||
raise ValueError(
|
||||
f"Invalid level structure: missing elements in {getattr(level,NAME, '!Missing name attribute!')}"
|
||||
)
|
||||
|
||||
for type_group in type_groups:
|
||||
revit_objects = getattr(type_group, ELEMENTS, None)
|
||||
if not revit_objects:
|
||||
raise ValueError(
|
||||
f"Invalid type structure: missing elements in "
|
||||
f"{getattr(type_group, NAME, '!Missing name attribute!')}"
|
||||
)
|
||||
|
||||
level_name = getattr(level, NAME, None)
|
||||
type_name = getattr(type_group, NAME, None)
|
||||
if level_name is None or type_name is None:
|
||||
raise ValueError(
|
||||
f"Every object should be on a level and be of a type."
|
||||
)
|
||||
|
||||
for revit_object in revit_objects:
|
||||
self.process_element(
|
||||
level=level_name, type_name=type_name, revit_object=revit_object
|
||||
)
|
||||
|
||||
def process_element(
|
||||
self, level: str, type_name: str, revit_object: Dict[str, Any]
|
||||
) -> None: # Mutating in-place
|
||||
elements = getattr(revit_object, ELEMENTS, None)
|
||||
if not elements:
|
||||
self.logger.log_missing_properties(revit_object[ID], ELEMENTS)
|
||||
|
||||
for element in elements:
|
||||
properties = getattr(element, PROPERTIES, None)
|
||||
if not properties:
|
||||
self.logger.log_missing_properties(
|
||||
revit_object[ID], PROPERTIES
|
||||
) # 🤔 revit_object/element?
|
||||
return
|
||||
|
||||
material_quantities = properties.get(MATERIAL_QUANTITIES, None)
|
||||
if not material_quantities:
|
||||
self.logger.log_missing_properties(
|
||||
revit_object[ID], MATERIAL_QUANTITIES
|
||||
)
|
||||
return
|
||||
|
||||
for material_name, material_data in material_quantities.items():
|
||||
if VOLUME not in material_data:
|
||||
self.logger.log_missing_properties(revit_object[ID], VOLUME)
|
||||
return
|
||||
|
||||
if STRUCTURAL_ASSET not in material_data:
|
||||
self.logger.log_missing_properties(
|
||||
revit_object[ID], STRUCTURAL_ASSET
|
||||
)
|
||||
return
|
||||
|
||||
# ⚠️ This should never hit. No STRUCTURAL_ASSET → no DENSITY
|
||||
if DENSITY not in material_data:
|
||||
self.logger.log_missing_properties(revit_object[ID], DENSITY)
|
||||
return
|
||||
|
||||
try:
|
||||
# Dict structure for numerical properties(e.g.)
|
||||
# {"name" : "volume", "value" : 100, "units" : "Cubic metres"}
|
||||
# 🤫 Shouldn't change.
|
||||
volume = material_data[VOLUME][VALUE]
|
||||
density = material_data[DENSITY][VALUE]
|
||||
mass = volume * density
|
||||
|
||||
material_data[MASS] = {
|
||||
NAME: MASS,
|
||||
VALUE: mass,
|
||||
UNITS: material_data[DENSITY][UNITS].split()[0],
|
||||
# TODO: 🫣 Units string operation is super sketchy.
|
||||
}
|
||||
|
||||
self.mass_aggregator.add_mass(
|
||||
mass, level, type_name, material_data[STRUCTURAL_ASSET]
|
||||
)
|
||||
|
||||
# ❗ We've validated everything. If the computation fails, there's a bug.
|
||||
# 🤾 Throw.
|
||||
except (ValueError, TypeError, KeyError) as e:
|
||||
raise ValueError(
|
||||
f"Computation failed for {material_name} despite having required properties: {str(e)}"
|
||||
) from e
|
||||
@@ -0,0 +1,268 @@
|
||||
from typing import Dict, Optional, Tuple, List
|
||||
|
||||
from src.domain.carbon.concrete_reinforcement import ReinforcementRates
|
||||
from src.domain.carbon.emission_factor_registry import EmissionFactorRegistry
|
||||
from src.domain.types import (
|
||||
BuildingElement,
|
||||
CarbonResult,
|
||||
Material,
|
||||
MaterialType,
|
||||
ElementCategory,
|
||||
)
|
||||
|
||||
|
||||
class CarbonCalculator:
|
||||
"""Calculates embodied carbon for building elements."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
steel_database: str,
|
||||
timber_database: str,
|
||||
concrete_database: str,
|
||||
country: str,
|
||||
custom_reinforcement_rates: Dict[str, float],
|
||||
):
|
||||
# Store database selections
|
||||
self._steel_database = steel_database
|
||||
self._timber_database = timber_database
|
||||
self._concrete_database = concrete_database
|
||||
self._country = country
|
||||
|
||||
# Initialize registry
|
||||
self._registry = EmissionFactorRegistry()
|
||||
|
||||
# Initialize reinforcement rates with the provided dictionary
|
||||
# TODO: Validate inputs (e.g. C# int.TryParse()? )
|
||||
self._reinforcement_rates = ReinforcementRates(custom_reinforcement_rates)
|
||||
|
||||
# Cache common material factors to avoid repeated lookups
|
||||
self._steel_factors_cache = {}
|
||||
self._timber_factors_cache = {}
|
||||
self._concrete_factors_cache = {}
|
||||
|
||||
# Track missing factors
|
||||
self._missing_timber_factors = set()
|
||||
self._missing_steel_factors = set()
|
||||
self._missing_concrete_factors = set()
|
||||
|
||||
def calculate_carbon(
|
||||
self, element: BuildingElement
|
||||
) -> tuple[Dict[str, CarbonResult], List[Dict[str, str]]]:
|
||||
"""Calculate carbon emissions for an element's materials and return results and errors."""
|
||||
results = {}
|
||||
errors = []
|
||||
|
||||
for material in element.materials:
|
||||
try:
|
||||
if material.type == MaterialType.CONCRETE:
|
||||
result = self._calculate_concrete_carbon(material, element.category)
|
||||
else:
|
||||
result = self._calculate_material_carbon(material)
|
||||
results[material.properties.name] = result
|
||||
except Exception as e:
|
||||
# Track missing factors
|
||||
if "No emission factor found" in str(e):
|
||||
if material.type == MaterialType.WOOD:
|
||||
material_key = (
|
||||
material.properties.structural_asset
|
||||
or material.properties.name
|
||||
)
|
||||
self._missing_timber_factors.add(material_key)
|
||||
elif material.type == MaterialType.METAL:
|
||||
self._missing_steel_factors.add(
|
||||
material.grade or material.properties.name
|
||||
)
|
||||
elif material.type == MaterialType.CONCRETE:
|
||||
# Track missing concrete factors
|
||||
strength = str(int(material.properties.compressive_strength))
|
||||
element_type = self._map_element_category_to_concrete_type(
|
||||
element.category
|
||||
)
|
||||
self._missing_concrete_factors.add(f"{strength}_{element_type}")
|
||||
|
||||
# Store error with material name instead of just printing
|
||||
error_msg = (
|
||||
f"No emission factor found for {material.properties.name}: {str(e)}"
|
||||
)
|
||||
errors.append({"material": material.properties.name, "error": str(e)})
|
||||
|
||||
return results, errors
|
||||
|
||||
def _calculate_material_carbon(
|
||||
self, material: Material, element_category: Optional[ElementCategory] = None
|
||||
) -> CarbonResult:
|
||||
"""Calculate carbon emissions for a single material."""
|
||||
if material.type == MaterialType.METAL:
|
||||
return self._calculate_metal_carbon(material)
|
||||
elif material.type == MaterialType.WOOD:
|
||||
return self._calculate_wood_carbon(material)
|
||||
elif material.type == MaterialType.CONCRETE:
|
||||
if element_category is None:
|
||||
raise ValueError(
|
||||
"Element category is required for concrete carbon calculation"
|
||||
)
|
||||
return self._calculate_concrete_carbon(material, element_category)
|
||||
else:
|
||||
raise ValueError(f"Unsupported material type: {material.type}")
|
||||
|
||||
def _calculate_metal_carbon(self, material: Material) -> CarbonResult:
|
||||
"""Calculate carbon emissions for metal."""
|
||||
|
||||
# Get factor from cache or registry
|
||||
if material.grade not in self._steel_factors_cache:
|
||||
factor = self._registry.get_steel_factor(
|
||||
material.grade, self._steel_database
|
||||
)
|
||||
if not factor:
|
||||
raise ValueError(
|
||||
f"No emission factor found for metal grade: {material.grade}"
|
||||
)
|
||||
self._steel_factors_cache[material.grade] = factor
|
||||
|
||||
factor = self._steel_factors_cache[material.grade]
|
||||
return CarbonResult(
|
||||
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:
|
||||
"""Calculate carbon emissions for wood."""
|
||||
material_name = material.properties.structural_asset
|
||||
|
||||
# Use name as a fallback if structural_asset is None
|
||||
if material_name is None:
|
||||
# Extract material type from name
|
||||
material_name = material.properties.name
|
||||
|
||||
# Get factor from cache or registry
|
||||
if material_name not in self._timber_factors_cache:
|
||||
factor = self._registry.get_timber_factor(
|
||||
material_name, self._timber_database
|
||||
)
|
||||
if not factor:
|
||||
raise ValueError(
|
||||
f"No emission factor found for wood type: {material_name}"
|
||||
)
|
||||
self._timber_factors_cache[material_name] = factor
|
||||
|
||||
factor = self._timber_factors_cache[material_name]
|
||||
return CarbonResult(
|
||||
factor=factor.value,
|
||||
total_carbon=material.properties.volume * factor.value,
|
||||
category="Wood",
|
||||
quantity=material.properties.volume,
|
||||
database=self._timber_database,
|
||||
)
|
||||
|
||||
def _calculate_concrete_carbon(
|
||||
self, material: Material, element_category: ElementCategory
|
||||
) -> CarbonResult:
|
||||
"""Calculate carbon emissions for concrete, including reinforcement."""
|
||||
if not material.properties.compressive_strength:
|
||||
raise ValueError(
|
||||
"Compressive strength required for concrete carbon calculation"
|
||||
)
|
||||
|
||||
# Handle unit conversion based on country
|
||||
# For US, convert PSI to MPa if needed
|
||||
strength_value = material.properties.compressive_strength
|
||||
if self._country == "USA":
|
||||
# Check if value is in PSI (typically large numbers)
|
||||
if strength_value > 100: # Assume PSI
|
||||
strength_value = strength_value / 145.038 # Convert PSI to MPa
|
||||
|
||||
# Round to nearest valid strength category (25, 30, 35, 40, 45, 50)
|
||||
valid_strengths = [25, 30, 35, 40, 45, 50]
|
||||
strength_mpa = min(valid_strengths, key=lambda x: abs(x - strength_value))
|
||||
strength = str(strength_mpa)
|
||||
|
||||
# Map element category to concrete element type for the database
|
||||
element_type = self._map_element_category_to_concrete_type(element_category)
|
||||
|
||||
# Create cache key for concrete factors
|
||||
concrete_cache_key = f"{strength}_{element_type}"
|
||||
|
||||
# Get concrete factor
|
||||
if concrete_cache_key not in self._concrete_factors_cache:
|
||||
try:
|
||||
factor = self._registry.get_concrete_factor(
|
||||
strength, element_type, self._concrete_database
|
||||
)
|
||||
if not factor:
|
||||
self._missing_concrete_factors.add(concrete_cache_key)
|
||||
raise ValueError(
|
||||
f"No emission factor found for concrete: strength={strength}, element={element_type}"
|
||||
)
|
||||
self._concrete_factors_cache[concrete_cache_key] = factor
|
||||
except Exception as e:
|
||||
self._missing_concrete_factors.add(concrete_cache_key)
|
||||
raise ValueError(f"Error getting concrete factor: {str(e)}")
|
||||
|
||||
concrete_factor = self._concrete_factors_cache[concrete_cache_key]
|
||||
concrete_volume = material.properties.volume
|
||||
concrete_carbon = concrete_volume * concrete_factor.value
|
||||
|
||||
# Calculate reinforcement carbon
|
||||
reinforcement_rate = self._reinforcement_rates.get_rate(element_type)
|
||||
reinforcement_mass = (
|
||||
concrete_volume * reinforcement_rate / 1000
|
||||
) # Convert kg to tons if needed
|
||||
|
||||
# Get rebar factor from steel database
|
||||
if "Rebar" not in self._steel_factors_cache:
|
||||
rebar_factor = self._registry.get_steel_factor(
|
||||
"Rebar", self._steel_database
|
||||
)
|
||||
if not rebar_factor:
|
||||
raise ValueError("No emission factor found for rebar")
|
||||
self._steel_factors_cache["Rebar"] = rebar_factor
|
||||
|
||||
rebar_factor = self._steel_factors_cache["Rebar"]
|
||||
reinforcement_carbon = reinforcement_mass * rebar_factor.value
|
||||
|
||||
# Total carbon is concrete + reinforcement
|
||||
total_carbon = concrete_carbon + reinforcement_carbon
|
||||
|
||||
# Create result with additional metadata
|
||||
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,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _map_element_category_to_concrete_type(
|
||||
element_category: ElementCategory,
|
||||
) -> str:
|
||||
"""Map BuildingElement category to concrete element type for database lookup."""
|
||||
|
||||
# Default mappings
|
||||
category_mapping = {
|
||||
ElementCategory.SLAB: "Slab",
|
||||
ElementCategory.WALL: "Wall",
|
||||
ElementCategory.COLUMN: "Column",
|
||||
ElementCategory.BEAM: "Beam",
|
||||
ElementCategory.FOUNDATION: "Foundation",
|
||||
}
|
||||
|
||||
# Return the mapped type or default to "Beam" if unknown
|
||||
return category_mapping.get(element_category, "Beam")
|
||||
|
||||
def get_missing_factors(self) -> Tuple[List[str], List[str], List[str]]:
|
||||
"""Return lists of materials that had no emission factor."""
|
||||
return (
|
||||
sorted(list(self._missing_timber_factors)),
|
||||
sorted(list(self._missing_steel_factors)),
|
||||
sorted(list(self._missing_concrete_factors)),
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
from typing import Optional, List, Union
|
||||
|
||||
from src.domain.types import BuildingElement, ElementCategory, Material
|
||||
from src.infrastructure.logging import Logging
|
||||
from src.services.material_processor import MaterialProcessor
|
||||
|
||||
|
||||
class ElementProcessor:
|
||||
"""Processes Revit building elements."""
|
||||
|
||||
SKIP_TYPES = [
|
||||
"Objects.Geometry.Line",
|
||||
"Objects.Geometry.Arc",
|
||||
"Objects.Geometry.Circle",
|
||||
]
|
||||
|
||||
SKIP_FAMILIES = ["Grid", "JS_SF_Centerline Only", "none"]
|
||||
|
||||
def __init__(self, material_processor: MaterialProcessor, logger: Logging):
|
||||
self.material_processor = material_processor
|
||||
self.logger = logger
|
||||
|
||||
def process_element(self, element: dict) -> Optional[BuildingElement]:
|
||||
"""Process a single Revit element."""
|
||||
try:
|
||||
# Skip basic geometric types
|
||||
if self.is_skipped(element):
|
||||
return None # Skipped elements return None
|
||||
|
||||
# Basic validation
|
||||
if not self.is_valid_element(element):
|
||||
return None # Invalid elements also return None, but we'll handle them differently
|
||||
|
||||
# Extract basic properties
|
||||
element_id = getattr(element, "id", "unknown")
|
||||
level = self._get_element_level(element)
|
||||
category = self._determine_category(element)
|
||||
|
||||
# Process materials
|
||||
materials = self._process_materials(element)
|
||||
|
||||
# Create building element
|
||||
return BuildingElement(
|
||||
id=element_id, level=level, category=category, materials=materials
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log_error(
|
||||
getattr(element, "id"),
|
||||
"Element Processing",
|
||||
f"Error processing element {getattr(element, 'id')}: {str(e)}",
|
||||
)
|
||||
return None
|
||||
|
||||
def is_skipped(self, element) -> bool:
|
||||
"""Skipping non-model objects."""
|
||||
if getattr(element, "speckle_type", None) in self.SKIP_TYPES:
|
||||
return True
|
||||
if getattr(element, "family", None) in self.SKIP_FAMILIES:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_valid_element(element) -> bool:
|
||||
"""Validate if element should be processed."""
|
||||
|
||||
# Must have properties
|
||||
if not hasattr(element, "properties"):
|
||||
return False
|
||||
|
||||
# Must have material quantities
|
||||
properties = getattr(element, "properties")
|
||||
if "Material Quantities" not in properties:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _get_element_level(element) -> str:
|
||||
"""Extract element level."""
|
||||
return getattr(element, "level", "Unknown")
|
||||
|
||||
@staticmethod
|
||||
def _determine_category(element: dict) -> ElementCategory:
|
||||
"""Determine element category based on type name."""
|
||||
type_name = getattr(element, "name", "").lower()
|
||||
|
||||
category_mapping = {
|
||||
"floor": ElementCategory.SLAB,
|
||||
"stair": ElementCategory.SLAB,
|
||||
"slab": ElementCategory.SLAB,
|
||||
"wall": ElementCategory.WALL,
|
||||
"column": ElementCategory.COLUMN,
|
||||
"beam": ElementCategory.BEAM,
|
||||
"framing": ElementCategory.BEAM,
|
||||
"foundation": ElementCategory.FOUNDATION,
|
||||
}
|
||||
|
||||
for key, category in category_mapping.items():
|
||||
if key in type_name:
|
||||
return category
|
||||
|
||||
return ElementCategory.BEAM # Default category
|
||||
|
||||
def _process_materials(self, element) -> List[Material]:
|
||||
"""Process all materials in the element."""
|
||||
materials = []
|
||||
properties = getattr(element, "properties")
|
||||
material_quantities = properties["Material Quantities"]
|
||||
|
||||
for material_data in material_quantities.values():
|
||||
try:
|
||||
material = self.material_processor.process_material(material_data)
|
||||
materials.append(material)
|
||||
except Exception as e:
|
||||
self.logger.log_warning(
|
||||
getattr(element, "id"),
|
||||
"Material Processing",
|
||||
f"Failed to process material in element {getattr(element, 'id')}: {str(e)}",
|
||||
)
|
||||
|
||||
return materials
|
||||
@@ -0,0 +1,93 @@
|
||||
from typing import Dict, Any
|
||||
|
||||
from src.domain.types import MaterialProperties, Material, MaterialType
|
||||
|
||||
|
||||
class MaterialProcessor:
|
||||
"""Processes Revit materials and calculates quantities."""
|
||||
|
||||
DEFAULT_CONCRETE_GRADE = "35"
|
||||
DEFAULT_STEEL_DENSITY = 7851.81483993 # kg/m3
|
||||
|
||||
def process_material(self, raw_material: Dict[str, Any]) -> Material:
|
||||
"""Process raw material data from Revit into domain model."""
|
||||
properties = MaterialProperties(
|
||||
name=raw_material["materialName"],
|
||||
volume=raw_material["volume"]["value"],
|
||||
density=raw_material.get("density", {}).get("value"),
|
||||
structural_asset=raw_material.get("structuralAsset"),
|
||||
compressive_strength=raw_material.get("compressiveStrength", {}).get(
|
||||
"value"
|
||||
),
|
||||
)
|
||||
|
||||
# Determine material type and create material
|
||||
if self._is_high_grade_material(raw_material):
|
||||
return self._process_high_grade_material(properties)
|
||||
else:
|
||||
return self._process_low_grade_material(properties)
|
||||
|
||||
@staticmethod
|
||||
def _is_high_grade_material(raw_material: Dict[str, Any]) -> bool:
|
||||
return "structuralAsset" in raw_material
|
||||
|
||||
def _process_high_grade_material(self, props: MaterialProperties) -> Material:
|
||||
"""Process materials with structural assets."""
|
||||
if "concrete" in props.name.lower():
|
||||
return self._process_concrete(props)
|
||||
elif "steel" in props.name.lower() or "metal" in props.name.lower():
|
||||
return self._process_steel(props)
|
||||
elif (
|
||||
"clt" in props.name.lower()
|
||||
or "timber" in props.name.lower()
|
||||
or "glulam" in props.name.lower()
|
||||
):
|
||||
return Material(type=MaterialType.WOOD, properties=props)
|
||||
else:
|
||||
raise ValueError(f"Unknown high-grade material: {props.name}")
|
||||
|
||||
def _process_low_grade_material(self, props: MaterialProperties) -> Material:
|
||||
"""Process materials without structural assets."""
|
||||
name = props.name.lower()
|
||||
|
||||
if "concrete" in name:
|
||||
return Material(
|
||||
type=MaterialType.CONCRETE,
|
||||
properties=props,
|
||||
grade=self.DEFAULT_CONCRETE_GRADE,
|
||||
)
|
||||
elif "steel" in name:
|
||||
mass = props.volume * self.DEFAULT_STEEL_DENSITY
|
||||
return Material(
|
||||
type=MaterialType.METAL,
|
||||
properties=props,
|
||||
mass=mass,
|
||||
grade="default_steel",
|
||||
)
|
||||
elif "clt" in name or "timber" in name or "wood" in name:
|
||||
return Material(type=MaterialType.WOOD, properties=props)
|
||||
else:
|
||||
raise ValueError(f"Unknown material type: {props.name}")
|
||||
|
||||
@staticmethod
|
||||
def _process_concrete(props: MaterialProperties) -> Material:
|
||||
"""Process concrete-specific properties."""
|
||||
if not props.compressive_strength:
|
||||
raise ValueError("Missing compressive strength for concrete")
|
||||
|
||||
grade = str(props.compressive_strength * 0.001) # Convert to MPa
|
||||
return Material(type=MaterialType.CONCRETE, properties=props, grade=grade)
|
||||
|
||||
@staticmethod
|
||||
def _process_steel(props: MaterialProperties) -> Material:
|
||||
"""Process steel-specific properties."""
|
||||
if not props.density:
|
||||
raise ValueError("Missing density for steel")
|
||||
|
||||
mass = props.volume * props.density
|
||||
return Material(
|
||||
type=MaterialType.METAL,
|
||||
properties=props,
|
||||
mass=mass,
|
||||
grade=props.structural_asset,
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
REQUIRED_PROPERTIES = ["volume", "density", "structuralAsset"]
|
||||
|
||||
# Keys
|
||||
DENSITY = "density"
|
||||
ELEMENTS = "elements"
|
||||
ID = "id"
|
||||
MASS = "mass"
|
||||
MATERIAL_QUANTITIES = "Material Quantities"
|
||||
NAME = "name"
|
||||
PROPERTIES = "properties"
|
||||
SOURCE_APPLICATION = "sourceApplication"
|
||||
STRUCTURAL_ASSET = "structuralAsset"
|
||||
UNITS = "units"
|
||||
VALUE = "value"
|
||||
VOLUME = "volume"
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Run integration tests with a speckle server."""
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from speckle_automate import (
|
||||
AutomationContext,
|
||||
AutomationRunData,
|
||||
AutomationStatus,
|
||||
run_function
|
||||
run_function,
|
||||
)
|
||||
|
||||
from main import FunctionInputs, automate_function
|
||||
@@ -14,7 +13,9 @@ from main import FunctionInputs, automate_function
|
||||
from speckle_automate.fixtures import *
|
||||
|
||||
|
||||
def test_function_run(test_automation_run_data: AutomationRunData, test_automation_token: str):
|
||||
def test_function_run(
|
||||
test_automation_run_data: AutomationRunData, test_automation_token: str
|
||||
):
|
||||
"""Run an integration test for the automate function."""
|
||||
automation_context = AutomationContext.initialize(
|
||||
test_automation_run_data, test_automation_token
|
||||
@@ -22,10 +23,7 @@ def test_function_run(test_automation_run_data: AutomationRunData, test_automati
|
||||
automate_sdk = run_function(
|
||||
automation_context,
|
||||
automate_function,
|
||||
FunctionInputs(
|
||||
forbidden_speckle_type="None",
|
||||
whisper_message=SecretStr("testing automatically"),
|
||||
),
|
||||
FunctionInputs(),
|
||||
)
|
||||
|
||||
assert automate_sdk.run_status == AutomationStatus.SUCCEEDED
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import pytest
|
||||
|
||||
from src.domain.carbon.databases.enums import TimberDatabase, SteelDatabase
|
||||
from src.domain.carbon.emission_factor_registry import EmissionFactorRegistry
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
"""Test suite for the EmissionFactorRegistry"""
|
||||
|
||||
@pytest.fixture
|
||||
def registry(self):
|
||||
"""Create and return a registry instance"""
|
||||
return EmissionFactorRegistry()
|
||||
|
||||
def test_timber_database_lookup(self, registry):
|
||||
"""Test direct lookup of timber factors"""
|
||||
# Test each database
|
||||
factor = registry.get_timber_factor(
|
||||
"FE_CLT Floor Panel (1)", TimberDatabase.Athena2021.value
|
||||
)
|
||||
assert factor is not None
|
||||
assert factor.value == 69
|
||||
|
||||
factor = registry.get_timber_factor(
|
||||
"FE_Glulam", TimberDatabase.Binderholz2019.value
|
||||
)
|
||||
assert factor is not None
|
||||
assert factor.value == 118
|
||||
|
||||
def test_steel_database_lookup(self, registry):
|
||||
"""Test direct lookup of steel factors"""
|
||||
factor = registry.get_steel_factor(
|
||||
"Metal - Steel CSA G40", SteelDatabase.Type350MPa.value
|
||||
)
|
||||
assert factor is not None
|
||||
assert factor.value == 1.22
|
||||
|
||||
def test_invalid_database(self, registry):
|
||||
"""Test error handling for invalid database"""
|
||||
with pytest.raises(ValueError, match="Unknown timber database"):
|
||||
registry.get_timber_factor("CLT", "NonExistentDatabase")
|
||||
Reference in New Issue
Block a user