55 Commits

Author SHA1 Message Date
dependabot[bot] cd4cec36c7 build(deps): bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-05 03:12:51 +00:00
Chuck Driesler ecd4740333 Merge pull request #14 from specklesystems/charles/differentIterator
fix(objects): more generic iteration of objects
2025-04-10 10:33:02 +01:00
Björn 373814cc25 feat: propert next-gen check 2025-04-10 09:35:22 +02:00
Björn 1fafb470e2 fix: errors with pdf when working with linked commit 2025-04-10 09:34:58 +02:00
Chuck Driesler b1c32bf5d3 fix(objects): more generic iteration of objects 2025-04-09 21:56:54 +01:00
Björn Steinhagen 98341cc99f fix: swallowed exceptions
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2025-02-27 23:04:16 +01:00
Björn Steinhagen 53fbfc0139 chore: taking out unused imports 2025-02-27 15:21:41 +01:00
Björn Steinhagen 6279d9569b Merge pull request #13 from bjoernsteinhagen/chuck/pdf
feat(pdf): demonstrate creating and writing a pdf file
2025-02-27 10:19:46 +01:00
Chuck Driesler ce05ec6862 feat(pdf): demonstrate creating and writing a pdf file 2025-02-26 20:27:04 +00:00
Björn Steinhagen 669c1372c3 Merge pull request #12 from bjoernsteinhagen/bjorn/gro-117-output-a-model-quality-metric
feat: model metric
2025-02-26 14:54:27 +01:00
Björn Steinhagen 70a5f06b2e feat: model metric 2025-02-26 14:53:33 +01:00
Björn Steinhagen 0908d9332c Merge pull request #11 from bjoernsteinhagen/bjorn/missing-aliases
fix: missing steel material alias
2025-02-25 16:32:10 +01:00
Björn Steinhagen 94b4cf7072 fix: missing steel material alias 2025-02-25 16:31:42 +01:00
Björn Steinhagen 914cc711b0 Merge pull request #10 from bjoernsteinhagen/bjorn/gro-116-write-ec-as-properties-on-objects
feat: write ec as properties on objects
2025-02-25 15:40:07 +01:00
Björn Steinhagen f19e1f3a3c feat: add embodied carbon calculation fields to objets 2025-02-25 15:37:23 +01:00
Björn Steinhagen 9509100f30 refactor: Implement proper dependency injection
- Remove direct instantiation of dependencies in RevitCarbonAnalyzer
- Create and inject dependencies in the main automate_function
- Improve constructor documentation
2025-02-25 13:27:20 +01:00
Björn Steinhagen 3c15953b76 refactor: Improve EmissionFactorRegistry with Single Responsibility Pattern
- Extract material name normalization to MaterialAliasService
- Create DatabaseFactory for centralized database creation
- Add lazy loading for database instances
- Implement LRU caching for performance optimization
- Fix type hints for concrete database handling
2025-02-25 13:14:07 +01:00
Björn Steinhagen f1ebf4ce39 Merge pull request #9 from bjoernsteinhagen/bjorn/web-2678-compute-concrete-embodied-carbon
feature: Implement embodied carbon calculation for concrete materials
2025-02-25 10:38:38 +01:00
Björn Steinhagen c31ab5a303 fix: enums being passed instead of strings 2025-02-25 10:30:27 +01:00
Björn Steinhagen 2116e3afb7 feat: inserting concrete architecture part 2 2025-02-25 10:20:35 +01:00
Björn Steinhagen 6ea1ff65e2 feat: concrete function inputs 2025-02-25 10:01:36 +01:00
Björn Steinhagen 726233334e feat: inserting concrete architecture part 1 2025-02-25 09:54:15 +01:00
Björn Steinhagen d609a0670e feat: add concrete database 2025-02-25 09:26:41 +01:00
Björn Steinhagen e7c73d0681 Merge pull request #8 from bjoernsteinhagen/bjorn/web-2680-setup-databases
feat: database-driven carbon factors with material normalization and improved logging
2025-02-25 08:15:20 +01:00
Björn Steinhagen e92066a8de feat: better logging for matches 2025-02-24 17:36:02 +01:00
Björn Steinhagen 665b26e823 test: add tests for lookup 2025-02-24 16:45:44 +01:00
Björn Steinhagen 1010caf8a7 chore: remove print statements 2025-02-24 15:17:25 +01:00
Björn Steinhagen 2020048dca feat: add databases 2025-02-24 15:11:54 +01:00
Björn Steinhagen 973accd2be feat: introducing function inputs for databases 2025-02-24 13:49:10 +01:00
Björn Steinhagen 69799fb7f9 feat: introducing databases to carbon_calculator 2025-02-24 13:25:42 +01:00
Björn Steinhagen 3ab35a6d31 Merge pull request #7 from bjoernsteinhagen/bjorn/refactor
refactor: simplification and optimization
2025-02-24 12:47:53 +01:00
Björn Steinhagen 907236e07f refactor(logic): everything 2025-02-24 10:46:10 +01:00
Björn Steinhagen 3e8d80dd21 refactor(revit_model): cleanup
first of many
2025-02-23 22:27:11 +01:00
Björn Steinhagen d130815f20 Merge pull request #6 from bjoernsteinhagen/charles/carbon_processor
Charles/carbon processor
2025-02-23 21:27:54 +01:00
Chuck Driesler 6f80f98409 calculate embodied carbon for metals 2025-02-18 17:38:51 +00:00
Chuck Driesler 6723aca9b8 build tweaks 2025-02-13 23:33:57 +00:00
Chuck Driesler 9f1b715def feat(carbon): sketch carbon processor 2025-02-13 21:14:39 +00:00
Chuck Driesler 6ee044a877 Merge pull request #5 from bjoernsteinhagen/bjorn/web-2672-implement-tiered-material-to-ec-factor-mapping-with-fallback
feat: mutate object with enough info for ecf lookup
2025-02-13 18:39:18 +00:00
Björn Steinhagen 241c420655 feat: mutating object 2025-02-13 17:32:43 +01:00
Björn Steinhagen 7f4610ff42 feat: phased processing approach 2025-02-13 02:25:53 +01:00
Björn Steinhagen 5f9d9a519e Merge pull request #4 from bjoernsteinhagen/bjorn/proper-next-gen-validation
style: checking for next-gen more stable
2025-02-12 23:11:34 +01:00
Björn Steinhagen c00b56959d style: checking for next-gen more stable 2025-02-12 23:10:42 +01:00
Björn Steinhagen e417d1e218 Merge pull request #1 from bjoernsteinhagen/dependabot/github_actions/specklesystems/speckle-automate-github-composite-action-0.9.0
Bump specklesystems/speckle-automate-github-composite-action from 0.8.1 to 0.9.0
2025-02-10 16:43:14 +01:00
Björn Steinhagen c351ac5df6 Merge pull request #2 from bjoernsteinhagen/dependabot/github_actions/actions/checkout-4.2.2
Bump actions/checkout from 4.1.7 to 4.2.2
2025-02-10 16:42:57 +01:00
Björn Steinhagen b09490fa78 Merge pull request #3 from bjoernsteinhagen/bjorn/refactor-core-and-domain 2025-02-10 15:29:41 +01:00
Björn Steinhagen f0692247f6 fix: removed redundant / duplicate logger 2025-02-10 14:51:09 +01:00
Björn Steinhagen 0d6efac003 docs: some thoughts on process_elements 2025-02-10 09:59:34 +01:00
Björn Steinhagen f6815bdfd0 refactor: cleaner architecture 2025-02-10 09:25:51 +01:00
Björn Steinhagen 809bfa4102 refactor: RevitModelProcessor 2025-02-10 08:53:42 +01:00
Björn Steinhagen 9d46562419 docs: class and method docs 2025-02-10 08:30:15 +01:00
Björn Steinhagen 9ca9a1910b feat: skipping lines, arcs and circles 2025-02-09 23:17:35 +01:00
Björn Steinhagen 25bd0093af fix: model groups
→ Model groups being a pain
→ Accessing some objects too shallow
2025-02-09 23:05:55 +01:00
Björn Steinhagen 7f49d0db3b refactor: from dirty poc to a clean solution
→ poc was not so solid 😏
→ refactor for better architecture and maintainability
2025-02-09 15:11:38 +01:00
dependabot[bot] 1abd27a5f5 Bump actions/checkout from 4.1.7 to 4.2.2
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.7 to 4.2.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.1.7...v4.2.2)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-06 15:55:10 +00:00
dependabot[bot] fd22ed1872 Bump specklesystems/speckle-automate-github-composite-action
Bumps [specklesystems/speckle-automate-github-composite-action](https://github.com/specklesystems/speckle-automate-github-composite-action) from 0.8.1 to 0.9.0.
- [Release notes](https://github.com/specklesystems/speckle-automate-github-composite-action/releases)
- [Commits](https://github.com/specklesystems/speckle-automate-github-composite-action/compare/0.8.1...0.9.0)

---
updated-dependencies:
- dependency-name: specklesystems/speckle-automate-github-composite-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-06 15:55:08 +00:00
43 changed files with 3309 additions and 918 deletions
+3 -3
View File
@@ -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 }}
+1
View File
@@ -313,3 +313,4 @@ pyrightconfig.json
.ionide
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,pycharm
report.pdf
+642 -35
View File
@@ -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": "",
},
"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": "",
},
"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
View File
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -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]
-32
View File
@@ -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
+20
View File
@@ -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)}"
)
+24
View File
@@ -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]
+114
View File
@@ -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
+16
View File
@@ -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
+27
View File
@@ -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"
+57
View File
@@ -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
View File
+83
View File
@@ -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)
-28
View File
@@ -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()
}
-109
View File
@@ -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
View File
+268
View File
@@ -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)),
)
+122
View File
@@ -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
+93
View File
@@ -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,
)
-15
View File
@@ -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"
+5 -7
View File
@@ -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
+41
View File
@@ -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")