Skip to content
Open
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 1.04
- improve objective logging

## 1.0.3
- Bug fix: circular import of objective in casadi model

Expand Down
2 changes: 1 addition & 1 deletion agentlib_mpc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
from .modules import MODULE_TYPES
from .models import MODEL_TYPES

__version__ = "1.0.3"
__version__ = "1.0.4"
164 changes: 146 additions & 18 deletions agentlib_mpc/data_structures/objective.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,86 @@ def evaluate(self, df):

return result

def _replace_subexpressions(expr_str, verbose=False):
"""
Replace CasADi subexpression definitions (marked with @N=...) in an expression string
with their actual definitions and inline them into the main expression.

"""
if "@" not in expr_str:
return expr_str

# Extract all subexpression definitions using regex
defs = dict(re.findall(r"(@\d+)=(.*?),\s*", expr_str))

# Remove the definitions from the expression string
clean_expr = re.sub(r"@\d+=.*?,\s*", "", expr_str)

# Inline subexpressions into the main expression
# Process in order to handle nested references (@2 might reference @1)
for subexpr in sorted(defs.keys(), key=lambda x: int(x[1:])):
clean_expr = clean_expr.replace(subexpr, f"({defs[subexpr]})")

if verbose:
print("Subexpressions found in objective expression:")
for subexpr, definition in defs.items():
print(f" {subexpr} = {definition}")
print(f"Expression before removing subexpressions: {expr_str}")
print(f"Cleaned expression after removing subexpressions: {clean_expr}")

return clean_expr


def _replace_ternary(eval_str):
"""
Replace CasADi ternary expressions like (cond)?true:false with where(cond, true, false).
"""
while "?" in eval_str:
q_idx = eval_str.rfind("?")
c_idx = eval_str.find(":", q_idx)
if q_idx == -1 or c_idx == -1:
break

# Backward search for condition
pares = 0
cond_start = 0
for i in range(q_idx - 1, -1, -1):
if eval_str[i] == ")":
pares += 1
elif eval_str[i] == "(":
pares -= 1
if pares < 0:
cond_start = i + 1
break
else:
cond_start = 0

# Forward search for false_val
pares = 0
false_end = len(eval_str)
for i in range(c_idx + 1, len(eval_str)):
if eval_str[i] == "(":
pares += 1
elif eval_str[i] == ")":
pares -= 1
if pares < 0:
false_end = i
break
elif pares == 0 and eval_str[i] in "+-*/,@":
if i > c_idx + 1:
false_end = i
break

cond_str = eval_str[cond_start:q_idx]
true_str = eval_str[q_idx + 1 : c_idx]
false_str = eval_str[c_idx + 1 : false_end]

eval_str = (
eval_str[:cond_start]
+ f"where({cond_str}, {true_str}, {false_str})"
+ eval_str[false_end:]
)
return eval_str

class SubObjective:

