Add math formula node

This commit is contained in:
StableLlama
2025-05-11 11:19:12 +02:00
parent ac0aa7b9e4
commit 7c2f6a5a51
2 changed files with 264 additions and 1 deletions
+4 -1
View File
@@ -1,6 +1,7 @@
from . import (boolean_nodes, casting_nodes, comparison_nodes, control_flow_nodes,
data_list_nodes, dict_nodes, float_nodes, int_nodes, list_nodes,
math_nodes, path_nodes, regex_nodes, set_nodes, string_nodes)
math_nodes, math_formula_node, path_nodes, regex_nodes, set_nodes,
string_nodes)
NODE_CLASS_MAPPINGS = {}
NODE_CLASS_MAPPINGS.update(boolean_nodes.NODE_CLASS_MAPPINGS)
@@ -16,6 +17,7 @@ NODE_CLASS_MAPPINGS.update(path_nodes.NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(regex_nodes.NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(set_nodes.NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(math_nodes.NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(math_formula_node.NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(string_nodes.NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS = {}
@@ -32,4 +34,5 @@ NODE_DISPLAY_NAME_MAPPINGS.update(path_nodes.NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(regex_nodes.NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(set_nodes.NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(math_nodes.NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(math_formula_node.NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(string_nodes.NODE_DISPLAY_NAME_MAPPINGS)
@@ -0,0 +1,260 @@
from inspect import cleandoc
class MathFormula:
"""
A node that evaluates a mathematical formula provided as a string without using eval.
This node takes up to 4 numerical inputs (`a`, `b`, `c`, and `d`) and safely evaluates the formula
with supported operations: +, -, *, /, //, %, **, parentheses, and mathematical functions like abs(),
floor(), ceil(), and round(). It treats one-letter variables as inputs and multi-letter words as functions.
Example: If the formula is "a + abs(b * 4 + c)" and inputs are:
a = 2, b = -3, c = 5,
The calculation would be:
result = 2 + abs(-3 * 4 + 5) = 2 + abs(-7) = 2 + 7 = 9
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"formula": ("STRING", {"default": "a + b"}),
"a": ("FLOAT", {"default": 0.0}),
},
"optional": {
"b": ("FLOAT", {"default": 0.0}),
"c": ("FLOAT", {"default": 0.0}),
"d": ("FLOAT", {"default": 0.0}),
},
}
RETURN_TYPES = ("FLOAT",)
CATEGORY = "Basic/maths"
DESCRIPTION = cleandoc(__doc__ or "")
FUNCTION = "evaluate"
OPERATORS = {"**", "//", "/", "*", "-", "+", "%"}
SUPPORTED_FUNCTIONS = {"abs", "floor", "ceil", "round"}
VALID_CHARS = set("0123456789.+-*/%(), ")
@classmethod
def VALIDATE_INPUTS(cls, input_types):
for input, value in input_types.items():
if input == "formula":
if value == "STRING":
continue
return f"input '{input}' must be a STRING type"
if value not in ("INT", "FLOAT"):
return f"input '{input}' must be an INT or FLOAT type"
return True
def evaluate(self, formula: str, a: float, b: float = 0.0, c: float = 0.0, d: float = 0.0) -> tuple[float]:
# Tokenize the formula without replacement
print(f'formula: "{formula}"')
tokens = self.tokenize_formula(formula)
print(f'ev tokens: "{tokens}"')
# Replace variables in the tokenized formula
replaced_tokens = []
for token in tokens:
if token == "a":
replaced_tokens.append(str(a))
elif token == "b":
replaced_tokens.append(str(b))
elif token == "c":
replaced_tokens.append(str(c))
elif token == "d":
replaced_tokens.append(str(d))
else:
replaced_tokens.append(token)
# Join the replaced tokens back into a formula
updated_formula = " ".join(replaced_tokens)
print(f'updated_formula: "{updated_formula}"')
# Ensure formula is valid
self.validate_formula(updated_formula)
# Parse formula into postfix form and evaluate
postfix = self.infix_to_postfix(replaced_tokens)
print(f'postfix: "{postfix}"')
result = self.evaluate_postfix(postfix)
print(f'result: "{result}"')
return (result,)
def validate_formula(self, formula: str):
"""Validates the formula for allowed operators, functions, and characters."""
import re
# Validate supported characters
for char in formula:
if char not in self.VALID_CHARS and not char.isalpha():
raise ValueError(f"Invalid character in formula: '{char}'")
# Validate that all functions are supported
functions = re.findall(r"[a-zA-Z_]\w*(?=\()", formula) # Matches functions immediately before "("
print('formula:')
print(formula)
print('functions:')
print(functions)
for func in functions:
if func not in self.SUPPORTED_FUNCTIONS:
raise ValueError(f"Unsupported function in formula: '{func}'")
def tokenize_formula(self, formula: str) -> list:
"""
Tokenizes a formula into numbers, variables, functions, operators, and parentheses.
Rules:
- A single-letter token (e.g., 'a') is treated as a variable.
- A multi-letter token (e.g., 'abs') is treated as a function.
- Numbers can be integers, floating point, or hexadecimal.
"""
import re
# Regex pattern for tokenizing
pattern = (
r"(?P<number>-?\d+(\.\d+)?([eE][+-]?\d+)?|-?0x[0-9a-fA-F]+)" # Matches float, int, hex, scientific notation
r"|(?P<function>[a-zA-Z]{2,})" # Matches multi-letter functions like 'abs'
r"|(?P<variable>[a-zA-Z])" # Matches single-letter variables (e.g., 'a', 'b')
r"|(?P<operator>\*\*|//|[+\-*/%()])" # Matches operators and parentheses
)
# Apply regex to extract matches
tokens = re.finditer(pattern, formula)
# Collect and clean the matched tokens
result = [match.group(0) for match in tokens]
print(f'Tokens: {result}') # Debug print
return result
def infix_to_postfix(self, tokens: list) -> list:
"""
Converts a list of tokens in infix notation to postfix notation (RPN).
Correctly handles unary negation (e.g., -b) and operator precedence.
"""
precedence = {"**": 4, "*": 3, "/": 3, "//": 3, "%": 3, "+": 2, "-": 2, "(": 1}
stack = [] # Operator stack
postfix = [] # Output list
previous_token = None # Tracks the previous token to distinguish unary vs binary operators
for token in tokens:
if self.is_number(token): # Numbers
postfix.append(float(token))
elif token in self.SUPPORTED_FUNCTIONS: # Functions (e.g., abs)
stack.append(token)
elif token == "(": # Left parentheses
stack.append(token)
elif token == ")": # Right parentheses
while stack and stack[-1] != "(":
postfix.append(stack.pop())
if not stack:
raise ValueError("Mismatched parentheses in expression.")
stack.pop() # Pops the left parenthesis
elif token in self.OPERATORS: # Operators (+, -, *, etc.)
# Check for unary negation
if token == "-" and (previous_token is None or previous_token in self.OPERATORS or previous_token == "("):
# Treat as unary minus
postfix.append(0.0)
stack.append(token)
else:
# Handle regular operators
while stack and precedence.get(stack[-1], 0) >= precedence[token]:
postfix.append(stack.pop())
stack.append(token)
else:
raise ValueError(f"Unexpected token in infix expression: {token}")
# Update previous token (e.g., to track variable, number, etc.)
previous_token = token
# Pop any remaining operators from the stack
while stack:
top = stack.pop()
if top in ["(", ")"]:
raise ValueError("Mismatched parentheses in expression.")
postfix.append(top)
return postfix
def evaluate_postfix(self, postfix: list) -> float:
"""
Evaluates a postfix (RPN) expression and returns the result.
Handles numbers, operators, and functions, including unary negation.
"""
stack = []
for token in postfix:
if isinstance(token, float): # Numbers
stack.append(token)
elif token in self.SUPPORTED_FUNCTIONS: # Functions (e.g., abs)
if not stack:
raise ValueError(f"Invalid formula: function '{token}' requires an argument but the stack is empty.")
arg = stack.pop()
stack.append(self.apply_function(token, arg))
elif token in self.OPERATORS: # Operators (+, -, *, /, etc.)
if len(stack) < 2:
raise ValueError(f"Invalid formula: operator '{token}' requires two arguments but the stack has {len(stack)}.")
b = stack.pop()
a = stack.pop()
stack.append(self.apply_operator(a, b, token))
else:
raise ValueError(f"Unexpected token in postfix expression: {token}")
if len(stack) != 1:
raise ValueError(f"Invalid postfix expression: stack contains excess items: {stack}")
return stack.pop()
def apply_operator(self, a: float, b: float, operator: str) -> float:
"""Applies the given operator to two operands."""
if operator == "+":
return a + b
elif operator == "-":
return a - b
elif operator == "*":
return a * b
elif operator == "/":
if b == 0:
raise ZeroDivisionError("Division by zero.")
return a / b
elif operator == "//":
if b == 0:
raise ZeroDivisionError("Division by zero.")
return a // b
elif operator == "%":
if b == 0:
raise ZeroDivisionError("Modulo by zero.")
return a % b
elif operator == "**":
return a ** b
else:
raise ValueError(f"Unsupported operator: {operator}")
def apply_function(self, func: str, arg: float) -> float:
"""Applies a mathematical function."""
import math
if func == "abs":
return abs(arg)
elif func == "floor":
return math.floor(arg)
elif func == "ceil":
return math.ceil(arg)
elif func == "round":
return round(arg)
else:
raise ValueError(f"Unsupported function: {func}")
def is_number(self, value: str) -> bool:
"""Checks if a token is a valid number."""
try:
float.fromhex(value) if value.startswith("0x") else float(value)
return True
except ValueError:
return False
NODE_CLASS_MAPPINGS = {
"Basic data handling: MathFormula": MathFormula,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Basic data handling: MathFormula": "formula",
}