feat: commit processing and mass calcs
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled

→ processes next-gen revit commit
→ logs any objects missing identified attributes
→ computes mass for objects with valid attributes
This commit is contained in:
Björn Steinhagen
2025-02-09 12:48:08 +01:00
parent 0d066cee9a
commit a81dc28ed1
23 changed files with 377 additions and 220 deletions
-4
View File
@@ -1,4 +0,0 @@
SPECKLE_TOKEN="mytoken"
SPECKLE_SERVER_URL="http://127.0.0.1:3000"
SPECKLE_PROJECT_ID=""
SPECKLE_AUTOMATION_ID=""
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="enabledOnReformat" value="true" />
<option name="enabledOnSave" value="true" />
<option name="pathToExecutable" value="C:\Users\stein\pipx\venvs\poetry\Scripts\black.exe" />
<option name="sdkName" value="Poetry (Embodied-Carbon-Calculator)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Poetry (Embodied-Carbon-Calculator)" project-jdk-type="Python SDK" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Embodied-Carbon-Calculator.iml" filepath="$PROJECT_DIR$/.idea/Embodied-Carbon-Calculator.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+1 -100
View File
@@ -1,100 +1 @@
# Speckle Automate function template - Python
This template repository is for a Speckle Automate function written in Python
using the [specklepy](https://pypi.org/project/specklepy/) SDK to interact with Speckle data.
This template contains the full scaffolding required to publish a function to the Automate environment.
It also has some sane defaults for development environment setups.
## Getting started
1. Use this template repository to create a new repository in your own / organization's profile.
Register the function
### Add new dependencies
To add new Python package dependencies to the project, use the following:
`$ poetry add pandas`
### Change launch variables
Describe how the launch.json should be edited.
### Github Codespaces
Create a new repo from this template, and use the create new code.
### Using this Speckle Function
1. [Create](https://automate.speckle.dev/) a new Speckle Automation.
1. Select your Speckle Project and Speckle Model.
1. Select the deployed Speckle Function.
1. Enter a phrase to use in the comment.
1. Click `Create Automation`.
## Getting Started with Creating Your Own Speckle Function
1. [Register](https://automate.speckle.dev/) your Function with [Speckle Automate](https://automate.speckle.dev/) and select the Python template.
1. A new repository will be created in your GitHub account.
1. Make changes to your Function in `main.py`. See below for the Developer Requirements and instructions on how to test.
1. To create a new version of your Function, create a new [GitHub release](https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository) in your repository.
## Developer Requirements
1. Install the following:
- [Python 3](https://www.python.org/downloads/)
- [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer)
1. Run `poetry shell && poetry install` to install the required Python packages.
## Building and Testing
The code can be tested locally by running `poetry run pytest`.
### Building and running the Docker Container Image
Running and testing your code on your machine is a great way to develop your Function; the following instructions are a bit more in-depth and only required if you are having issues with your Function in GitHub Actions or on Speckle Automate.
#### Building the Docker Container Image
The GitHub Action packages your code into the format required by Speckle Automate. This is done by building a Docker Image, which Speckle Automate runs. You can attempt to build the Docker Image locally to test the building process.
To build the Docker Container Image, you must have [Docker](https://docs.docker.com/get-docker/) installed.
Once you have Docker running on your local machine:
1. Open a terminal
1. Navigate to the directory in which you cloned this repository
1. Run the following command:
```bash
docker build -f ./Dockerfile -t speckle_automate_python_example .
```
#### Running the Docker Container Image
Once the GitHub Action has built the image, it is sent to Speckle Automate. When Speckle Automate runs your Function as part of an Automation, it will run the Docker Container Image. You can test that your Docker Container Image runs correctly locally.
1. To then run the Docker Container Image, run the following command:
```bash
docker run --rm speckle_automate_python_example \
python -u main.py run \
'{"projectId": "1234", "modelId": "1234", "branchName": "myBranch", "versionId": "1234", "speckleServerUrl": "https://speckle.xyz", "automationId": "1234", "automationRevisionId": "1234", "automationRunId": "1234", "functionId": "1234", "functionName": "my function", "functionLogo": "base64EncodedPng"}' \
'{}' \
yourSpeckleServerAuthenticationToken
```
Let's explain this in more detail:
`docker run—-rm speckle_automate_python_example` tells Docker to run the Docker Container Image we built earlier. `speckle_automate_python_example` is the name of the Docker Container Image. The `--rm` flag tells Docker to remove the container after it has finished running, freeing up space on your machine.
The line `python -u main.py run` is the command run inside the Docker Container Image. The rest of the command is the arguments passed to the command. The arguments are:
- `'{"projectId": "1234", "modelId": "1234", "branchName": "myBranch", "versionId": "1234", "speckleServerUrl": "https://speckle.xyz", "automationId": "1234", "automationRevisionId": "1234", "automationRunId": "1234", "functionId": "1234", "functionName": "my function", "functionLogo": "base64EncodedPng"}'` - the metadata that describes the automation and the function.
- `{}` - the input parameters for the function the Automation creator can set. Here, they are blank, but you can add your parameters to test your function.
- `yourSpeckleServerAuthenticationToken`—the authentication token for the Speckle Server that the Automation can connect to. This is required to interact with the Speckle Server, for example, to get data from the Model.
## Resources
- [Learn](https://speckle.guide/dev/python.html) more about SpecklePy and interacting with Speckle from Python.
insert cool readme 😎 ...
-20
View File
@@ -1,20 +0,0 @@
{
"speckleToken": "YOUR SPEKCLE TOKEN",
"functionInputs": {
"whisperMessage": "you are doing something weird",
"forbiddenSpeckleType": "wall"
},
"automationRunData": {
"project_id": "project id",
"speckle_server_url": "https://latest.speckle.systems",
"automation_id": "automation id",
"automation_run_id": "automation run id",
"function_run_id": "function run id",
"triggers": [
{
"payload": { "modelId": "model id", "versionId": "version id" },
"triggerType": "versionCreation"
}
]
}
}
-27
View File
@@ -1,27 +0,0 @@
"""Helper module for a simple speckle object tree flattening."""
from collections.abc import Iterable
from specklepy.objects import Base
def flatten_base(base: Base) -> Iterable[Base]:
"""Flatten a base object into an iterable of bases.
This function recursively traverses the `elements` or `@elements` attribute of the
base object, yielding each nested base object.
Args:
base (Base): The base object to flatten.
Yields:
Base: Each nested base object in the hierarchy.
"""
# Attempt to get the elements attribute, fallback to @elements if necessary
elements = getattr(base, "elements", getattr(base, "@elements", None))
if elements is not None:
for element in elements:
yield from flatten_base(element)
yield base
+27 -68
View File
@@ -1,8 +1,3 @@
"""This module contains the function's business logic.
Use the automation_context module to wrap your function in an Automate context helper.
"""
from pydantic import Field, SecretStr
from speckle_automate import (
AutomateBase,
@@ -10,17 +5,10 @@ from speckle_automate import (
execute_automate_function,
)
from flatten import flatten_base
from src.processors.commit_processory import CommitProcessor
class FunctionInputs(AutomateBase):
"""These are function author-defined values.
Automate will make sure to supply them matching the types specified here.
Please use the pydantic model schema to define your inputs:
https://docs.pydantic.dev/latest/usage/models/
"""
# An example of how to use secret values.
whisper_message: SecretStr = Field(title="This is a secret message")
forbidden_speckle_type: str = Field(
@@ -36,68 +24,39 @@ def automate_function(
automate_context: AutomationContext,
function_inputs: FunctionInputs,
) -> None:
"""This is an example Speckle Automate function.
# 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
)
Args:
automate_context: A context-helper object that carries relevant information
about the runtime context of this function.
It gives access to the Speckle project data that triggered this run.
It also has convenient methods for attaching result data to the Speckle model.
function_inputs: An instance object matching the defined schema.
"""
# The context provides a convenient way to receive the triggering version.
version_root_object = automate_context.receive_version()
objects_with_forbidden_speckle_type = [
b
for b in flatten_base(version_root_object)
if b.speckle_type == function_inputs.forbidden_speckle_type
]
count = len(objects_with_forbidden_speckle_type)
if count > 0:
# This is how a run is marked with a failure cause.
automate_context.attach_error_to_objects(
category="Forbidden speckle_type"
f" ({function_inputs.forbidden_speckle_type})",
object_ids=[o.id for o in objects_with_forbidden_speckle_type if o.id],
message="This project should not contain the type: "
f"{function_inputs.forbidden_speckle_type}",
)
# ️ 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(
"Automation failed: "
f"Found {count} object that have one of the forbidden speckle types: "
f"{function_inputs.forbidden_speckle_type}"
f"Automation built for v3 Revit commits. These are commits with a "
f"case-sensitive sourceApplication == 'revit', not {commit_root.sourceApplication})"
)
# Set the automation context view to the original model/version view
# to show the offending objects.
automate_context.set_context_view()
# Process elements
model_root = automate_context.receive_version() # TODO: This is a waste!
processor = CommitProcessor()
processor.process_elements(model_root)
else:
automate_context.mark_run_success("No forbidden types found.")
compliance_summary = processor.logger.get_summary()
for missing_property, elements in compliance_summary.items():
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. ",
)
# If the function generates file results, this is how it can be
# attached to the Speckle project/model
# automate_context.store_file_result("./report.pdf")
# TODO: create_new_version_in_project
automate_context.mark_run_success("Under development.")
def automate_function_without_inputs(automate_context: AutomationContext) -> None:
"""A function example without inputs.
If your function does not need any input variables,
besides what the automation context provides,
the inputs argument can be omitted.
"""
pass
# make sure to call the function with the executor
if __name__ == "__main__":
# NOTE: always pass in the automate function by its reference; do not invoke it!
# Pass in the function reference with the inputs schema to the executor.
execute_automate_function(automate_function, FunctionInputs)
# If the function has no arguments, the executor can handle it like so
# execute_automate_function(automate_function_without_inputs)
Generated
+108 -1
View File
@@ -42,6 +42,17 @@ files = [
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
]
[[package]]
name = "astroid"
version = "3.3.8"
description = "An abstract syntax tree for Python with inference support."
optional = false
python-versions = ">=3.9.0"
files = [
{file = "astroid-3.3.8-py3-none-any.whl", hash = "sha256:187ccc0c248bfbba564826c26f070494f7bc964fd286b6d9fff4420e55de828c"},
{file = "astroid-3.3.8.tar.gz", hash = "sha256:a88c7994f914a4ea8572fac479459f4955eeccc877be3f2d959a33273b0cf40b"},
]
[[package]]
name = "attrs"
version = "23.2.0"
@@ -283,6 +294,21 @@ wrapt = ">=1.10,<2"
[package.extras]
dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "jinja2 (>=3.0.3,<3.1.0)", "setuptools", "sphinx (<2)", "tox"]
[[package]]
name = "dill"
version = "0.3.9"
description = "serialize all of Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a"},
{file = "dill-0.3.9.tar.gz", hash = "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c"},
]
[package.extras]
graph = ["objgraph (>=1.7.2)"]
profile = ["gprof2dot (>=2022.7.29)"]
[[package]]
name = "gql"
version = "3.5.0"
@@ -406,6 +432,32 @@ files = [
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
[[package]]
name = "isort"
version = "6.0.0"
description = "A Python utility / library to sort Python imports."
optional = false
python-versions = ">=3.9.0"
files = [
{file = "isort-6.0.0-py3-none-any.whl", hash = "sha256:567954102bb47bb12e0fae62606570faacddd441e45683968c8d1734fb1af892"},
{file = "isort-6.0.0.tar.gz", hash = "sha256:75d9d8a1438a9432a7d7b54f2d3b45cad9a4a0fdba43617d9873379704a8bdf1"},
]
[package.extras]
colors = ["colorama"]
plugins = ["setuptools"]
[[package]]
name = "mccabe"
version = "0.7.0"
description = "McCabe checker, plugin for flake8"
optional = false
python-versions = ">=3.6"
files = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
]
[[package]]
name = "multidict"
version = "6.1.0"
@@ -882,6 +934,33 @@ azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0
toml = ["tomli (>=2.0.1)"]
yaml = ["pyyaml (>=6.0.1)"]
[[package]]
name = "pylint"
version = "3.3.4"
description = "python code static checker"
optional = false
python-versions = ">=3.9.0"
files = [
{file = "pylint-3.3.4-py3-none-any.whl", hash = "sha256:289e6a1eb27b453b08436478391a48cd53bb0efb824873f949e709350f3de018"},
{file = "pylint-3.3.4.tar.gz", hash = "sha256:74ae7a38b177e69a9b525d0794bd8183820bfa7eb68cc1bee6e8ed22a42be4ce"},
]
[package.dependencies]
astroid = ">=3.3.8,<=3.4.0-dev0"
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
dill = [
{version = ">=0.3.7", markers = "python_version >= \"3.12\""},
{version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""},
]
isort = ">=4.2.5,<5.13.0 || >5.13.0,<7"
mccabe = ">=0.6,<0.8"
platformdirs = ">=2.2.0"
tomlkit = ">=0.10.1"
[package.extras]
spelling = ["pyenchant (>=3.2,<4.0)"]
testutils = ["gitpython (>3)"]
[[package]]
name = "pytest"
version = "7.4.4"
@@ -1019,6 +1098,34 @@ files = [
{file = "stringcase-1.2.0.tar.gz", hash = "sha256:48a06980661908efe8d9d34eab2b6c13aefa2163b3ced26972902e3bdfd87008"},
]
[[package]]
name = "structlog"
version = "25.1.0"
description = "Structured Logging for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "structlog-25.1.0-py3-none-any.whl", hash = "sha256:843fe4f254540329f380812cbe612e1af5ec5b8172205ae634679cd35a6d6321"},
{file = "structlog-25.1.0.tar.gz", hash = "sha256:2ef2a572e0e27f09664965d31a576afe64e46ac6084ef5cec3c2b8cd6e4e3ad3"},
]
[package.extras]
dev = ["freezegun (>=0.2.8)", "mypy (>=1.4)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "rich", "simplejson", "twisted"]
docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-mermaid", "sphinxext-opengraph", "twisted"]
tests = ["freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"]
typing = ["mypy (>=1.4)", "rich", "twisted"]
[[package]]
name = "tomlkit"
version = "0.13.2"
description = "Style preserving TOML library"
optional = false
python-versions = ">=3.8"
files = [
{file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"},
{file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"},
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
@@ -1386,4 +1493,4 @@ propcache = ">=0.2.0"
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "016af7c16ab48a41f716ccec40a277fee96b42bb15dc790ac1f6b2c0fc9920a4"
content-hash = "1251201031b01adfe8b5f59d340deb29175d98c2d372a34dd1f883df16dec333"
+2
View File
@@ -9,6 +9,8 @@ package-mode = false
[tool.poetry.dependencies]
python = "^3.11"
specklepy = "^2.21.0"
pylint = "^3.3.4"
structlog = "^25.1.0"
[tool.poetry.group.dev.dependencies]
black = "^23.3.0"
View File
View File
+32
View File
@@ -0,0 +1,32 @@
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()
},
}
View File
+28
View File
@@ -0,0 +1,28 @@
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()
}
View File
+109
View File
@@ -0,0 +1,109 @@
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
+15
View File
@@ -0,0 +1,15 @@
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"