Expand Down Expand Up @@ -145,21 +225,49 @@ def _evaluate_expression(self, expr, df):
direct expression with a casadi function and map from the available variables"""
# Handle simple named variables first
var_name = expr.name

for col_type in ["variable", "parameter"]:
if (col_type, var_name) in df.columns:
return df.loc[:, (col_type, var_name)].values[:-1]

expr_str = str(expr)

if "@" in expr_str:
expr_str = _replace_subexpressions(expr_str)

if "?" in expr_str:
expr_str = _replace_ternary(expr_str)

# Replace logical NOT '!' with bitwise NOT '~' for NumPy arrays
expr_str = re.sub(r"(?<![<>=!])!(?!=)", "~", expr_str)

# Handle common CasADi functions with simple replacements
casadi_replacements = {
"sq(": "(",
"fabs(": "abs(",
"sqrt(": "sqrt(",
"sin(": "sin(",
"cos(": "cos(",
"tan(": "tan(",
"asin(": "arcsin(",
"acos(": "arccos(",
"atan(": "arctan(",
"atan2(": "arctan2(",
"sinh(": "sinh(",
"cosh(": "cosh(",
"tanh(": "tanh(",
"asinh(": "arcsinh(",
"acosh(": "arccosh(",
"atanh(": "arctanh(",
"exp(": "exp(",
"log(": "log(",
"log10(": "log10(",
"pow(": "power(",
"floor(": "floor(",
"ceil(": "ceil(",
"sign(": "sign(",
"fmin(": "minimum(",
"fmax(": "maximum(",
}

# Apply replacements
Expand All @@ -174,18 +282,11 @@ def _evaluate_expression(self, expr, df):
eval_str = eval_str.replace(casadi_func, replacement)

# Extract variable names, filtering out CasADi function names
casadi_functions = [
"sq",
"fabs",
"sqrt",
"sin",
"cos",
"exp",
"log",
"abs",
"max",
"min",
]
base_funcs = [k.rstrip("(") for k in casadi_replacements.keys()]
replaced_funcs = [v.rstrip("(") for v in casadi_replacements.values() if v.endswith("(")]
extra_funcs = ["round", "max", "min", "minimum", "maximum", "where", "abs", "power"]
casadi_functions = list(set(base_funcs + replaced_funcs + extra_funcs))

var_names = re.findall(r"[a-zA-Z][a-zA-Z0-9_]*", expr_str)
var_names = [name for name in var_names if name not in casadi_functions]

Expand All @@ -206,10 +307,30 @@ def _evaluate_expression(self, expr, df):
"sqrt": np.sqrt,
"sin": np.sin,
"cos": np.cos,
"tan": np.tan,
"arcsin": np.arcsin,
"arccos": np.arccos,
"arctan": np.arctan,
"arctan2": np.arctan2,
"sinh": np.sinh,
"cosh": np.cosh,
"tanh": np.tanh,
"arcsinh": np.arcsinh,
"arccosh": np.arccosh,
"arctanh": np.arctanh,
"exp": np.exp,
"log": np.log,
"log10": np.log10,
"power": np.power,
"floor": np.floor,
"ceil": np.ceil,
"round": np.round,
"sign": np.sign,
"minimum": np.minimum,
"maximum": np.maximum,
"max": np.maximum,
"min": np.minimum,
"where": np.where,
}
)

Expand Down Expand Up @@ -565,7 +686,18 @@ def _evaluate_condition(self, condition, df):
"""
condition_str = str(condition)

identifier_pattern = r"[a-zA-Z_][a-zA-Z0-9_]*"
if "@" in condition_str:
condition_str = _replace_subexpressions(condition_str)

if "?" in condition_str:
condition_str = _replace_ternary(condition_str)

# Replace CasADi logical operators with Python equivalents for eval
condition_str = condition_str.replace("&&", " and ")
condition_str = condition_str.replace("||", " or ")
condition_str = re.sub(r"(?<![<>=!])!(?!=)", " not ", condition_str)

identifier_pattern = r'[a-zA-Z_][a-zA-Z0-9_]*'
potential_vars = re.findall(identifier_pattern, condition_str)

operators_keywords = {
Expand Down Expand Up @@ -607,11 +739,7 @@ def _evaluate_condition(self, condition, df):
local_vars[var_name] = values[i]
try:
eval_str = condition_str
result = eval(
eval_str,
{"__builtins__": {}, "abs": abs, "min": min, "max": max},
local_vars,
)
result = eval(eval_str, {"__builtins__": {}, "abs": abs, "min": min, "max": max, "where": np.where}, local_vars)
mask[i] = bool(result)

except Exception as e:
Expand Down
2 changes: 2 additions & 0 deletions agentlib_mpc/models/casadi_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,8 @@ def __init__(self, **kwargs):
expressions=self.objective, name=str(self.objective)
)
)
# Mark as legacy with flag
self.objective._is_legacy_wrapped = True
warnings.warn(
"\033[93mWARNING:\033[0m Model uses the deprecated objective formulation. "
"Consider migrating to the new CombinedObjective formulation.\n"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,13 @@ def save_result_df(

# Calculate objective values
df = results.df
objective_names, objective_values = self.approximate_objective(df)

# Skip objective calculation for legacy objectives
if getattr(self.system.objective, '_is_legacy_wrapped', False):
objective_names = []
objective_values = {}
else:
objective_names, objective_values = self.approximate_objective(df)

if not self.results_folder_exists():
if not self.config.save_only_stats:
Expand Down
Loading
Loading