bilt-exercise-4
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled

This commit is contained in:
Jonathon Broughton
2024-05-07 23:22:36 +00:00
parent 04d53bb458
commit a999f18e00
2 changed files with 319 additions and 175 deletions
+20 -92
View File
@@ -4,6 +4,12 @@ from Utilities.helpers import flatten_base, speckle_print
from rules import RevitRules from rules import RevitRules
from Utilities.helpers import flatten_base, speckle_print
from Utilities.spreadsheet import read_rules_from_spreadsheet
from Workshop.Exercise_4.rules import apply_rules_to_objects
class FunctionInputs(AutomateBase): class FunctionInputs(AutomateBase):
"""These are function author defined values. """These are function author defined values.
@@ -12,16 +18,13 @@ class FunctionInputs(AutomateBase):
https://docs.pydantic.dev/latest/usage/models/ https://docs.pydantic.dev/latest/usage/models/
""" """
# In this exercise, we will add two new input fields to the FunctionInputs class. # In this exercise, we will move rules to an external source so not to hardcode them.
category: str = Field( spreadsheet_url: str = Field(
title="Revit Category", title="Spreadsheet URL",
description="This is the category objects to check.", description="This is the URL of the spreadsheet to check. It should be a TSV format data source.",
)
property: str = Field(
title="Property Name",
description="This is the property to check.",
) )
def automate_function( def automate_function(
automate_context: AutomationContext, automate_context: AutomationContext,
function_inputs: FunctionInputs, function_inputs: FunctionInputs,
@@ -42,95 +45,20 @@ def automate_function(
# We can continue to work with a flattened list of objects. # We can continue to work with a flattened list of objects.
flat_list_of_objects = list(flatten_base(version_root_object)) flat_list_of_objects = list(flatten_base(version_root_object))
# filter to only include objects that are in the specified category # read the rules from the spreadsheet
in_category_objects = [ rules = read_rules_from_spreadsheet(function_inputs.spreadsheet_url)
speckle_object
for speckle_object in flat_list_of_objects
if RevitRules.is_category(speckle_object, function_inputs.category)
]
# check if the property exists on the objects # apply the rules to the objects
non_property_objects = [ apply_rules_to_objects(flat_list_of_objects, rules, automate_context)
obj
for obj in in_category_objects
if not RevitRules.has_parameter(obj, function_inputs.property)
]
property_objects = [
obj
for obj in in_category_objects
if RevitRules.has_parameter(obj, function_inputs.property)
]
# property_objects should be those where while the property is present,
# is not an empty string or the default value
valid_property_objects = [
obj
for obj in property_objects
if RevitRules.get_parameter_value(obj, function_inputs.property)
not in ["", "Default", None]
]
for obj in valid_property_objects:
speckle_print(RevitRules.get_parameter_value(obj, function_inputs.property))
# invalid_property_objects property_objects not in valid_property_objects
invalid_property_objects = [
obj for obj in property_objects if obj not in valid_property_objects
]
# mark all the non-property objects as failed
(
automate_context.attach_error_to_objects(
category=f"Missing Property {function_inputs.category} Objects",
object_ids=[obj.id for obj in non_property_objects],
message=f"This {function_inputs.category} does not have the specified property {function_inputs.property}",
)
if non_property_objects
else None
)
# mark all the invalid property objects as warning
(
automate_context.attach_warning_to_objects(
category=f"Invalid Property {function_inputs.category} Objects",
object_ids=[obj.id for obj in invalid_property_objects],
message=f"This {function_inputs.category} has the specified property {function_inputs.property} but it is "
f"empty or default",
)
if invalid_property_objects
else None
)
# mark all the property objects as successful
(
automate_context.attach_info_to_objects(
category=f"Valid Property {function_inputs.category} Objects",
object_ids=[obj.id for obj in property_objects],
message=f"This {function_inputs.category} has the specified property {function_inputs.property}",
)
if property_objects
else None
)
if len(non_property_objects) > 0:
automate_context.mark_run_failed(
"Some objects do not have the specified property."
)
elif len(invalid_property_objects) > 0:
automate_context.mark_run_success(
"Some objects have the specified property but it is empty or default.",
)
else:
automate_context.mark_run_success(
f"All {function_inputs.category} objects have the {function_inputs.property} property."
)
# set the automation context view, to the original model / version view # set the automation context view, to the original model / version view
automate_context.set_context_view() automate_context.set_context_view()
# report success
automate_context.mark_run_success(
f"Successfully applied rules to {len(flat_list_of_objects)} objects."
)
# make sure to call the function with the executor # make sure to call the function with the executor
+299 -83
View File
@@ -1,12 +1,16 @@
from typing import List, Optional, Tuple, Callable, Dict, Any, cast, Union from typing import List, Optional, Tuple, Any, cast
from speckle_automate import AutomationContext, ObjectResultLevel
from specklepy.objects.base import Base from specklepy.objects.base import Base
from Levenshtein import ratio from Levenshtein import ratio
import pandas as pd
import re import re
from Utilities.helpers import speckle_print
# We're going to define a set of rules that will allow us to filter and # We're going to define a set of rules that will allow us to filter and
# process parameters in our Speckle objects. These rules will be encapsulated # process parameters in our Speckle objects. These rules will be encapsulated
# in a class called `Rules`. We'll also define a set of rules specific to Revit # in a class called `ParameterRules`.
# objects in a class called `RevitRules`.
class Rules: class Rules:
@@ -70,7 +74,7 @@ class Rules:
Returns: Returns:
bool: True if the object has a display value, False otherwise. bool: True if the object has a display value, False otherwise.
""" """
# Check if the speckle_object has a display value using the try_get_display_value method # Check for direct displayable state using try_get_display_value
display_values = Rules.try_get_display_value(speckle_object) display_values = Rules.try_get_display_value(speckle_object)
if display_values and getattr(speckle_object, "id", None) is not None: if display_values and getattr(speckle_object, "id", None) is not None:
return True return True
@@ -87,57 +91,6 @@ class Rules:
return False return False
# Below are more speculatively defined rules that could be used in a traversal of flat list parsing
@staticmethod
def speckle_type_rule(
desired_type: str,
) -> Callable[[Base], bool]:
"""
Rule: Check if a parameter's speckle_type matches the desired type.
"""
return lambda prop: getattr(prop, "speckle_type", None) == desired_type
@staticmethod
def is_speckle_type(prop: Base, desired_type: str) -> bool:
"""
Rule: Check if a parameter's speckle_type matches the desired type.
"""
return getattr(prop, "speckle_type", None) == desired_type
@staticmethod
def has_missing_value(prop: Dict[str, str]) -> bool:
"""
Rule: Missing Value Check.
The AEC industry often requires all parameters to have meaningful values.
This rule checks if a parameter is missing its value, potentially indicating
an oversight during data entry or transfer.
"""
return not prop.get("value")
@staticmethod
def has_default_value(prop: Dict[str, str], default="Default") -> bool:
"""
Rule: Default Value Check.
Default values can sometimes creep into final datasets due to software defaults.
This rule identifies parameters that still have their default values, helping
to highlight areas where real, meaningful values need to be provided.
"""
return prop.get("value") == default
@staticmethod
def parameter_exists(prop_name: str, parent_object: Dict[str, str]) -> bool:
"""
Rule: Parameter Existence Check.
For certain critical parameters, their mere presence (or lack thereof) is vital.
This rule verifies if a specific parameter exists within an object, allowing
teams to ensure that key data points are always present.
"""
return prop_name in parent_object.get("parameters", {})
def get_displayable_objects(flat_list_of_objects: List[Base]) -> List[Base]: def get_displayable_objects(flat_list_of_objects: List[Base]) -> List[Base]:
# modify this lambda from before to use the static method from the Checks class # modify this lambda from before to use the static method from the Checks class
@@ -151,10 +104,42 @@ def get_displayable_objects(flat_list_of_objects: List[Base]) -> List[Base]:
# and the same logic that could be modified to traverse a tree of objects # and the same logic that could be modified to traverse a tree of objects
# Now we're going to define a set of rules that are specific to Revit objects. def filter_objects_by_category(
speckle_objects: List[Base], category_input: str
) -> Tuple[List[Base], List[Base]]:
"""
Filters objects by category value and test.
This function takes a list of Speckle objects, filters out the objects
with a matching category value and satisfies the test, and returns
both the matching and non-matching objects.
Args:
speckle_objects (List[Base]): The list of Speckle objects to filter.
category_input (str): The category value to match against.
Returns:
Tuple[List[Base], List[Base]]: A tuple containing two lists:
- The first list contains objects with matching category and test.
- The second list contains objects without matching category or test.
"""
matching_objects = []
non_matching_objects = []
for obj in speckle_objects:
if RevitRules.is_category(obj, category_input):
matching_objects.append(obj)
else:
non_matching_objects.append(obj)
return matching_objects, non_matching_objects
class RevitRules: class RevitRules:
@staticmethod @staticmethod
def has_parameter(speckle_object: Base, parameter_name: str) -> bool: def has_parameter(
speckle_object: Base, parameter_name: str, *_args, **_kwargs
) -> bool:
""" """
Checks if the speckle_object has a Revit parameter with the given name. Checks if the speckle_object has a Revit parameter with the given name.
@@ -170,6 +155,8 @@ class RevitRules:
Args: Args:
speckle_object (Base): The Speckle object to check. speckle_object (Base): The Speckle object to check.
parameter_name (str): The name of the parameter to check for. parameter_name (str): The name of the parameter to check for.
*_args: Extra positional arguments which are ignored.
**_kwargs: Extra keyword arguments which are ignored.
Returns: Returns:
bool: True if the object has the parameter, False otherwise. bool: True if the object has the parameter, False otherwise.
@@ -262,6 +249,8 @@ class RevitRules:
None, None,
) )
from typing import Any, Union, List
@staticmethod @staticmethod
def is_parameter_value( def is_parameter_value(
speckle_object: Base, parameter_name: str, value_to_match: Any speckle_object: Base, parameter_name: str, value_to_match: Any
@@ -281,7 +270,7 @@ class RevitRules:
return parameter_value == value_to_match return parameter_value == value_to_match
@staticmethod @staticmethod
def is_like_parameter_value( def is_parameter_value_like(
speckle_object: Base, speckle_object: Base,
parameter_name: str, parameter_name: str,
pattern: str, pattern: str,
@@ -313,9 +302,31 @@ class RevitRules:
else: else:
return bool(re.match(pattern, str(parameter_value))) return bool(re.match(pattern, str(parameter_value)))
@staticmethod
def parse_number_from_string(input_string: str):
"""
Attempts to parse an integer or float from a given string.
Args:
input_string (str): The string containing the number to be parsed.
Returns:
int or float: The parsed number, or raises ValueError if parsing is not possible.
"""
try:
# First try to convert it to an integer
return int(input_string)
except ValueError:
# If it fails to convert to an integer, try to convert to a float
try:
return float(input_string)
except ValueError:
# Raise an error if neither conversion is possible
raise ValueError("Input string is not a valid integer or float")
@staticmethod @staticmethod
def is_parameter_value_greater_than( def is_parameter_value_greater_than(
speckle_object: Base, parameter_name: str, threshold: Union[int, float] speckle_object: Base, parameter_name: str, threshold: str
) -> bool: ) -> bool:
""" """
Checks if the value of the specified parameter is greater than the given threshold. Checks if the value of the specified parameter is greater than the given threshold.
@@ -328,18 +339,20 @@ class RevitRules:
Returns: Returns:
bool: True if the parameter value is greater than the threshold, False otherwise. bool: True if the parameter value is greater than the threshold, False otherwise.
""" """
parameter_value = RevitRules.get_parameter_value(speckle_object, parameter_name) parameter_value = RevitRules.get_parameter_value(speckle_object, parameter_name)
if parameter_value is None: if parameter_value is None:
return False return False
if not isinstance(parameter_value, (int, float)): if not isinstance(parameter_value, (int, float)):
raise ValueError( raise ValueError(
f"Parameter value must be a number, got {type(parameter_value)}" f"Parameter value must be a number, got {type(parameter_value)}"
) )
return parameter_value > threshold return parameter_value > RevitRules.parse_number_from_string(threshold)
@staticmethod @staticmethod
def is_parameter_value_less_than( def is_parameter_value_less_than(
speckle_object: Base, parameter_name: str, threshold: Union[int, float] speckle_object: Base, parameter_name: str, threshold: str
) -> bool: ) -> bool:
""" """
Checks if the value of the specified parameter is less than the given threshold. Checks if the value of the specified parameter is less than the given threshold.
@@ -359,10 +372,40 @@ class RevitRules:
raise ValueError( raise ValueError(
f"Parameter value must be a number, got {type(parameter_value)}" f"Parameter value must be a number, got {type(parameter_value)}"
) )
return parameter_value < threshold return parameter_value < RevitRules.parse_number_from_string(threshold)
@staticmethod @staticmethod
def is_parameter_value_in_range( def is_parameter_value_in_range(
speckle_object: Base, parameter_name: str, range: str
) -> bool:
"""
Checks if the value of the specified parameter falls within the given range.
Args:
speckle_object (Base): The Speckle object to check.
parameter_name (str): The name of the parameter to check.
range (str): The range to check against, in the format "min_value, max_value".
Returns:
bool: True if the parameter value falls within the range (inclusive), False otherwise.
"""
min_value, max_value = range.split(",")
min_value = RevitRules.parse_number_from_string(min_value)
max_value = RevitRules.parse_number_from_string(max_value)
parameter_value = RevitRules.get_parameter_value(speckle_object, parameter_name)
if parameter_value is None:
return False
if not isinstance(parameter_value, (int, float)):
raise ValueError(
f"Parameter value must be a number, got {type(parameter_value)}"
)
return min_value <= parameter_value <= max_value
@staticmethod
def is_parameter_value_in_range_expanded(
speckle_object: Base, speckle_object: Base,
parameter_name: str, parameter_name: str,
min_value: Union[int, float], min_value: Union[int, float],
@@ -413,7 +456,20 @@ class RevitRules:
bool: True if the parameter value is found in the list, False otherwise. bool: True if the parameter value is found in the list, False otherwise.
""" """
parameter_value = RevitRules.get_parameter_value(speckle_object, parameter_name) parameter_value = RevitRules.get_parameter_value(speckle_object, parameter_name)
return parameter_value in value_list
if isinstance(value_list, str):
value_list = [value.strip() for value in value_list.split(",")]
# parameter_value is effectively Any type, so to find its value in the value_list
def is_value_in_list(value: Any, my_list: Any) -> bool:
# Ensure that my_list is actually a list
if isinstance(my_list, list):
return value in my_list or str(value) in my_list
else:
speckle_print(f"Expected a list, got {type(my_list)} instead.")
return False
return is_value_in_list(parameter_value, value_list)
@staticmethod @staticmethod
def is_parameter_value_true(speckle_object: Base, parameter_name: str) -> bool: def is_parameter_value_true(speckle_object: Base, parameter_name: str) -> bool:
@@ -498,32 +554,192 @@ class RevitRules:
return RevitRules.get_parameter_value(speckle_object, "category") return RevitRules.get_parameter_value(speckle_object, "category")
def filter_objects_by_category( # Mapping of input predicates to the corresponding methods in RevitRules
speckle_objects: List[Base], category_input: str input_predicate_mapping = {
) -> Tuple[List[Base], List[Base]]: "exists": "has_parameter",
""" "matches": "is_parameter_value",
Filters objects by category value and test. "greater than": "is_parameter_value_greater_than",
"less than": "is_parameter_value_less_than",
"in range": "is_parameter_value_in_range",
"in list": "is_parameter_value_in_list",
"equals": "is_parameter_value",
"true": "is_parameter_value_true",
"false": "is_parameter_value_false",
"is like": "is_parameter_value_like",
}
This function takes a list of Speckle objects, filters out the objects
with a matching category value and satisfies the test, and returns def evaluate_condition(speckle_object: Base, condition: pd.Series) -> bool:
both the matching and non-matching objects. """
Given a Speckle object and a condition, evaluates the condition and returns a boolean value.
A condition is a pandas Series object with the following keys:
- 'Property Name': The name of the property to evaluate.
- 'Predicate': The predicate to use for evaluation.
- 'Value': The value to compare against.
Args: Args:
speckle_objects (List[Base]): The list of Speckle objects to filter. speckle_object (Base): The Speckle object to evaluate.
category_input (str): The category value to match against. condition (pd.Series): The condition to evaluate.
Returns: Returns:
Tuple[List[Base], List[Base]]: A tuple containing two lists: bool: The result of the evaluation. True if the condition is met, False otherwise.
- The first list contains objects with matching category and test.
- The second list contains objects without matching category or test.
""" """
matching_objects = [] property_name = condition["Property Name"]
non_matching_objects = [] predicate_key = condition["Predicate"]
value = condition["Value"]
for speckle_object in speckle_objects: if predicate_key in input_predicate_mapping:
if RevitRules.is_category(speckle_object, category_input): method_name = input_predicate_mapping[predicate_key]
matching_objects.append(speckle_object) method = getattr(RevitRules, method_name, None)
# speckle_print(f"Checking {property_name} {predicate_key} {value}")
if method:
check_answer = method(speckle_object, property_name, value)
return check_answer
return False
def process_rule(
speckle_objects: List[Base], rule_group: pd.DataFrame
) -> Tuple[List[Base], List[Base]]:
"""
Processes a set of rules against Speckle objects, returning those that pass and fail.
The first rule is used as a filter ('WHERE'), and subsequent rules as conditions ('AND').
Args:
speckle_objects: List of Speckle objects to be processed.
rule_group: DataFrame defining the filter and conditions.
Returns:
A tuple of lists containing objects that passed and failed the rule.
"""
# Extract the 'WHERE' condition and subsequent 'AND' conditions
filter_condition = rule_group.iloc[0]
subsequent_conditions = rule_group.iloc[1:]
# get the last row of the rule_group and get the Message and Report Severity
rule_info = rule_group.iloc[-1]
# Filter objects based on the 'WHERE' condition
filtered_objects = [
speckle_object
for speckle_object in speckle_objects
if evaluate_condition(speckle_object, filter_condition)
]
rule_number = rule_info["Rule Number"]
speckle_print(
f"{ filter_condition['Logic']} {filter_condition['Property Name']} "
f"{filter_condition['Predicate']} {filter_condition['Value']}"
)
speckle_print(
f"{rule_number}: {len(list(filtered_objects))} objects passed the filter."
)
# Initialize lists for passed and failed objects
pass_objects, fail_objects = [], []
# Evaluate each filtered object against the 'AND' conditions
for speckle_object in filtered_objects:
if all(
evaluate_condition(speckle_object, cond)
for _, cond in subsequent_conditions.iterrows()
):
pass_objects.append(speckle_object)
else: else:
non_matching_objects.append(speckle_object) fail_objects.append(speckle_object)
return matching_objects, non_matching_objects return pass_objects, fail_objects
def apply_rules_to_objects(
speckle_objects: List[Base],
rules_df: pd.DataFrame,
automate_context: AutomationContext,
) -> dict[str, Tuple[List[Base], List[Base]]]:
"""
Applies defined rules to a list of objects and updates the automate context based on the results.
Args:
speckle_objects (List[Base]): The list of objects to which rules are applied.
rules_df (pd.DataFrame): The DataFrame containing rule definitions.
automate_context (Any): Context manager for attaching rule results.
"""
grouped_rules = rules_df.groupby("Rule Number")
grouped_results = {}
for rule_id, rule_group in grouped_rules:
rule_id_str = str(rule_id) # Convert rule_id to string
# Ensure rule_group has necessary columns
if (
"Message" not in rule_group.columns
or "Report Severity" not in rule_group.columns
):
continue # Or raise an exception if these columns are mandatory
pass_objects, fail_objects = process_rule(speckle_objects, rule_group)
attach_results(
pass_objects, rule_group.iloc[-1], rule_id_str, automate_context, True
)
attach_results(
fail_objects, rule_group.iloc[-1], rule_id_str, automate_context, False
)
grouped_results[rule_id_str] = (pass_objects, fail_objects)
# return pass_objects, fail_objects for each rule
return grouped_results
def attach_results(
speckle_objects: List[Base],
rule_info: pd.Series,
rule_id: str,
context: AutomationContext,
passed: bool,
) -> None:
"""
Attaches the results of a rule to the objects in the context.
Args:
speckle_objects (List[Base]): The list of objects to which the rule was applied.
rule_info (pd.Series): The information about the rule.
rule_id (str): The ID of the rule.
context (AutomationContext): The context manager for attaching results.
passed (bool): Whether the rule passed or failed.
"""
if not speckle_objects:
return
message = f"{rule_info['Message']} - {'Passed' if passed else 'Failed'}"
if passed:
context.attach_info_to_objects(
category=f"Rule {rule_id} Success",
object_ids=[speckle_object.id for speckle_object in speckle_objects],
message=message,
)
else:
speckle_print(rule_info["Report Severity"])
severity = (
ObjectResultLevel.WARNING
if rule_info["Report Severity"].capitalize() == "Warning"
or rule_info["Report Severity"].capitalize() == "Warn"
else ObjectResultLevel.ERROR
)
context.attach_result_to_objects(
category=f"Rule {rule_id} Results",
object_ids=[speckle_object.id for speckle_object in speckle_objects],
message=message,
level=severity,
)