From 3af74572df27d864f1ca2527270bc8e7c3f6a737 Mon Sep 17 00:00:00 2001 From: KennyLewi Date: Thu, 19 Mar 2026 14:08:23 +0800 Subject: [PATCH 1/3] feat: add custom IF function to simulation --- client/src/utils/displayEquationHelpers.ts | 145 ++++++++++++++++---- client/src/utils/equationValidationRegex.ts | 16 ++- server/sim/functions.go | 12 ++ server/sim/sim.go | 88 ++++++++---- 4 files changed, 202 insertions(+), 59 deletions(-) create mode 100644 server/sim/functions.go diff --git a/client/src/utils/displayEquationHelpers.ts b/client/src/utils/displayEquationHelpers.ts index 81b13db..602c3ac 100644 --- a/client/src/utils/displayEquationHelpers.ts +++ b/client/src/utils/displayEquationHelpers.ts @@ -1,15 +1,23 @@ -import { parse } from "mathjs"; - import { type Flow, isFlowId, isNodeId, isStockId, type Node, type Stock } from "@/models/graph"; import { + VALID_COMMA, + VALID_COMPARISON_REGEX, + VALID_COMPARISON_STRING, VALID_ENTITY_ID_REGEX, VALID_EQUATION_REGEX, + VALID_FUNCTION_REGEX, + VALID_FUNCTIONS, + VALID_FUNCTIONS_STRING, VALID_NUMBER, VALID_OPERATOR_REGEX, VALID_OPERATOR_STRING, } from "@/utils/equationValidationRegex"; -const INVALID_CHARACTERS_ERROR = `Equation can only contain numbers, operators ${VALID_OPERATOR_STRING} and valid node, stock or flow labels.`; +const INVALID_CHARACTERS_ERROR = `Equation can only contain numbers, operators "${VALID_OPERATOR_STRING}", comparison operators "${VALID_COMPARISON_STRING}", commas, functions "${VALID_FUNCTIONS_STRING}", and valid node/stock/flow labels.`; +const UNBALANCED_PARENTHESES_ERROR = "Equation has unbalanced parentheses."; +const INVALID_BINARY_OPERATOR_ERROR = + "Operators and comparisons must be placed between two valid operands."; +const INVALID_COMMA_ERROR = "Commas must be inside functions and separate valid arguments."; type EquationValidationResult = { isValid: boolean; @@ -72,6 +80,7 @@ export function validateEquation( equation = equation.trim(); if (equation.length < 1) return { isValid: true }; const tokens = equation.match(VALID_EQUATION_REGEX); + // Empty tokens error if (!tokens) { return { isValid: false, @@ -82,6 +91,7 @@ export function validateEquation( const reconstructed = tokens.join(""); const stripped = equation.replace(/\s+/g, ""); + // Invalid characters error if (reconstructed !== stripped) { return { isValid: false, @@ -90,9 +100,18 @@ export function validateEquation( } const tokensWithValidIds = tokens.filter((tok) => { - // Keep numbers and operators + // numbers if (new RegExp(`^${VALID_NUMBER}$`).test(tok)) return true; + // math operators if (new RegExp(`^${VALID_OPERATOR_REGEX}$`).test(tok)) return true; + // comparison operators + if (new RegExp(`^${VALID_COMPARISON_REGEX}$`).test(tok)) return true; + // comma + if (tok === VALID_COMMA) return true; + // functions + if (new RegExp(`^${VALID_FUNCTION_REGEX}$`).test(tok)) { + return VALID_FUNCTIONS.has(tok); + } // Keep ID tokens only if they exist in state if (isNodeId(tok)) return nodes[tok] !== undefined; @@ -100,6 +119,7 @@ export function validateEquation( if (isFlowId(tok)) return flows[tok] !== undefined; }); + // Checks for valid IDs and functions if (tokensWithValidIds.length !== tokens.length) { return { isValid: false, @@ -107,40 +127,109 @@ export function validateEquation( }; } - try { - parse(tokensWithValidIds.join(" ")); - return { isValid: true }; - } catch { + if (!hasBalancedParentheses(tokens)) { + return { + isValid: false, + error: UNBALANCED_PARENTHESES_ERROR, + }; + } + + if (!hasValidBinaryOperatorPlacement(tokens)) { return { isValid: false, - error: "Equation should be in the format [operand] [operator] [operand] (e.g. A + B)", + error: INVALID_BINARY_OPERATOR_ERROR, }; } + + if (!hasValidCommaPlacement(tokens)) { + return { + isValid: false, + error: INVALID_COMMA_ERROR, + }; + } + + return { isValid: true }; } -export function removeInvalidCharacters( - equation: string, - nodes: Record, - flows: Record, - stocks: Record, -) { - const tokens = equation.match(VALID_EQUATION_REGEX); - if (!tokens) return ""; +function hasBalancedParentheses(tokens: string[]): boolean { + let balance = 0; + for (const token of tokens) { + if (token === "(") { + balance++; + } else if (token === ")") { + balance--; + if (balance < 0) { + return false; + } + } + } + return balance === 0; +} - const tokensWithValidIds = tokens.filter((tok) => { - // Keep numbers and operators - if (new RegExp(VALID_NUMBER).test(tok)) return true; - if (new RegExp(VALID_OPERATOR_REGEX).test(tok)) return true; +const operators = new Set(["+", "-", "*", "/"]); +const comparisons = new Set(["==", "!=", ">", "<", ">=", "<="]); - // Keep ID tokens only if they exist in state - if (isNodeId(tok)) return nodes[tok] !== undefined; - if (isFlowId(tok)) return flows[tok] !== undefined; - if (isStockId(tok)) return stocks[tok] !== undefined; +function isBinaryOp(tok: string) { + return operators.has(tok) || comparisons.has(tok); +} - return false; - }); +function hasValidBinaryOperatorPlacement(tokens: string[]) { + for (let i = 0; i < tokens.length; i++) { + const curr = tokens[i]; + + if (!isBinaryOp(curr)) continue; + + const prev = tokens[i - 1]; + const next = tokens[i + 1]; + + if (curr === "-") { + const isUnary = i === 0 || isBinaryOp(prev) || prev === "(" || prev === VALID_COMMA; + + if (isUnary) { + if (i === tokens.length - 1) return false; + + if (isBinaryOp(next) || next === ")" || next === VALID_COMMA) { + return false; + } + + continue; + } + } + + if (i === 0 || i === tokens.length - 1) return false; + + if (isBinaryOp(prev) || prev === "(" || prev === VALID_COMMA) { + return false; + } + + if (isBinaryOp(next) || next === ")" || next === VALID_COMMA) { + return false; + } + } + + return true; +} + +function hasValidCommaPlacement(tokens: string[]) { + let parenDepth = 0; + + for (let i = 0; i < tokens.length; i++) { + const curr = tokens[i]; + const prev = tokens[i - 1]; + const next = tokens[i + 1]; + + if (curr === "(") parenDepth++; + if (curr === ")") parenDepth--; + + if (curr !== VALID_COMMA) continue; + + if (parenDepth <= 0) return false; + if (i === 0 || i === tokens.length - 1) return false; + if (prev === "(" || prev === VALID_COMMA) return false; + if (next === ")" || next === VALID_COMMA) return false; + } - return tokensWithValidIds.join(" "); + return true; } export function removeWhitespaces(equation: string) { diff --git a/client/src/utils/equationValidationRegex.ts b/client/src/utils/equationValidationRegex.ts index 307c6d2..40a0591 100644 --- a/client/src/utils/equationValidationRegex.ts +++ b/client/src/utils/equationValidationRegex.ts @@ -1,14 +1,19 @@ import { ID_SEPARATOR } from "@/models/graph"; +export const VALID_FUNCTION_REGEX = String.raw`\b[A-Z_][A-Z0-9_]*\b`; +export const VALID_COMPARISON_REGEX = String.raw`==|!=|>=|<=|>|<`; +export const VALID_COMMA = String.raw`,`; export const VALID_ENTITY_ID_REGEX = String.raw`\b(node|stock|flow)${ID_SEPARATOR}\d+\b`; export const VALID_OPERATOR_REGEX = String.raw`[+\-*/()]`; export const VALID_NUMBER = String.raw`\d+(?:\.\d+)?`; export const VALID_EQUATION_REGEX = new RegExp( - `${VALID_ENTITY_ID_REGEX}|${VALID_NUMBER}|${VALID_OPERATOR_REGEX}`, + `${VALID_ENTITY_ID_REGEX}|${VALID_NUMBER}|${VALID_COMPARISON_REGEX}|${VALID_OPERATOR_REGEX}|${VALID_COMMA}|${VALID_FUNCTION_REGEX}`, "g", ); +export const VALID_FUNCTIONS = new Set(["IF"]); + function getValidOperators(): string { // Remove ^, $ and brackets const stripped = VALID_OPERATOR_REGEX.replace(/[\^$[\]]/g, ""); @@ -17,3 +22,12 @@ function getValidOperators(): string { } export const VALID_OPERATOR_STRING = getValidOperators(); + +function getValidComparisonOperators(): string { + // Remove ^ and $ from the regex string + return VALID_COMPARISON_REGEX.replace(/[\^$]/g, "").split("|").join(" "); +} + +export const VALID_COMPARISON_STRING = getValidComparisonOperators(); + +export const VALID_FUNCTIONS_STRING = [...VALID_FUNCTIONS].join(", "); diff --git a/server/sim/functions.go b/server/sim/functions.go new file mode 100644 index 0000000..7e58df6 --- /dev/null +++ b/server/sim/functions.go @@ -0,0 +1,12 @@ +package sim + +func IF(condition bool, trueValue, falseValue interface{}) interface{} { + if condition { + return trueValue + } + return falseValue +} + +var CustomFunctions = map[string]interface{}{ + "IF": IF, +} diff --git a/server/sim/sim.go b/server/sim/sim.go index 24733ef..4c9ec5c 100644 --- a/server/sim/sim.go +++ b/server/sim/sim.go @@ -2,86 +2,116 @@ package sim import ( "fmt" + "math" "github.com/expr-lang/expr" "github.com/expr-lang/expr/vm" ) func simulate(req SimulationRequest) (SimulationResult, error) { + timestamp := req.Settings.StartTime + delta := req.Settings.Delta + endTime := req.Settings.EndTime + + if delta <= 0 { + return SimulationResult{}, fmt.Errorf("delta must be > 0") + } + if endTime < timestamp { + return SimulationResult{}, fmt.Errorf("endTime must be >= startTime") + } + currValues := make(map[string]float64) + env := make(map[string]interface{}) + + // inject custom functions + for name, fn := range CustomFunctions { + env[name] = fn + } + // initialise stocks and variables for _, s := range req.Stocks { currValues[s.ID] = s.InitialValue + env[s.ID] = s.InitialValue } for _, v := range req.Variables { currValues[v.ID] = 0.0 + env[v.ID] = 0.0 } - programs := make(map[string]*vm.Program) + // compile equations + programs := make(map[string]*vm.Program, len(req.Variables)) for _, v := range req.Variables { - program, err := expr.Compile(v.Equation, expr.Env(currValues), expr.AsFloat64()) + program, err := expr.Compile( + v.Equation, + expr.Env(env), + expr.AsFloat64(), + ) if err != nil { return SimulationResult{}, fmt.Errorf("error compiling equation for %s: %w", v.ID, err) } programs[v.ID] = program } - var results []TimeResult - timestamp := req.Settings.StartTime - delta := req.Settings.Delta - endTime := req.Settings.EndTime - - if delta <= 0 { - return SimulationResult{}, fmt.Errorf("delta must be > 0") - } - if endTime < timestamp { - return SimulationResult{}, fmt.Errorf("endTime must be >= startTime") - } - - sortedVarIds, err := topologicalSort((req.Variables)) + // sort variable evaluation topoligically + sortedVarIds, err := topologicalSort(req.Variables) if err != nil { return SimulationResult{}, err } + estimatedSteps := int(math.Ceil((endTime-timestamp)/delta)) + 1 + if estimatedSteps < 0 { + estimatedSteps = 0 + } + results := make([]TimeResult, 0, estimatedSteps) + stepCount := 0 for timestamp <= endTime { - // variables are evaluated in topological order for _, varId := range sortedVarIds { program := programs[varId] - output, err := expr.Run(program, currValues) + output, err := expr.Run(program, env) if err != nil { - return SimulationResult{}, fmt.Errorf("error evaluating %s at time %f: %w", varId, timestamp, err) + return SimulationResult{}, fmt.Errorf( + "error evaluating %s at time %f: %w", + varId, + timestamp, + err, + ) } - currValues[varId] = output.(float64) + + val := output.(float64) + currValues[varId] = val + env[varId] = val } - // save timestamp snapshot snapshot := make(map[string]float64, len(currValues)) for id, value := range currValues { snapshot[id] = value } + results = append(results, TimeResult{ Timestamp: timestamp, Values: snapshot, }) - // update stocks for next timestamp for _, s := range req.Stocks { netFlow := 0.0 + for _, flowId := range s.Inflow { netFlow += currValues[flowId] } for _, flowId := range s.Outflow { netFlow -= currValues[flowId] } + result := currValues[s.ID] + (netFlow * delta) if result < 0 { result = 0 } + currValues[s.ID] = result + env[s.ID] = result } - // increment timestamp stepCount++ timestamp = req.Settings.StartTime + (float64(stepCount) * delta) } @@ -90,14 +120,14 @@ func simulate(req SimulationRequest) (SimulationResult, error) { } func topologicalSort(variables []VariableInitial) ([]string, error) { - varMap := make(map[string]VariableInitial) + varMap := make(map[string]VariableInitial, len(variables)) for _, v := range variables { varMap[v.ID] = v } - visited := make(map[string]bool) - visiting := make(map[string]bool) - sortedVarIds := []string{} + visited := make(map[string]bool, len(variables)) + visiting := make(map[string]bool, len(variables)) + sortedVarIds := make([]string, 0, len(variables)) var dfs func(id string) error dfs = func(id string) error { @@ -105,14 +135,12 @@ func topologicalSort(variables []VariableInitial) ([]string, error) { return nil } - // cycle checking if visiting[id] { return fmt.Errorf("circular dependency detected involving variable: %s", id) } visiting[id] = true - variable, isVariable := varMap[id] - if isVariable { + if variable, isVariable := varMap[id]; isVariable { for _, depId := range variable.Dependencies { if err := dfs(depId); err != nil { return err @@ -123,7 +151,7 @@ func topologicalSort(variables []VariableInitial) ([]string, error) { visiting[id] = false visited[id] = true - if isVariable { + if _, isVariable := varMap[id]; isVariable { sortedVarIds = append(sortedVarIds, id) } return nil From 6ea7ed4f4c99ce5db839dbda22f0413c1ee0bfdc Mon Sep 17 00:00:00 2001 From: KennyLewi Date: Thu, 19 Mar 2026 14:29:42 +0800 Subject: [PATCH 2/3] feat: replace ids with labels for error messages in simulation --- client/src/actions/simulation.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/client/src/actions/simulation.ts b/client/src/actions/simulation.ts index 5c78e33..af1127d 100644 --- a/client/src/actions/simulation.ts +++ b/client/src/actions/simulation.ts @@ -59,7 +59,22 @@ export async function runSimulation(settings: SimulationSettings): Promise { + if (entity.id && entity.label) { + const regex = new RegExp(entity.id, "g"); + errorText = errorText.replace(regex, `'${entity.label}'`); + } + }); + throw new Error(errorText || "Failed to run simulation"); } From 95b26eef669c489e6b51909ff1499ff7a54cd0ba Mon Sep 17 00:00:00 2001 From: KennyLewi Date: Tue, 24 Mar 2026 22:45:36 +0800 Subject: [PATCH 3/3] fix: update error messages and parentheses checking for equation editor --- client/src/utils/displayEquationHelpers.ts | 33 ++++++++++++++++------ 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/client/src/utils/displayEquationHelpers.ts b/client/src/utils/displayEquationHelpers.ts index 602c3ac..07ac749 100644 --- a/client/src/utils/displayEquationHelpers.ts +++ b/client/src/utils/displayEquationHelpers.ts @@ -17,7 +17,8 @@ const INVALID_CHARACTERS_ERROR = `Equation can only contain numbers, operators " const UNBALANCED_PARENTHESES_ERROR = "Equation has unbalanced parentheses."; const INVALID_BINARY_OPERATOR_ERROR = "Operators and comparisons must be placed between two valid operands."; -const INVALID_COMMA_ERROR = "Commas must be inside functions and separate valid arguments."; +const INVALID_COMMA_ERROR = + "Commas must be inside a valid function and separate valid arguments. Entity names can't be function names"; type EquationValidationResult = { isValid: boolean; @@ -211,22 +212,36 @@ function hasValidBinaryOperatorPlacement(tokens: string[]) { } function hasValidCommaPlacement(tokens: string[]) { - let parenDepth = 0; + const parentStack: boolean[] = []; for (let i = 0; i < tokens.length; i++) { const curr = tokens[i]; const prev = tokens[i - 1]; const next = tokens[i + 1]; - if (curr === "(") parenDepth++; - if (curr === ")") parenDepth--; + if (curr === "(") { + const isEntity = + prev !== undefined && (isNodeId(prev) || isStockId(prev) || isFlowId(prev)); + if (isEntity) return false; - if (curr !== VALID_COMMA) continue; + const isFunctionContext = prev !== undefined && VALID_FUNCTIONS.has(prev); + parentStack.push(isFunctionContext); + } - if (parenDepth <= 0) return false; - if (i === 0 || i === tokens.length - 1) return false; - if (prev === "(" || prev === VALID_COMMA) return false; - if (next === ")" || next === VALID_COMMA) return false; + if (curr === ")") { + parentStack.pop(); + } + + if (curr === VALID_COMMA) { + if (parentStack.length === 0) return false; + + const isInsideFunction = parentStack[parentStack.length - 1]; + if (!isInsideFunction) return false; + + if (i === 0 || i === tokens.length - 1) return false; + if (prev === "(" || prev === VALID_COMMA) return false; + if (next === ")" || next === VALID_COMMA) return false; + } } return true;