qnty 0.0.8__py3-none-any.whl → 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. qnty/__init__.py +140 -59
  2. qnty/constants/__init__.py +10 -0
  3. qnty/constants/numerical.py +18 -0
  4. qnty/constants/solvers.py +6 -0
  5. qnty/constants/tests.py +6 -0
  6. qnty/dimensions/__init__.py +23 -0
  7. qnty/dimensions/base.py +97 -0
  8. qnty/dimensions/field_dims.py +126 -0
  9. qnty/dimensions/field_dims.pyi +128 -0
  10. qnty/dimensions/signature.py +111 -0
  11. qnty/equations/__init__.py +4 -0
  12. qnty/equations/equation.py +220 -0
  13. qnty/equations/system.py +130 -0
  14. qnty/expressions/__init__.py +40 -0
  15. qnty/expressions/formatter.py +188 -0
  16. qnty/expressions/functions.py +74 -0
  17. qnty/expressions/nodes.py +701 -0
  18. qnty/expressions/types.py +70 -0
  19. qnty/extensions/plotting/__init__.py +0 -0
  20. qnty/extensions/reporting/__init__.py +0 -0
  21. qnty/problems/__init__.py +145 -0
  22. qnty/problems/composition.py +1031 -0
  23. qnty/problems/problem.py +695 -0
  24. qnty/problems/rules.py +145 -0
  25. qnty/problems/solving.py +1216 -0
  26. qnty/problems/validation.py +127 -0
  27. qnty/quantities/__init__.py +29 -0
  28. qnty/quantities/base_qnty.py +677 -0
  29. qnty/quantities/field_converters.py +24004 -0
  30. qnty/quantities/field_qnty.py +1012 -0
  31. qnty/quantities/field_setter.py +12320 -0
  32. qnty/quantities/field_vars.py +6325 -0
  33. qnty/quantities/field_vars.pyi +4191 -0
  34. qnty/solving/__init__.py +0 -0
  35. qnty/solving/manager.py +96 -0
  36. qnty/solving/order.py +403 -0
  37. qnty/solving/solvers/__init__.py +13 -0
  38. qnty/solving/solvers/base.py +82 -0
  39. qnty/solving/solvers/iterative.py +165 -0
  40. qnty/solving/solvers/simultaneous.py +475 -0
  41. qnty/units/__init__.py +1 -0
  42. qnty/units/field_units.py +10507 -0
  43. qnty/units/field_units.pyi +2461 -0
  44. qnty/units/prefixes.py +203 -0
  45. qnty/{unit.py → units/registry.py} +89 -61
  46. qnty/utils/__init__.py +16 -0
  47. qnty/utils/caching/__init__.py +23 -0
  48. qnty/utils/caching/manager.py +401 -0
  49. qnty/utils/error_handling/__init__.py +66 -0
  50. qnty/utils/error_handling/context.py +39 -0
  51. qnty/utils/error_handling/exceptions.py +96 -0
  52. qnty/utils/error_handling/handlers.py +171 -0
  53. qnty/utils/logging.py +40 -0
  54. qnty/utils/protocols.py +164 -0
  55. qnty/utils/scope_discovery.py +420 -0
  56. qnty-0.1.0.dist-info/METADATA +199 -0
  57. qnty-0.1.0.dist-info/RECORD +60 -0
  58. qnty/dimension.py +0 -186
  59. qnty/equation.py +0 -297
  60. qnty/expression.py +0 -553
  61. qnty/prefixes.py +0 -229
  62. qnty/unit_types/base.py +0 -47
  63. qnty/units.py +0 -8113
  64. qnty/variable.py +0 -300
  65. qnty/variable_types/base.py +0 -58
  66. qnty/variable_types/expression_variable.py +0 -106
  67. qnty/variable_types/typed_variable.py +0 -87
  68. qnty/variables.py +0 -2298
  69. qnty/variables.pyi +0 -6148
  70. qnty-0.0.8.dist-info/METADATA +0 -355
  71. qnty-0.0.8.dist-info/RECORD +0 -19
  72. /qnty/{unit_types → extensions}/__init__.py +0 -0
  73. /qnty/{variable_types → extensions/integration}/__init__.py +0 -0
  74. {qnty-0.0.8.dist-info → qnty-0.1.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,1031 @@
1
+ """
2
+ Sub-problem composition system for EngineeringProblem.
3
+
4
+ This module provides the complete sub-problem integration system including:
5
+ - Sub-problem proxy objects for clean composition syntax
6
+ - Namespace handling and variable mapping
7
+ - Composite equation creation
8
+ - Metaclass system for automatic proxy creation
9
+
10
+ Combined from composition.py and composition_mixin.py for focused functionality.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from ..equations import Equation
18
+ from ..expressions import BinaryOperation, ConditionalExpression, Constant, VariableReference, max_expr, min_expr, sin
19
+ from ..expressions.nodes import wrap_operand
20
+ from ..quantities import Dimensionless, FieldQnty
21
+ from .rules import Rules
22
+
23
+ # Constants for composition
24
+ MATHEMATICAL_OPERATORS = ["+", "-", "*", "/", " / ", " * ", " + ", " - "]
25
+ COMMON_COMPOSITE_VARIABLES = ["P", "c", "S", "E", "W", "Y"]
26
+
27
+ # Constants for metaclass
28
+ RESERVED_ATTRIBUTES: set[str] = {"name", "description"}
29
+ PRIVATE_ATTRIBUTE_PREFIX = "_"
30
+ SUB_PROBLEM_REQUIRED_ATTRIBUTES: tuple[str, ...] = ("variables", "equations")
31
+
32
+
33
+ # ========== COMPOSITION CLASSES ==========
34
+
35
+
36
+ class ArithmeticOperationsMixin:
37
+ """Mixin providing common arithmetic operations that create DelayedExpression objects."""
38
+
39
+ def __add__(self, other):
40
+ return DelayedExpression("+", self, other)
41
+
42
+ def __radd__(self, other):
43
+ return DelayedExpression("+", other, self)
44
+
45
+ def __sub__(self, other):
46
+ return DelayedExpression("-", self, other)
47
+
48
+ def __rsub__(self, other):
49
+ return DelayedExpression("-", other, self)
50
+
51
+ def __mul__(self, other):
52
+ return DelayedExpression("*", self, other)
53
+
54
+ def __rmul__(self, other):
55
+ return DelayedExpression("*", other, self)
56
+
57
+ def __truediv__(self, other):
58
+ return DelayedExpression("/", self, other)
59
+
60
+ def __rtruediv__(self, other):
61
+ return DelayedExpression("/", other, self)
62
+
63
+
64
+ class DelayedEquation:
65
+ """
66
+ Stores an equation definition that will be evaluated later when proper context is available.
67
+ """
68
+
69
+ def __init__(self, lhs_symbol, rhs_factory, name=None):
70
+ self.lhs_symbol = lhs_symbol
71
+ self.rhs_factory = rhs_factory # Function that creates the RHS expression
72
+ self.name = name or f"{lhs_symbol}_equation"
73
+
74
+ def evaluate(self, context):
75
+ """Evaluate the equation with the given context (namespace with variables)."""
76
+ if self.lhs_symbol not in context:
77
+ return None
78
+
79
+ lhs_var = context[self.lhs_symbol]
80
+
81
+ try:
82
+ # Call the factory function with the context to create the RHS
83
+ rhs_expr = self.rhs_factory(context)
84
+ return lhs_var.equals(rhs_expr)
85
+ except Exception:
86
+ return None
87
+
88
+
89
+ class SubProblemProxy:
90
+ """
91
+ Proxy object that represents a sub-problem and provides namespaced variable access
92
+ during class definition. Returns properly namespaced variables immediately to prevent
93
+ malformed expressions.
94
+ """
95
+
96
+ def __init__(self, sub_problem, namespace):
97
+ self._sub_problem = sub_problem
98
+ self._namespace = namespace
99
+ self._variable_cache = {}
100
+ self._variable_configurations = {} # Track configurations applied to variables
101
+ # Global registry to track which expressions involve proxy variables
102
+ if not hasattr(SubProblemProxy, "_expressions_with_proxies"):
103
+ SubProblemProxy._expressions_with_proxies = set()
104
+
105
+ def __getattr__(self, name):
106
+ # Handle internal Python attributes to prevent recursion during deepcopy
107
+ if name.startswith("_"):
108
+ raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
109
+
110
+ if name in self._variable_cache:
111
+ return self._variable_cache[name]
112
+
113
+ try:
114
+ attr_value = getattr(self._sub_problem, name)
115
+ if isinstance(attr_value, FieldQnty):
116
+ # Create a properly namespaced variable immediately
117
+ namespaced_var = self._create_namespaced_variable(attr_value)
118
+ self._variable_cache[name] = namespaced_var
119
+ return namespaced_var
120
+ return attr_value
121
+ except AttributeError as e:
122
+ raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") from e
123
+
124
+ def _create_namespaced_variable(self, original_var):
125
+ """Create a Variable with namespaced symbol for proper expression creation."""
126
+ namespaced_symbol = f"{self._namespace}_{original_var.symbol}"
127
+
128
+ # Create new Variable with namespaced symbol that tracks modifications
129
+ namespaced_var = ConfigurableVariable(
130
+ symbol=namespaced_symbol,
131
+ name=f"{original_var.name} ({self._namespace.title()})",
132
+ quantity=original_var.quantity,
133
+ is_known=original_var.is_known,
134
+ proxy=self,
135
+ original_symbol=original_var.symbol,
136
+ )
137
+
138
+ return namespaced_var
139
+
140
+ def track_configuration(self, original_symbol, quantity, is_known):
141
+ """Track a configuration change made to a variable."""
142
+ self._variable_configurations[original_symbol] = {"quantity": quantity, "is_known": is_known}
143
+
144
+ def get_configurations(self):
145
+ """Get all tracked configurations."""
146
+ return self._variable_configurations.copy()
147
+
148
+
149
+ class ConfigurableVariable:
150
+ """
151
+ A Variable wrapper that can track configuration changes and report them back to its proxy.
152
+ This acts as a proxy around the actual qnty Variable rather than inheriting from it.
153
+ """
154
+
155
+ def __init__(self, symbol, name, quantity, is_known=True, proxy=None, original_symbol=None):
156
+ # Store the actual variable (we'll delegate to it)
157
+ # Create a variable of the appropriate type based on the original
158
+ # For now, we'll create a Dimensionless variable and update it
159
+ self._variable = Dimensionless(name)
160
+
161
+ # Set the properties
162
+ self._variable.symbol = symbol
163
+ self._variable.quantity = quantity
164
+ self._variable.is_known = is_known
165
+
166
+ # Store proxy information
167
+ self._proxy = proxy
168
+ self._original_symbol = original_symbol
169
+
170
+ def __getattr__(self, name):
171
+ """Delegate all other attributes to the wrapped variable."""
172
+ return getattr(self._variable, name)
173
+
174
+ # Delegate arithmetic operations to the wrapped variable
175
+ def __add__(self, other):
176
+ return self._variable.__add__(other)
177
+
178
+ def __radd__(self, other):
179
+ return self._variable.__radd__(other)
180
+
181
+ def __sub__(self, other):
182
+ return self._variable.__sub__(other)
183
+
184
+ def __rsub__(self, other):
185
+ return self._variable.__rsub__(other)
186
+
187
+ def __mul__(self, other):
188
+ return self._variable.__mul__(other)
189
+
190
+ def __rmul__(self, other):
191
+ return self._variable.__rmul__(other)
192
+
193
+ def __truediv__(self, other):
194
+ return self._variable.__truediv__(other)
195
+
196
+ def __rtruediv__(self, other):
197
+ return self._variable.__rtruediv__(other)
198
+
199
+ def __pow__(self, other):
200
+ return self._variable.__pow__(other)
201
+
202
+ def __neg__(self):
203
+ # Implement negation as multiplication by -1, consistent with other arithmetic operations
204
+ return self._variable * (-1)
205
+
206
+ # Comparison operations
207
+ def __lt__(self, other):
208
+ return self._variable.__lt__(other)
209
+
210
+ def __le__(self, other):
211
+ return self._variable.__le__(other)
212
+
213
+ def __gt__(self, other):
214
+ return self._variable.__gt__(other)
215
+
216
+ def __ge__(self, other):
217
+ return self._variable.__ge__(other)
218
+
219
+ def __eq__(self, other):
220
+ return self._variable.__eq__(other)
221
+
222
+ def __ne__(self, other):
223
+ return self._variable.__ne__(other)
224
+
225
+ def __setattr__(self, name, value):
226
+ """Delegate attribute setting to the wrapped variable when appropriate."""
227
+ if name.startswith("_") or name in ("_variable", "_proxy", "_original_symbol"):
228
+ super().__setattr__(name, value)
229
+ else:
230
+ setattr(self._variable, name, value)
231
+
232
+ def set(self, value):
233
+ """Override set method to track configuration changes."""
234
+ result = self._variable.set(value)
235
+ if self._proxy and self._original_symbol:
236
+ # Track this configuration change
237
+ self._proxy.track_configuration(self._original_symbol, self._variable.quantity, self._variable.is_known)
238
+ return result
239
+
240
+ def update(self, value=None, unit=None, quantity=None, is_known=None):
241
+ """Override update method to track configuration changes."""
242
+ result = self._variable.update(value, unit, quantity, is_known)
243
+ if self._proxy and self._original_symbol:
244
+ # Track this configuration change
245
+ self._proxy.track_configuration(self._original_symbol, self._variable.quantity, self._variable.is_known)
246
+ return result
247
+
248
+ def mark_known(self):
249
+ """Override mark_known to track configuration changes."""
250
+ result = self._variable.mark_known()
251
+ if self._proxy and self._original_symbol:
252
+ # Track this configuration change
253
+ self._proxy.track_configuration(self._original_symbol, self._variable.quantity, self._variable.is_known)
254
+ return result
255
+
256
+ def mark_unknown(self):
257
+ """Override mark_unknown to track configuration changes."""
258
+ result = self._variable.mark_unknown()
259
+ if self._proxy and self._original_symbol:
260
+ # Track this configuration change
261
+ self._proxy.track_configuration(self._original_symbol, self._variable.quantity, self._variable.is_known)
262
+ return result
263
+
264
+
265
+ class DelayedVariableReference(ArithmeticOperationsMixin):
266
+ """
267
+ A placeholder for a variable that will be resolved to its namespaced version later.
268
+ Supports arithmetic operations that create delayed expressions.
269
+ """
270
+
271
+ def __init__(self, namespace, symbol, original_var):
272
+ self.namespace = namespace
273
+ self.symbol = symbol
274
+ self.original_var = original_var
275
+ self._namespaced_symbol = f"{namespace}_{symbol}"
276
+
277
+ def resolve(self, context):
278
+ """Resolve to the actual namespaced variable from context."""
279
+ return context.get(self._namespaced_symbol)
280
+
281
+
282
+ class DelayedExpression(ArithmeticOperationsMixin):
283
+ """
284
+ Represents an arithmetic expression that will be resolved later when context is available.
285
+ Supports chaining of operations.
286
+ """
287
+
288
+ def __init__(self, operation, left, right):
289
+ self.operation = operation
290
+ self.left = left
291
+ self.right = right
292
+
293
+ def resolve(self, context):
294
+ """Resolve this expression to actual Variable/Expression objects."""
295
+ left_resolved = self._resolve_operand(self.left, context)
296
+ right_resolved = self._resolve_operand(self.right, context)
297
+
298
+ if left_resolved is None or right_resolved is None:
299
+ return None
300
+
301
+ # Create the actual expression
302
+ if self.operation == "+":
303
+ return left_resolved + right_resolved
304
+ elif self.operation == "-":
305
+ return left_resolved - right_resolved
306
+ elif self.operation == "*":
307
+ return left_resolved * right_resolved
308
+ elif self.operation == "/":
309
+ return left_resolved / right_resolved
310
+ else:
311
+ return BinaryOperation(self.operation, left_resolved, right_resolved)
312
+
313
+ def _resolve_operand(self, operand, context):
314
+ """Resolve a single operand to a Variable/Expression."""
315
+ if isinstance(operand, DelayedVariableReference | DelayedExpression | DelayedFunction):
316
+ return operand.resolve(context)
317
+ else:
318
+ # It's a literal value or Variable
319
+ return operand
320
+
321
+
322
+ class DelayedFunction(ArithmeticOperationsMixin):
323
+ """
324
+ Represents a function call that will be resolved later when context is available.
325
+ """
326
+
327
+ def __init__(self, func_name, *args):
328
+ self.func_name = func_name
329
+ self.args = args
330
+
331
+ def resolve(self, context):
332
+ """Resolve function call with given context."""
333
+ # Resolve all arguments
334
+ resolved_args = []
335
+ for arg in self.args:
336
+ if isinstance(arg, DelayedVariableReference | DelayedExpression | DelayedFunction):
337
+ resolved_arg = arg.resolve(context)
338
+ if resolved_arg is None:
339
+ return None
340
+ resolved_args.append(resolved_arg)
341
+ else:
342
+ resolved_args.append(arg)
343
+
344
+ # Call the appropriate function
345
+ if self.func_name == "sin":
346
+ return sin(resolved_args[0])
347
+ elif self.func_name == "min_expr":
348
+ return min_expr(*resolved_args)
349
+ elif self.func_name == "max_expr":
350
+ return max_expr(*resolved_args)
351
+ else:
352
+ # Generic function call
353
+ return None
354
+
355
+
356
+ # Delayed function factories
357
+ def delayed_sin(expr):
358
+ return DelayedFunction("sin", expr)
359
+
360
+
361
+ def delayed_min_expr(*args):
362
+ return DelayedFunction("min_expr", *args)
363
+
364
+
365
+ def delayed_max_expr(*args):
366
+ return DelayedFunction("max_expr", *args)
367
+
368
+
369
+ # ========== COMPOSITION MIXIN ==========
370
+
371
+
372
+ class CompositionMixin:
373
+ """Mixin class providing sub-problem composition functionality."""
374
+
375
+ # These attributes/methods will be provided by other mixins in the final Problem class
376
+ variables: dict[str, FieldQnty]
377
+ sub_problems: dict[str, Any]
378
+ logger: Any
379
+
380
+ def add_variable(self, variable: FieldQnty) -> None:
381
+ """Will be provided by Problem class."""
382
+ del variable # Unused in stub method
383
+ ...
384
+
385
+ def add_equation(self, equation: Equation) -> None:
386
+ """Will be provided by Problem class."""
387
+ del equation # Unused in stub method
388
+ ...
389
+
390
+ def _clone_variable(self, variable: FieldQnty) -> FieldQnty:
391
+ """Will be provided by Problem class."""
392
+ return variable # Stub method - return input as placeholder
393
+
394
+ def _recreate_validation_checks(self) -> None:
395
+ """Will be provided by ValidationMixin."""
396
+ ...
397
+
398
+ def _extract_from_class_variables(self):
399
+ """Extract variables, equations, and sub-problems from class-level definitions."""
400
+ self._extract_sub_problems()
401
+ self._extract_direct_variables()
402
+ self._recreate_validation_checks()
403
+ self._create_composite_equations()
404
+ self._extract_equations()
405
+
406
+ def _extract_sub_problems(self):
407
+ """Extract and integrate sub-problems from class-level definitions."""
408
+ if hasattr(self.__class__, "_original_sub_problems"):
409
+ original_sub_problems = getattr(self.__class__, "_original_sub_problems", {})
410
+ for attr_name, sub_problem in original_sub_problems.items():
411
+ self._integrate_sub_problem(sub_problem, attr_name)
412
+
413
+ def _extract_direct_variables(self):
414
+ """Extract direct variables from class-level definitions."""
415
+ processed_symbols = set()
416
+
417
+ # Single pass through class attributes to collect variables
418
+ for attr_name, attr_value in self._get_class_attributes():
419
+ if isinstance(attr_value, FieldQnty):
420
+ # Set symbol based on attribute name (T_bar, P, etc.)
421
+ attr_value.symbol = attr_name
422
+
423
+ # Skip if we've already processed this symbol
424
+ if attr_value.symbol in processed_symbols:
425
+ continue
426
+ processed_symbols.add(attr_value.symbol)
427
+
428
+ # Clone variable to avoid shared state between instances
429
+ cloned_var = self._clone_variable(attr_value)
430
+ self.add_variable(cloned_var)
431
+ # Set the same cloned variable object as instance attribute
432
+ # Use super() to bypass our custom __setattr__ during initialization
433
+ super().__setattr__(attr_name, cloned_var)
434
+
435
+ def _extract_equations(self):
436
+ """Extract and process equations from class-level definitions."""
437
+ equations_to_process = self._collect_class_equations()
438
+
439
+ for attr_name, equation in equations_to_process:
440
+ try:
441
+ # Add equation to the problem
442
+ self.add_equation(equation)
443
+ # Set the equation as an instance attribute
444
+ setattr(self, attr_name, equation)
445
+ except Exception as e:
446
+ # Log but continue - some equations might fail during class definition
447
+ self.logger.warning(f"Failed to process equation {attr_name}: {e}")
448
+ # Still set the original equation as attribute
449
+ setattr(self, attr_name, equation)
450
+
451
+ def _get_class_attributes(self) -> list[tuple[str, Any]]:
452
+ """Get all non-private class attributes efficiently."""
453
+ return [(attr_name, getattr(self.__class__, attr_name)) for attr_name in dir(self.__class__) if not attr_name.startswith("_")]
454
+
455
+ def _collect_class_equations(self) -> list[tuple[str, Any]]:
456
+ """Collect all equation objects from class attributes."""
457
+ equations_to_process = []
458
+ for attr_name, attr_value in self._get_class_attributes():
459
+ if isinstance(attr_value, Equation):
460
+ equations_to_process.append((attr_name, attr_value))
461
+ return equations_to_process
462
+
463
+ def _integrate_sub_problem(self, sub_problem, namespace: str) -> None:
464
+ """
465
+ Integrate a sub-problem by flattening its variables with namespace prefixes.
466
+ Creates a simple dotted access pattern: self.header.P becomes self.header_P
467
+ """
468
+ self.sub_problems[namespace] = sub_problem
469
+ proxy_configs = getattr(self.__class__, "_proxy_configurations", {}).get(namespace, {})
470
+
471
+ namespace_obj = self._create_namespace_object(sub_problem, namespace, proxy_configs)
472
+ super().__setattr__(namespace, namespace_obj)
473
+
474
+ self._integrate_sub_problem_equations(sub_problem, namespace)
475
+
476
+ def _create_namespace_object(self, sub_problem, namespace: str, proxy_configs: dict):
477
+ """Create namespace object with all sub-problem variables."""
478
+ namespace_obj = type("SubProblemNamespace", (), {})()
479
+
480
+ for var_symbol, var in sub_problem.variables.items():
481
+ namespaced_var = self._create_namespaced_variable(var, var_symbol, namespace, proxy_configs)
482
+ self.add_variable(namespaced_var)
483
+
484
+ # Set both namespaced access (self.header_P) and dotted access (self.header.P)
485
+ if namespaced_var.symbol is not None:
486
+ super().__setattr__(namespaced_var.symbol, namespaced_var)
487
+ setattr(namespace_obj, var_symbol, namespaced_var)
488
+
489
+ return namespace_obj
490
+
491
+ def _integrate_sub_problem_equations(self, sub_problem, namespace: str):
492
+ """Integrate equations from sub-problem with proper namespacing."""
493
+ for equation in sub_problem.equations:
494
+ try:
495
+ # Skip conditional equations for variables that are overridden to known values in composition
496
+ if self._should_skip_subproblem_equation(equation, namespace):
497
+ continue
498
+
499
+ namespaced_equation = self._namespace_equation(equation, namespace)
500
+ if namespaced_equation:
501
+ self.add_equation(namespaced_equation)
502
+ except Exception as e:
503
+ self.logger.debug(f"Failed to namespace equation from {namespace}: {e}")
504
+
505
+ def _create_namespaced_variable(self, var: FieldQnty, var_symbol: str, namespace: str, proxy_configs: dict) -> FieldQnty:
506
+ """Create a namespaced variable with proper configuration."""
507
+ namespaced_symbol = f"{namespace}_{var_symbol}"
508
+ namespaced_var = self._clone_variable(var)
509
+ namespaced_var.symbol = namespaced_symbol
510
+ namespaced_var.name = f"{var.name} ({namespace.title()})"
511
+
512
+ # Apply proxy configuration if available
513
+ if var_symbol in proxy_configs:
514
+ config = proxy_configs[var_symbol]
515
+ namespaced_var.quantity = config["quantity"]
516
+ namespaced_var.is_known = config["is_known"]
517
+
518
+ return namespaced_var
519
+
520
+ def _namespace_equation(self, equation, namespace: str):
521
+ """
522
+ Create a namespaced version of an equation by prefixing all variable references.
523
+ """
524
+ try:
525
+ # Get all variable symbols in the equation
526
+ variables_in_eq = equation.get_all_variables()
527
+
528
+ # Create mapping from original symbols to namespaced symbols
529
+ symbol_mapping = self._create_symbol_mapping(variables_in_eq, namespace)
530
+ if not symbol_mapping:
531
+ return None
532
+
533
+ # Create new equation with namespaced references
534
+ return self._create_namespaced_equation(equation, symbol_mapping)
535
+
536
+ except Exception:
537
+ return None
538
+
539
+ def _create_symbol_mapping(self, variables_in_eq: set[str], namespace: str) -> dict[str, str]:
540
+ """Create mapping from original symbols to namespaced symbols."""
541
+ symbol_mapping = {}
542
+ for var_symbol in variables_in_eq:
543
+ namespaced_symbol = f"{namespace}_{var_symbol}"
544
+ if namespaced_symbol in self.variables:
545
+ symbol_mapping[var_symbol] = namespaced_symbol
546
+ return symbol_mapping
547
+
548
+ def _create_namespaced_equation(self, equation: Equation, symbol_mapping: dict[str, str]) -> Equation | None:
549
+ """Create new equation with namespaced references."""
550
+ # For LHS, we need a Variable object to call .equals()
551
+ # For RHS, we need proper expression structure
552
+ namespaced_lhs = self._namespace_expression_for_lhs(equation.lhs, symbol_mapping)
553
+ namespaced_rhs = self._namespace_expression(equation.rhs, symbol_mapping)
554
+
555
+ if namespaced_lhs and namespaced_rhs and hasattr(namespaced_lhs, "equals"):
556
+ return namespaced_lhs.equals(namespaced_rhs)
557
+ return None
558
+
559
+ def _namespace_expression(self, expr, symbol_mapping):
560
+ """
561
+ Create a namespaced version of an expression by replacing variable references.
562
+ """
563
+ # Handle variable references
564
+ if isinstance(expr, VariableReference):
565
+ return self._namespace_variable_reference(expr, symbol_mapping)
566
+ elif isinstance(expr, FieldQnty) and expr.symbol in symbol_mapping:
567
+ return self._namespace_variable_object(expr, symbol_mapping)
568
+
569
+ # Handle operations
570
+ elif isinstance(expr, BinaryOperation):
571
+ return self._namespace_binary_operation(expr, symbol_mapping)
572
+ elif isinstance(expr, ConditionalExpression):
573
+ return self._namespace_conditional_expression(expr, symbol_mapping)
574
+ elif self._is_unary_operation(expr):
575
+ return self._namespace_unary_operation(expr, symbol_mapping)
576
+ elif self._is_binary_function(expr):
577
+ return self._namespace_binary_function(expr, symbol_mapping)
578
+ elif isinstance(expr, Constant):
579
+ return expr
580
+ else:
581
+ return expr
582
+
583
+ def _namespace_variable_reference(self, expr: VariableReference, symbol_mapping: dict[str, str]) -> VariableReference:
584
+ """Namespace a VariableReference object."""
585
+ # VariableReference uses the 'name' property which returns the symbol if available
586
+ symbol = expr.name
587
+ if symbol in symbol_mapping:
588
+ namespaced_symbol = symbol_mapping[symbol]
589
+ if namespaced_symbol in self.variables:
590
+ return VariableReference(self.variables[namespaced_symbol])
591
+ return expr
592
+
593
+ def _namespace_variable_object(self, expr: FieldQnty, symbol_mapping: dict[str, str]) -> VariableReference | FieldQnty:
594
+ """Namespace a Variable object."""
595
+ if expr.symbol is None:
596
+ return expr
597
+ namespaced_symbol = symbol_mapping[expr.symbol]
598
+ if namespaced_symbol in self.variables:
599
+ # Return VariableReference for use in expressions, not the Variable itself
600
+ return VariableReference(self.variables[namespaced_symbol])
601
+ return expr
602
+
603
+ def _namespace_binary_operation(self, expr: BinaryOperation, symbol_mapping: dict[str, str]) -> BinaryOperation:
604
+ """Namespace a BinaryOperation."""
605
+ namespaced_left = self._namespace_expression(expr.left, symbol_mapping)
606
+ namespaced_right = self._namespace_expression(expr.right, symbol_mapping)
607
+ return BinaryOperation(expr.operator, wrap_operand(namespaced_left), wrap_operand(namespaced_right))
608
+
609
+ def _namespace_unary_operation(self, expr, symbol_mapping):
610
+ """Namespace a UnaryFunction."""
611
+ namespaced_operand = self._namespace_expression(expr.operand, symbol_mapping)
612
+ return type(expr)(expr.operator, namespaced_operand)
613
+
614
+ def _namespace_binary_function(self, expr, symbol_mapping):
615
+ """Namespace a BinaryFunction."""
616
+ namespaced_left = self._namespace_expression(expr.left, symbol_mapping)
617
+ namespaced_right = self._namespace_expression(expr.right, symbol_mapping)
618
+ return type(expr)(expr.function_name, namespaced_left, namespaced_right)
619
+
620
+ def _namespace_conditional_expression(self, expr: ConditionalExpression, symbol_mapping: dict[str, str]) -> ConditionalExpression:
621
+ """Namespace a ConditionalExpression."""
622
+ namespaced_condition = self._namespace_expression(expr.condition, symbol_mapping)
623
+ namespaced_true_expr = self._namespace_expression(expr.true_expr, symbol_mapping)
624
+ namespaced_false_expr = self._namespace_expression(expr.false_expr, symbol_mapping)
625
+
626
+ return ConditionalExpression(wrap_operand(namespaced_condition), wrap_operand(namespaced_true_expr), wrap_operand(namespaced_false_expr))
627
+
628
+ def _namespace_expression_for_lhs(self, expr, symbol_mapping: dict[str, str]) -> FieldQnty | None:
629
+ """
630
+ Create a namespaced version of an expression for LHS, returning Variable objects.
631
+ """
632
+ if isinstance(expr, VariableReference):
633
+ # VariableReference uses the 'name' property which returns the symbol if available
634
+ symbol = expr.name
635
+ if symbol and symbol in symbol_mapping:
636
+ namespaced_symbol = symbol_mapping[symbol]
637
+ if namespaced_symbol in self.variables:
638
+ return self.variables[namespaced_symbol]
639
+ # If we can't find a mapping, return None since VariableReference doesn't have .equals()
640
+ return None
641
+ elif isinstance(expr, FieldQnty) and expr.symbol in symbol_mapping:
642
+ # This is a Variable object
643
+ namespaced_symbol = symbol_mapping[expr.symbol]
644
+ if namespaced_symbol in self.variables:
645
+ return self.variables[namespaced_symbol]
646
+ return expr
647
+ else:
648
+ return expr
649
+
650
+ def _is_unary_operation(self, expr) -> bool:
651
+ """Check if expression is a unary operation."""
652
+ # UnaryFunction and similar classes have an 'operand' attribute
653
+ return hasattr(expr, "operand") and hasattr(expr, "operator")
654
+
655
+ def _is_binary_function(self, expr) -> bool:
656
+ """Check if expression is a binary function."""
657
+ # BinaryFunction classes have 'function_name', 'left', and 'right' attributes
658
+ return hasattr(expr, "function_name") and hasattr(expr, "left") and hasattr(expr, "right")
659
+
660
+ def _should_skip_subproblem_equation(self, equation, namespace: str) -> bool:
661
+ """
662
+ Check if an equation from a sub-problem should be skipped during integration.
663
+
664
+ Skip conditional equations for variables that are set to known values in the composed problem.
665
+ """
666
+ try:
667
+ # Check if this is a conditional equation
668
+ if not self._is_conditional_equation(equation):
669
+ return False
670
+
671
+ # Check if the LHS variable would be set to a known value in composition
672
+ original_symbol = self._get_equation_lhs_symbol(equation)
673
+ if original_symbol is not None:
674
+ namespaced_symbol = f"{namespace}_{original_symbol}"
675
+
676
+ # Check if this namespaced variable exists and is already known
677
+ if namespaced_symbol in self.variables:
678
+ var = self.variables[namespaced_symbol]
679
+ if var.is_known:
680
+ # The variable is already set to a known value in composition,
681
+ # so skip the conditional equation that would override it
682
+ self.logger.debug(f"Skipping conditional equation for {namespaced_symbol} (already known: {var.quantity})")
683
+ return True
684
+
685
+ return False
686
+
687
+ except Exception:
688
+ return False
689
+
690
+ def _create_composite_equations(self):
691
+ """
692
+ Create composite equations for common patterns in sub-problems.
693
+ This handles equations like P = min(header.P, branch.P) automatically.
694
+ """
695
+ if not self.sub_problems:
696
+ return
697
+
698
+ # Common composite patterns to auto-generate
699
+ for var_name in COMMON_COMPOSITE_VARIABLES:
700
+ # Check if this variable exists in multiple sub-problems
701
+ sub_problem_vars = []
702
+ for namespace in self.sub_problems:
703
+ namespaced_symbol = f"{namespace}_{var_name}"
704
+ if namespaced_symbol in self.variables:
705
+ sub_problem_vars.append(self.variables[namespaced_symbol])
706
+
707
+ # If we have the variable in multiple sub-problems and no direct variable exists
708
+ if len(sub_problem_vars) >= 2 and var_name in self.variables:
709
+ # Check if a composite equation already exists
710
+ equation_attr_name = f"{var_name}_eqn"
711
+ if hasattr(self.__class__, equation_attr_name):
712
+ # Skip auto-creation since explicit equation exists
713
+ continue
714
+
715
+ # Auto-create composite equation
716
+ try:
717
+ composite_var = self.variables[var_name]
718
+ if not composite_var.is_known: # Only for unknown variables
719
+ composite_expr = min_expr(*sub_problem_vars)
720
+ equals_method = getattr(composite_var, "equals", None)
721
+ if equals_method:
722
+ composite_eq = equals_method(composite_expr)
723
+ self.add_equation(composite_eq)
724
+ setattr(self, f"{var_name}_eqn", composite_eq)
725
+ except Exception as e:
726
+ self.logger.debug(f"Failed to create composite equation for {var_name}: {e}")
727
+
728
+ # Placeholder methods that will be provided by Problem class
729
+ def _process_equation(self, attr_name: str, equation: Equation) -> bool:
730
+ """Will be provided by Problem class."""
731
+ del attr_name, equation # Unused in stub method
732
+ return True
733
+
734
+ def _is_conditional_equation(self, _equation: Equation) -> bool:
735
+ """Will be provided by Problem class."""
736
+ return "cond(" in str(_equation)
737
+
738
+ def _get_equation_lhs_symbol(self, equation: Equation) -> str | None:
739
+ """Will be provided by Problem class."""
740
+ return getattr(equation.lhs, "symbol", None)
741
+
742
+
743
+ # ========== METACLASS SYSTEM ==========
744
+
745
+
746
+ # Custom exceptions for better error handling
747
+ class MetaclassError(Exception):
748
+ """Base exception for metaclass-related errors."""
749
+
750
+ pass
751
+
752
+
753
+ class SubProblemProxyError(MetaclassError):
754
+ """Raised when sub-problem proxy creation fails."""
755
+
756
+ pass
757
+
758
+
759
+ class NamespaceError(MetaclassError):
760
+ """Raised when namespace operations fail."""
761
+
762
+ pass
763
+
764
+
765
+ class ProblemMeta(type):
766
+ """
767
+ Metaclass that processes class-level sub-problems to create proper namespace proxies
768
+ BEFORE any equations are evaluated.
769
+
770
+ This metaclass enables clean composition syntax like:
771
+ class MyProblem(EngineeringProblem):
772
+ header = create_pipe_problem()
773
+ branch = create_pipe_problem()
774
+ # Equations can reference header.P, branch.T, etc.
775
+ """
776
+
777
+ # Declare the attributes that will be dynamically added to created classes
778
+ _original_sub_problems: dict[str, Any]
779
+ _proxy_configurations: dict[str, dict[str, Any]]
780
+ _class_checks: dict[str, Any]
781
+
782
+ @classmethod
783
+ def __prepare__(mcs, *args, **kwargs) -> ProxiedNamespace:
784
+ """
785
+ Called before the class body is evaluated.
786
+ Returns a custom namespace that proxies sub-problems.
787
+
788
+ Args:
789
+ *args: Positional arguments (name, bases) - unused but required by protocol
790
+ **kwargs: Additional keyword arguments - unused but required by protocol
791
+
792
+ Returns:
793
+ ProxiedNamespace that will handle sub-problem proxying
794
+ """
795
+ # Parameters are required by metaclass protocol but not used in this implementation
796
+ del args, kwargs # Explicitly acknowledge unused parameters
797
+ return ProxiedNamespace()
798
+
799
+ def __new__(mcs, name: str, bases: tuple[type, ...], namespace: ProxiedNamespace, **kwargs) -> type:
800
+ """
801
+ Create the new class with properly integrated sub-problems.
802
+
803
+ Args:
804
+ name: Name of the class being created
805
+ bases: Base classes
806
+ namespace: The ProxiedNamespace containing proxied sub-problems
807
+ **kwargs: Additional keyword arguments - unused but required by protocol
808
+
809
+ Returns:
810
+ The newly created class with metaclass attributes
811
+
812
+ Raises:
813
+ MetaclassError: If class creation fails due to metaclass issues
814
+ """
815
+ # kwargs is required by metaclass protocol but not used in this implementation
816
+ del kwargs # Explicitly acknowledge unused parameter
817
+ try:
818
+ # Validate the namespace
819
+ if not isinstance(namespace, ProxiedNamespace):
820
+ raise MetaclassError(f"Expected ProxiedNamespace, got {type(namespace)}")
821
+
822
+ # Extract the original sub-problems and proxy objects from the namespace
823
+ sub_problem_proxies = getattr(namespace, "_sub_problem_proxies", {})
824
+ proxy_objects = getattr(namespace, "_proxy_objects", {})
825
+
826
+ # Validate that proxy objects are consistent
827
+ if set(sub_problem_proxies.keys()) != set(proxy_objects.keys()):
828
+ raise MetaclassError("Inconsistent proxy state: sub-problem and proxy object keys don't match")
829
+
830
+ # Create the class normally
831
+ cls = super().__new__(mcs, name, bases, dict(namespace))
832
+
833
+ # Store the original sub-problems and proxy configurations for later integration
834
+ cls._original_sub_problems = sub_problem_proxies
835
+
836
+ # Extract configurations safely with error handling
837
+ proxy_configurations = {}
838
+ for proxy_name, proxy in proxy_objects.items():
839
+ try:
840
+ # Cache configurations to avoid recomputation
841
+ if not hasattr(proxy, "_cached_configurations"):
842
+ proxy._cached_configurations = proxy.get_configurations()
843
+ proxy_configurations[proxy_name] = proxy._cached_configurations
844
+ except Exception as e:
845
+ raise SubProblemProxyError(f"Failed to get configurations from proxy '{proxy_name}': {e}") from e
846
+
847
+ cls._proxy_configurations = proxy_configurations
848
+
849
+ # Collect Check objects from class attributes
850
+ checks = {}
851
+ for attr_name, attr_value in namespace.items():
852
+ if isinstance(attr_value, Rules):
853
+ checks[attr_name] = attr_value
854
+
855
+ cls._class_checks = checks
856
+
857
+ return cls
858
+
859
+ except Exception as e:
860
+ # Re-raise MetaclassError and SubProblemProxyError as-is
861
+ if isinstance(e, MetaclassError | SubProblemProxyError):
862
+ raise
863
+ # Wrap other exceptions
864
+ raise MetaclassError(f"Failed to create class '{name}': {e}") from e
865
+
866
+
867
+ class ProxiedNamespace(dict):
868
+ """
869
+ Custom namespace that automatically proxies sub-problems as they're added.
870
+
871
+ This namespace intercepts class attribute assignments during class creation
872
+ and automatically wraps EngineeringProblem objects in SubProblemProxy objects.
873
+ This enables clean composition syntax where sub-problems can be referenced
874
+ with dot notation in equations.
875
+
876
+ Example:
877
+ class ComposedProblem(EngineeringProblem):
878
+ header = create_pipe_problem() # Gets proxied automatically
879
+ branch = create_pipe_problem() # Gets proxied automatically
880
+ # Now equations can use header.P, branch.T, etc.
881
+ """
882
+
883
+ def __init__(self) -> None:
884
+ """Initialize the proxied namespace with empty storage."""
885
+ super().__init__()
886
+ self._sub_problem_proxies: dict[str, Any] = {}
887
+ self._proxy_objects: dict[str, SubProblemProxy] = {}
888
+
889
+ def __setitem__(self, key: str, value: Any) -> None:
890
+ """
891
+ Intercept attribute assignment and proxy sub-problems automatically.
892
+
893
+ Args:
894
+ key: The attribute name being set
895
+ value: The value being assigned
896
+
897
+ Raises:
898
+ NamespaceError: If namespace operation fails
899
+ SubProblemProxyError: If proxy creation fails
900
+ """
901
+ try:
902
+ if self._is_sub_problem(key, value):
903
+ self._create_and_store_proxy(key, value)
904
+ elif self._is_variable_with_auto_symbol(value):
905
+ self._set_variable_symbol_and_store(key, value)
906
+ else:
907
+ super().__setitem__(key, value)
908
+ except Exception as e:
909
+ if isinstance(e, NamespaceError | SubProblemProxyError):
910
+ raise
911
+ raise NamespaceError(f"Failed to set attribute '{key}': {e}") from e
912
+
913
+ def _is_sub_problem(self, key: str, value: Any) -> bool:
914
+ """
915
+ Determine if a value should be treated as a sub-problem.
916
+
917
+ Args:
918
+ key: The attribute name
919
+ value: The value being assigned
920
+
921
+ Returns:
922
+ True if this should be proxied as a sub-problem
923
+ """
924
+ # Quick checks first (fail fast)
925
+ if key.startswith(PRIVATE_ATTRIBUTE_PREFIX) or key in RESERVED_ATTRIBUTES:
926
+ return False
927
+
928
+ # Check for None or basic types that definitely aren't sub-problems
929
+ if value is None or isinstance(value, str | int | float | bool | list | dict):
930
+ return False
931
+
932
+ # Cache hasattr results to avoid repeated attribute lookups
933
+ if not hasattr(self, "_attr_cache"):
934
+ self._attr_cache = {}
935
+
936
+ # Use object id as cache key since objects are unique
937
+ cache_key = (id(value), tuple(SUB_PROBLEM_REQUIRED_ATTRIBUTES))
938
+ if cache_key not in self._attr_cache:
939
+ self._attr_cache[cache_key] = all(hasattr(value, attr) for attr in SUB_PROBLEM_REQUIRED_ATTRIBUTES)
940
+
941
+ return self._attr_cache[cache_key]
942
+
943
+ def _is_variable_with_auto_symbol(self, value: Any) -> bool:
944
+ """
945
+ Determine if a value is a Variable that needs automatic symbol assignment.
946
+
947
+ Args:
948
+ value: The value being assigned
949
+
950
+ Returns:
951
+ True if this is a Variable that needs automatic symbol assignment
952
+ """
953
+ # Import Variable here to avoid circular imports
954
+ try:
955
+ if not isinstance(value, FieldQnty):
956
+ return False
957
+ # Auto-assign symbol if:
958
+ # 1. Symbol is explicitly "<auto>", OR
959
+ # 2. Symbol equals the variable name (default behavior, no explicit symbol set)
960
+ return value.symbol == "<auto>" or value.symbol == value.name
961
+ except ImportError:
962
+ return False
963
+
964
+ def _set_variable_symbol_and_store(self, key: str, value: Any) -> None:
965
+ """
966
+ Set the variable's symbol to the attribute name and store it.
967
+
968
+ Args:
969
+ key: The attribute name to use as symbol
970
+ value: The Variable object
971
+ """
972
+ try:
973
+ # Set the symbol to the attribute name
974
+ value.symbol = key
975
+ # Store the modified variable
976
+ super().__setitem__(key, value)
977
+ except Exception as e:
978
+ raise NamespaceError(f"Failed to set symbol for variable '{key}': {e}") from e
979
+
980
+ def _create_and_store_proxy(self, key: str, value: Any) -> None:
981
+ """
982
+ Create a proxy for the sub-problem and store references.
983
+
984
+ Args:
985
+ key: The attribute name for the sub-problem
986
+ value: The sub-problem object to proxy
987
+
988
+ Raises:
989
+ SubProblemProxyError: If proxy creation fails
990
+ NamespaceError: If key already exists as a sub-problem
991
+ """
992
+ # Check for conflicts
993
+ if key in self._sub_problem_proxies:
994
+ raise NamespaceError(f"Sub-problem '{key}' already exists in namespace")
995
+
996
+ try:
997
+ # Store the original sub-problem
998
+ self._sub_problem_proxies[key] = value
999
+
1000
+ # Create and store the proxy
1001
+ proxy = SubProblemProxy(value, key)
1002
+ self._proxy_objects[key] = proxy
1003
+
1004
+ # Set the proxy in the namespace
1005
+ super().__setitem__(key, proxy)
1006
+
1007
+ except Exception as e:
1008
+ # Clean up partial state on failure
1009
+ self._sub_problem_proxies.pop(key, None)
1010
+ self._proxy_objects.pop(key, None)
1011
+ raise SubProblemProxyError(f"Failed to create proxy for sub-problem '{key}': {e}") from e
1012
+
1013
+
1014
+ # Export all relevant classes
1015
+ __all__ = [
1016
+ "CompositionMixin",
1017
+ "ProblemMeta",
1018
+ "ProxiedNamespace",
1019
+ "SubProblemProxy",
1020
+ "ConfigurableVariable",
1021
+ "DelayedEquation",
1022
+ "DelayedVariableReference",
1023
+ "DelayedExpression",
1024
+ "DelayedFunction",
1025
+ "delayed_sin",
1026
+ "delayed_min_expr",
1027
+ "delayed_max_expr",
1028
+ "MetaclassError",
1029
+ "SubProblemProxyError",
1030
+ "NamespaceError",
1031
+ ]