qnty 0.0.7__py3-none-any.whl → 0.0.9__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.
- qnty/__init__.py +140 -58
- qnty/_backup/problem_original.py +1251 -0
- qnty/_backup/quantity.py +63 -0
- qnty/codegen/cli.py +125 -0
- qnty/codegen/generators/data/unit_data.json +8807 -0
- qnty/codegen/generators/data_processor.py +345 -0
- qnty/codegen/generators/dimensions_gen.py +434 -0
- qnty/codegen/generators/doc_generator.py +141 -0
- qnty/codegen/generators/out/dimension_mapping.json +974 -0
- qnty/codegen/generators/out/dimension_metadata.json +123 -0
- qnty/codegen/generators/out/units_metadata.json +223 -0
- qnty/codegen/generators/quantities_gen.py +159 -0
- qnty/codegen/generators/setters_gen.py +178 -0
- qnty/codegen/generators/stubs_gen.py +167 -0
- qnty/codegen/generators/units_gen.py +295 -0
- qnty/codegen/generators/utils/__init__.py +0 -0
- qnty/equations/__init__.py +4 -0
- qnty/equations/equation.py +257 -0
- qnty/equations/system.py +127 -0
- qnty/expressions/__init__.py +61 -0
- qnty/expressions/cache.py +94 -0
- qnty/expressions/functions.py +96 -0
- qnty/expressions/nodes.py +546 -0
- qnty/generated/__init__.py +0 -0
- qnty/generated/dimensions.py +514 -0
- qnty/generated/quantities.py +6003 -0
- qnty/generated/quantities.pyi +4192 -0
- qnty/generated/setters.py +12210 -0
- qnty/generated/units.py +9798 -0
- qnty/problem/__init__.py +91 -0
- qnty/problem/base.py +142 -0
- qnty/problem/composition.py +385 -0
- qnty/problem/composition_mixin.py +382 -0
- qnty/problem/equations.py +413 -0
- qnty/problem/metaclass.py +302 -0
- qnty/problem/reconstruction.py +1016 -0
- qnty/problem/solving.py +180 -0
- qnty/problem/validation.py +64 -0
- qnty/problem/variables.py +239 -0
- qnty/quantities/__init__.py +6 -0
- qnty/quantities/expression_quantity.py +314 -0
- qnty/quantities/quantity.py +428 -0
- qnty/quantities/typed_quantity.py +215 -0
- qnty/solving/__init__.py +0 -0
- qnty/solving/manager.py +90 -0
- qnty/solving/order.py +355 -0
- qnty/solving/solvers/__init__.py +20 -0
- qnty/solving/solvers/base.py +92 -0
- qnty/solving/solvers/iterative.py +185 -0
- qnty/solving/solvers/simultaneous.py +547 -0
- qnty/units/__init__.py +0 -0
- qnty/{prefixes.py → units/prefixes.py} +54 -33
- qnty/{unit.py → units/registry.py} +73 -32
- qnty/utils/__init__.py +0 -0
- qnty/utils/logging.py +40 -0
- qnty/validation/__init__.py +0 -0
- qnty/validation/registry.py +0 -0
- qnty/validation/rules.py +167 -0
- qnty-0.0.9.dist-info/METADATA +199 -0
- qnty-0.0.9.dist-info/RECORD +63 -0
- qnty/dimension.py +0 -186
- qnty/equation.py +0 -216
- qnty/expression.py +0 -492
- qnty/unit_types/base.py +0 -47
- qnty/units.py +0 -8113
- qnty/variable.py +0 -263
- qnty/variable_types/base.py +0 -58
- qnty/variable_types/expression_variable.py +0 -68
- qnty/variable_types/typed_variable.py +0 -87
- qnty/variables.py +0 -2298
- qnty/variables.pyi +0 -6148
- qnty-0.0.7.dist-info/METADATA +0 -355
- qnty-0.0.7.dist-info/RECORD +0 -19
- /qnty/{unit_types → codegen}/__init__.py +0 -0
- /qnty/{variable_types → codegen/generators}/__init__.py +0 -0
- {qnty-0.0.7.dist-info → qnty-0.0.9.dist-info}/WHEEL +0 -0
@@ -0,0 +1,1251 @@
|
|
1
|
+
"""
|
2
|
+
Core EngineeringProblem class for engineering problem solving.
|
3
|
+
|
4
|
+
This module provides the main EngineeringProblem class that coordinates
|
5
|
+
all aspects of engineering problem definition, solving, and analysis.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from __future__ import annotations
|
9
|
+
|
10
|
+
from collections.abc import Callable
|
11
|
+
from typing import TYPE_CHECKING, Any
|
12
|
+
|
13
|
+
from qnty.equations import Equation, EquationSystem
|
14
|
+
|
15
|
+
if TYPE_CHECKING:
|
16
|
+
from qnty.expressions import BinaryOperation, Constant, VariableReference
|
17
|
+
from qnty.utils.logging import get_logger
|
18
|
+
from qnty.solving.order import Order
|
19
|
+
from qnty.problem.reconstruction import EquationReconstructor
|
20
|
+
from qnty.problem.metaclass import ProblemMeta
|
21
|
+
from qnty.solving.solvers import SolverManager
|
22
|
+
from qnty.quantities import Quantity as Qty
|
23
|
+
from qnty.quantities import TypeSafeVariable as Variable
|
24
|
+
from qnty.generated.units import DimensionlessUnits
|
25
|
+
|
26
|
+
# Constants
|
27
|
+
MAX_ITERATIONS_DEFAULT = 100
|
28
|
+
TOLERANCE_DEFAULT = 1e-10
|
29
|
+
MATHEMATICAL_OPERATORS = ['+', '-', '*', '/', ' / ', ' * ', ' + ', ' - ']
|
30
|
+
COMMON_COMPOSITE_VARIABLES = ['P', 'c', 'S', 'E', 'W', 'Y']
|
31
|
+
|
32
|
+
|
33
|
+
# Custom Exceptions
|
34
|
+
class VariableNotFoundError(KeyError):
|
35
|
+
"""Raised when trying to access a variable that doesn't exist."""
|
36
|
+
pass
|
37
|
+
|
38
|
+
|
39
|
+
class EquationValidationError(ValueError):
|
40
|
+
"""Raised when an equation fails validation."""
|
41
|
+
pass
|
42
|
+
|
43
|
+
|
44
|
+
class SolverError(RuntimeError):
|
45
|
+
"""Raised when the solving process fails."""
|
46
|
+
pass
|
47
|
+
|
48
|
+
|
49
|
+
class Problem(metaclass=ProblemMeta):
|
50
|
+
"""
|
51
|
+
Main container class for engineering problems.
|
52
|
+
|
53
|
+
This class coordinates all aspects of engineering problem definition, solving, and analysis.
|
54
|
+
It supports both programmatic problem construction and class-level inheritance patterns
|
55
|
+
for defining domain-specific engineering problems.
|
56
|
+
|
57
|
+
Key Features:
|
58
|
+
- Automatic dependency graph construction and topological solving order
|
59
|
+
- Dual solving approach: SymPy symbolic solving with numerical fallback
|
60
|
+
- Sub-problem composition with automatic variable namespacing
|
61
|
+
- Comprehensive validation and error handling
|
62
|
+
- Professional report generation capabilities
|
63
|
+
|
64
|
+
Usage Patterns:
|
65
|
+
1. Inheritance Pattern (Recommended for domain problems):
|
66
|
+
class MyProblem(EngineeringProblem):
|
67
|
+
x = Variable("x", Qty(5.0, length))
|
68
|
+
y = Variable("y", Qty(0.0, length), is_known=False)
|
69
|
+
eq = y.equals(x * 2)
|
70
|
+
|
71
|
+
2. Programmatic Pattern (For dynamic problems):
|
72
|
+
problem = EngineeringProblem("Dynamic Problem")
|
73
|
+
problem.add_variables(x, y)
|
74
|
+
problem.add_equation(y.equals(x * 2))
|
75
|
+
|
76
|
+
3. Composition Pattern (For reusable sub-problems):
|
77
|
+
class ComposedProblem(EngineeringProblem):
|
78
|
+
sub1 = create_sub_problem()
|
79
|
+
sub2 = create_sub_problem()
|
80
|
+
# Equations can reference sub1.variable, sub2.variable
|
81
|
+
|
82
|
+
Attributes:
|
83
|
+
name (str): Human-readable name for the problem
|
84
|
+
description (str): Detailed description of the problem
|
85
|
+
variables (dict[str, Variable]): All variables in the problem
|
86
|
+
equations (list[Equation]): All equations in the problem
|
87
|
+
is_solved (bool): Whether the problem has been successfully solved
|
88
|
+
solution (dict[str, Variable]): Solved variable values
|
89
|
+
sub_problems (dict[str, EngineeringProblem]): Integrated sub-problems
|
90
|
+
"""
|
91
|
+
|
92
|
+
def __init__(self, name: str | None = None, description: str = ""):
|
93
|
+
# Handle subclass mode (class-level name/description) vs explicit name
|
94
|
+
self.name = name or getattr(self.__class__, 'name', self.__class__.__name__)
|
95
|
+
self.description = description or getattr(self.__class__, 'description', "")
|
96
|
+
|
97
|
+
# Core storage
|
98
|
+
self.variables: dict[str, Variable] = {}
|
99
|
+
self.equations: list[Equation] = []
|
100
|
+
|
101
|
+
# Internal systems
|
102
|
+
self.equation_system = EquationSystem()
|
103
|
+
self.dependency_graph = Order()
|
104
|
+
|
105
|
+
# Solving state
|
106
|
+
self.is_solved = False
|
107
|
+
self.solution: dict[str, Variable] = {}
|
108
|
+
self.solving_history: list[dict[str, Any]] = []
|
109
|
+
|
110
|
+
# Performance optimization caches
|
111
|
+
self._known_variables_cache: dict[str, Variable] | None = None
|
112
|
+
self._unknown_variables_cache: dict[str, Variable] | None = None
|
113
|
+
self._cache_dirty = True
|
114
|
+
|
115
|
+
# Validation and warning system
|
116
|
+
self.warnings: list[dict[str, Any]] = []
|
117
|
+
self.validation_checks: list[Callable] = []
|
118
|
+
|
119
|
+
self.logger = get_logger()
|
120
|
+
self.solver_manager = SolverManager(self.logger)
|
121
|
+
|
122
|
+
# Sub-problem composition support
|
123
|
+
self.sub_problems: dict[str, Problem] = {}
|
124
|
+
self.variable_aliases: dict[str, str] = {} # Maps alias -> original variable symbol
|
125
|
+
|
126
|
+
# Initialize equation reconstructor
|
127
|
+
self.equation_reconstructor = EquationReconstructor(self)
|
128
|
+
|
129
|
+
# Auto-populate from class-level variables and equations (subclass pattern)
|
130
|
+
self._extract_from_class_variables()
|
131
|
+
|
132
|
+
def _extract_from_class_variables(self):
|
133
|
+
"""Extract variables, equations, and sub-problems from class-level definitions."""
|
134
|
+
self._extract_sub_problems()
|
135
|
+
self._extract_direct_variables()
|
136
|
+
self._recreate_validation_checks()
|
137
|
+
self._create_composite_equations()
|
138
|
+
self._extract_equations()
|
139
|
+
|
140
|
+
def _extract_sub_problems(self):
|
141
|
+
"""Extract and integrate sub-problems from class-level definitions."""
|
142
|
+
if hasattr(self.__class__, '_original_sub_problems'):
|
143
|
+
original_sub_problems = getattr(self.__class__, '_original_sub_problems', {})
|
144
|
+
for attr_name, sub_problem in original_sub_problems.items():
|
145
|
+
self._integrate_sub_problem(sub_problem, attr_name)
|
146
|
+
|
147
|
+
def _extract_direct_variables(self):
|
148
|
+
"""Extract direct variables from class-level definitions."""
|
149
|
+
processed_symbols = set()
|
150
|
+
|
151
|
+
# Single pass through class attributes to collect variables
|
152
|
+
for attr_name, attr_value in self._get_class_attributes():
|
153
|
+
if isinstance(attr_value, Variable):
|
154
|
+
# Set symbol based on attribute name (T_bar, P, etc.)
|
155
|
+
attr_value.symbol = attr_name
|
156
|
+
|
157
|
+
# Skip if we've already processed this symbol
|
158
|
+
if attr_value.symbol in processed_symbols:
|
159
|
+
continue
|
160
|
+
processed_symbols.add(attr_value.symbol)
|
161
|
+
|
162
|
+
# Clone variable to avoid shared state between instances
|
163
|
+
cloned_var = self._clone_variable(attr_value)
|
164
|
+
self.add_variable(cloned_var)
|
165
|
+
# Set the same cloned variable object as instance attribute
|
166
|
+
# Use super() to bypass our custom __setattr__ during initialization
|
167
|
+
super().__setattr__(attr_name, cloned_var)
|
168
|
+
|
169
|
+
def _extract_equations(self):
|
170
|
+
"""Extract and process equations from class-level definitions."""
|
171
|
+
equations_to_process = self._collect_class_equations()
|
172
|
+
|
173
|
+
for attr_name, equation in equations_to_process:
|
174
|
+
try:
|
175
|
+
if self._process_equation(attr_name, equation):
|
176
|
+
setattr(self, attr_name, equation)
|
177
|
+
except Exception as e:
|
178
|
+
# Log but continue - some equations might fail during class definition
|
179
|
+
self.logger.warning(f"Failed to process equation {attr_name}: {e}")
|
180
|
+
# Still set the original equation as attribute
|
181
|
+
setattr(self, attr_name, equation)
|
182
|
+
|
183
|
+
def _get_class_attributes(self) -> list[tuple[str, Any]]:
|
184
|
+
"""Get all non-private class attributes efficiently."""
|
185
|
+
return [(attr_name, getattr(self.__class__, attr_name))
|
186
|
+
for attr_name in dir(self.__class__)
|
187
|
+
if not attr_name.startswith('_')]
|
188
|
+
|
189
|
+
def _collect_class_equations(self) -> list[tuple[str, Equation]]:
|
190
|
+
"""Collect all equation objects from class attributes."""
|
191
|
+
equations_to_process = []
|
192
|
+
for attr_name, attr_value in self._get_class_attributes():
|
193
|
+
if isinstance(attr_value, Equation):
|
194
|
+
equations_to_process.append((attr_name, attr_value))
|
195
|
+
return equations_to_process
|
196
|
+
|
197
|
+
def _process_equation(self, attr_name: str, equation: Equation) -> bool:
|
198
|
+
"""Process a single equation and add it to the problem if valid."""
|
199
|
+
return self._process_equation_impl(attr_name, equation)
|
200
|
+
|
201
|
+
def _process_equation_impl(self, attr_name: str, equation: Equation) -> bool:
|
202
|
+
"""
|
203
|
+
Process a single equation and determine if it should be added.
|
204
|
+
Returns True if the equation was successfully processed.
|
205
|
+
"""
|
206
|
+
# First, update variable references to use symbols instead of names
|
207
|
+
updated_equation = self._update_equation_variable_references(equation)
|
208
|
+
|
209
|
+
# Check if this equation contains delayed expressions
|
210
|
+
try:
|
211
|
+
has_delayed = self.equation_reconstructor.contains_delayed_expressions(updated_equation)
|
212
|
+
if has_delayed:
|
213
|
+
return self._handle_delayed_equation(attr_name, updated_equation)
|
214
|
+
except Exception as e:
|
215
|
+
self.logger.debug(f"Error checking delayed expressions for {attr_name}: {e}")
|
216
|
+
|
217
|
+
# Check if this equation has invalid self-references
|
218
|
+
try:
|
219
|
+
has_self_ref = self._has_invalid_self_references(updated_equation)
|
220
|
+
if has_self_ref:
|
221
|
+
self.logger.debug(f"Skipping invalid self-referencing equation {attr_name}: {updated_equation}")
|
222
|
+
return False
|
223
|
+
except Exception as e:
|
224
|
+
self.logger.debug(f"Error checking self-references for {attr_name}: {e}")
|
225
|
+
|
226
|
+
# Check if equation references non-existent variables
|
227
|
+
try:
|
228
|
+
has_missing = self._equation_has_missing_variables(updated_equation)
|
229
|
+
if has_missing:
|
230
|
+
return self._handle_equation_with_missing_variables(attr_name, updated_equation)
|
231
|
+
except Exception as e:
|
232
|
+
self.logger.debug(f"Error checking missing variables for {attr_name}: {e}")
|
233
|
+
|
234
|
+
# Process valid equation
|
235
|
+
self.add_equation(updated_equation)
|
236
|
+
return True
|
237
|
+
|
238
|
+
def _handle_delayed_equation(self, attr_name: str, equation: Equation) -> bool:
|
239
|
+
"""Handle equations with delayed expressions."""
|
240
|
+
resolved_equation = self.equation_reconstructor.resolve_delayed_equation(equation)
|
241
|
+
if resolved_equation:
|
242
|
+
self.add_equation(resolved_equation)
|
243
|
+
setattr(self, attr_name, resolved_equation)
|
244
|
+
return True
|
245
|
+
else:
|
246
|
+
self.logger.debug(f"Skipping unresolvable delayed equation {attr_name}: {equation}")
|
247
|
+
return False
|
248
|
+
|
249
|
+
def _handle_equation_with_missing_variables(self, attr_name: str, equation: Equation) -> bool:
|
250
|
+
"""Handle equations that reference missing variables."""
|
251
|
+
# Handle conditional equations more carefully
|
252
|
+
if self._is_conditional_equation(equation):
|
253
|
+
return self._handle_conditional_equation(attr_name, equation)
|
254
|
+
|
255
|
+
# Only attempt reconstruction for simple mathematical expressions from composition
|
256
|
+
if self.equation_reconstructor.should_attempt_reconstruction(equation):
|
257
|
+
return self._attempt_equation_reconstruction(attr_name, equation)
|
258
|
+
else:
|
259
|
+
# Skip other problematic equations
|
260
|
+
self.logger.debug(f"Skipping equation with missing variables {attr_name}: {equation}")
|
261
|
+
return False
|
262
|
+
|
263
|
+
def _handle_conditional_equation(self, attr_name: str, equation: Equation) -> bool:
|
264
|
+
"""Handle conditional equations with missing variables."""
|
265
|
+
missing_vars = equation.get_all_variables() - set(self.variables.keys())
|
266
|
+
|
267
|
+
|
268
|
+
# Skip conditional equations from sub-problems in composed systems
|
269
|
+
if self.sub_problems and self._is_conditional_equation_from_subproblem(equation, attr_name):
|
270
|
+
self.logger.debug(f"Skipping conditional equation {attr_name} from sub-problem in composed system")
|
271
|
+
return False
|
272
|
+
|
273
|
+
# Check for composite expressions that might be reconstructable
|
274
|
+
unresolvable_vars = [var for var in missing_vars
|
275
|
+
if any(op in var for op in MATHEMATICAL_OPERATORS)]
|
276
|
+
|
277
|
+
if self.sub_problems and unresolvable_vars:
|
278
|
+
# Before skipping, try to reconstruct conditional equations with composite expressions
|
279
|
+
self.logger.debug(f"Attempting to reconstruct conditional equation {attr_name} with composite variables: {unresolvable_vars}")
|
280
|
+
reconstructed_equation = self.equation_reconstructor.reconstruct_composite_expressions_generically(equation)
|
281
|
+
if reconstructed_equation:
|
282
|
+
self.logger.debug(f"Successfully reconstructed conditional equation {attr_name}: {reconstructed_equation}")
|
283
|
+
self.add_equation(reconstructed_equation)
|
284
|
+
setattr(self, attr_name, reconstructed_equation)
|
285
|
+
return True
|
286
|
+
else:
|
287
|
+
self.logger.debug(f"Failed to reconstruct conditional equation {attr_name}, trying simple substitution")
|
288
|
+
# Try simple substitution for basic arithmetic expressions
|
289
|
+
reconstructed_equation = self._try_simple_substitution(equation, missing_vars)
|
290
|
+
if reconstructed_equation:
|
291
|
+
self.add_equation(reconstructed_equation)
|
292
|
+
return True
|
293
|
+
else:
|
294
|
+
self.add_equation(equation)
|
295
|
+
return True
|
296
|
+
else:
|
297
|
+
# Try to add the conditional equation even with missing simple variables
|
298
|
+
self.add_equation(equation)
|
299
|
+
return True
|
300
|
+
|
301
|
+
def _try_simple_substitution(self, _equation: Equation, _missing_vars: set[str]) -> Equation | None:
|
302
|
+
"""
|
303
|
+
Try simple substitution for basic arithmetic expressions in conditional equations.
|
304
|
+
|
305
|
+
The real issue is that nested expressions in conditionals aren't being handled properly.
|
306
|
+
For now, just return None and let the equation be added as-is.
|
307
|
+
"""
|
308
|
+
return None
|
309
|
+
|
310
|
+
def _fix_variable_references(self, equation: Equation) -> Equation:
|
311
|
+
"""
|
312
|
+
Fix VariableReferences in equation expressions to point to Variables in problem.variables.
|
313
|
+
|
314
|
+
This resolves issues where expression trees contain VariableReferences pointing to
|
315
|
+
proxy Variables from class creation time instead of the actual Variables in the problem.
|
316
|
+
"""
|
317
|
+
try:
|
318
|
+
# Fix the RHS expression
|
319
|
+
fixed_rhs = self._fix_expression_variables(equation.rhs)
|
320
|
+
|
321
|
+
# Create new equation with fixed RHS (LHS should already be correct)
|
322
|
+
return Equation(equation.name, equation.lhs, fixed_rhs)
|
323
|
+
|
324
|
+
except Exception as e:
|
325
|
+
self.logger.debug(f"Error fixing variable references in equation {equation.name}: {e}")
|
326
|
+
return equation # Return original if fixing fails
|
327
|
+
|
328
|
+
def _fix_expression_variables(self, expr):
|
329
|
+
"""
|
330
|
+
Recursively fix VariableReferences in an expression tree to point to correct Variables.
|
331
|
+
"""
|
332
|
+
|
333
|
+
if isinstance(expr, VariableReference):
|
334
|
+
# Check if this VariableReference points to the wrong Variable
|
335
|
+
symbol = getattr(expr, 'symbol', None)
|
336
|
+
if symbol and symbol in self.variables:
|
337
|
+
correct_var = self.variables[symbol]
|
338
|
+
if expr.variable is not correct_var:
|
339
|
+
# Create new VariableReference pointing to correct Variable
|
340
|
+
return VariableReference(correct_var)
|
341
|
+
return expr
|
342
|
+
|
343
|
+
elif isinstance(expr, BinaryOperation):
|
344
|
+
# Recursively fix left and right operands
|
345
|
+
fixed_left = self._fix_expression_variables(expr.left)
|
346
|
+
fixed_right = self._fix_expression_variables(expr.right)
|
347
|
+
return BinaryOperation(expr.operator, fixed_left, fixed_right)
|
348
|
+
|
349
|
+
elif hasattr(expr, 'operand'):
|
350
|
+
# Recursively fix operand
|
351
|
+
fixed_operand = self._fix_expression_variables(expr.operand)
|
352
|
+
return type(expr)(expr.operator, fixed_operand)
|
353
|
+
|
354
|
+
elif hasattr(expr, 'function_name'):
|
355
|
+
# Recursively fix left and right operands
|
356
|
+
fixed_left = self._fix_expression_variables(expr.left)
|
357
|
+
fixed_right = self._fix_expression_variables(expr.right)
|
358
|
+
return type(expr)(expr.function_name, fixed_left, fixed_right)
|
359
|
+
|
360
|
+
elif isinstance(expr, Constant):
|
361
|
+
return expr
|
362
|
+
|
363
|
+
else:
|
364
|
+
# Unknown expression type, return as-is
|
365
|
+
return expr
|
366
|
+
|
367
|
+
def _attempt_equation_reconstruction(self, attr_name: str, equation: Equation) -> bool:
|
368
|
+
"""Attempt to reconstruct equations with composite expressions."""
|
369
|
+
missing_vars = equation.get_all_variables() - set(self.variables.keys())
|
370
|
+
self.logger.debug(f"Attempting to reconstruct equation {attr_name} with missing variables: {missing_vars}")
|
371
|
+
|
372
|
+
reconstructed_equation = self.equation_reconstructor.reconstruct_composite_expressions_generically(equation)
|
373
|
+
if reconstructed_equation:
|
374
|
+
self.logger.debug(f"Successfully reconstructed {attr_name}: {reconstructed_equation}")
|
375
|
+
self.add_equation(reconstructed_equation)
|
376
|
+
setattr(self, attr_name, reconstructed_equation)
|
377
|
+
return True
|
378
|
+
else:
|
379
|
+
self.logger.debug(f"Failed to reconstruct equation {attr_name}: {equation}")
|
380
|
+
return False
|
381
|
+
|
382
|
+
def _integrate_sub_problem(self, sub_problem: Problem, namespace: str) -> None:
|
383
|
+
"""
|
384
|
+
Integrate a sub-problem by flattening its variables with namespace prefixes.
|
385
|
+
Creates a simple dotted access pattern: self.header.P becomes self.header_P
|
386
|
+
"""
|
387
|
+
self.sub_problems[namespace] = sub_problem
|
388
|
+
|
389
|
+
# Get proxy configurations if available
|
390
|
+
proxy_configs = getattr(self.__class__, '_proxy_configurations', {}).get(namespace, {})
|
391
|
+
|
392
|
+
# Create a namespace object for dotted access (self.header.P)
|
393
|
+
namespace_obj = type('SubProblemNamespace', (), {})()
|
394
|
+
|
395
|
+
# Add all sub-problem variables with namespace prefixes
|
396
|
+
for var_symbol, var in sub_problem.variables.items():
|
397
|
+
namespaced_var = self._create_namespaced_variable(var, var_symbol, namespace, proxy_configs)
|
398
|
+
self.add_variable(namespaced_var)
|
399
|
+
|
400
|
+
# Set both namespaced access (self.header_P) and dotted access (self.header.P)
|
401
|
+
if namespaced_var.symbol is not None:
|
402
|
+
super().__setattr__(namespaced_var.symbol, namespaced_var)
|
403
|
+
setattr(namespace_obj, var_symbol, namespaced_var)
|
404
|
+
|
405
|
+
# Set the namespace object for dotted access
|
406
|
+
super().__setattr__(namespace, namespace_obj)
|
407
|
+
|
408
|
+
# Also add all sub-problem equations (they'll be namespaced automatically)
|
409
|
+
for equation in sub_problem.equations:
|
410
|
+
try:
|
411
|
+
# Skip conditional equations for variables that are overridden to known values in composition
|
412
|
+
if self._should_skip_subproblem_equation(equation, namespace):
|
413
|
+
continue
|
414
|
+
|
415
|
+
namespaced_equation = self._namespace_equation(equation, namespace)
|
416
|
+
if namespaced_equation:
|
417
|
+
self.add_equation(namespaced_equation)
|
418
|
+
except Exception as e:
|
419
|
+
self.logger.debug(f"Failed to namespace equation from {namespace}: {e}")
|
420
|
+
|
421
|
+
def _create_namespaced_variable(self, var: Variable, var_symbol: str, namespace: str, proxy_configs: dict) -> Variable:
|
422
|
+
"""Create a namespaced variable with proper configuration."""
|
423
|
+
namespaced_symbol = f"{namespace}_{var_symbol}"
|
424
|
+
namespaced_var = self._clone_variable(var)
|
425
|
+
namespaced_var.symbol = namespaced_symbol
|
426
|
+
namespaced_var.name = f"{var.name} ({namespace.title()})"
|
427
|
+
|
428
|
+
# Apply proxy configuration if available
|
429
|
+
if var_symbol in proxy_configs:
|
430
|
+
config = proxy_configs[var_symbol]
|
431
|
+
namespaced_var.quantity = config['quantity']
|
432
|
+
namespaced_var.is_known = config['is_known']
|
433
|
+
|
434
|
+
return namespaced_var
|
435
|
+
|
436
|
+
def _namespace_equation(self, equation: Equation, namespace: str) -> Equation | None:
|
437
|
+
"""
|
438
|
+
Create a namespaced version of an equation by prefixing all variable references.
|
439
|
+
"""
|
440
|
+
try:
|
441
|
+
# Get all variable symbols in the equation
|
442
|
+
variables_in_eq = equation.get_all_variables()
|
443
|
+
|
444
|
+
# Create mapping from original symbols to namespaced symbols
|
445
|
+
symbol_mapping = {}
|
446
|
+
for var_symbol in variables_in_eq:
|
447
|
+
namespaced_symbol = f"{namespace}_{var_symbol}"
|
448
|
+
if namespaced_symbol in self.variables:
|
449
|
+
symbol_mapping[var_symbol] = namespaced_symbol
|
450
|
+
|
451
|
+
if not symbol_mapping:
|
452
|
+
return None
|
453
|
+
|
454
|
+
# Create new equation with namespaced references
|
455
|
+
# For LHS, we need a Variable object to call .equals()
|
456
|
+
# For RHS, we need proper expression structure
|
457
|
+
namespaced_lhs = self._namespace_expression_for_lhs(equation.lhs, symbol_mapping)
|
458
|
+
namespaced_rhs = self._namespace_expression(equation.rhs, symbol_mapping)
|
459
|
+
|
460
|
+
if namespaced_lhs and namespaced_rhs:
|
461
|
+
equals_method = getattr(namespaced_lhs, 'equals', None)
|
462
|
+
if equals_method:
|
463
|
+
return equals_method(namespaced_rhs)
|
464
|
+
|
465
|
+
return None
|
466
|
+
|
467
|
+
except Exception:
|
468
|
+
return None
|
469
|
+
|
470
|
+
def _namespace_expression(self, expr, symbol_mapping):
|
471
|
+
"""
|
472
|
+
Create a namespaced version of an expression by replacing variable references.
|
473
|
+
"""
|
474
|
+
|
475
|
+
# Handle variable references
|
476
|
+
if isinstance(expr, VariableReference):
|
477
|
+
return self._namespace_variable_reference(expr, symbol_mapping)
|
478
|
+
elif hasattr(expr, 'symbol') and expr.symbol in symbol_mapping:
|
479
|
+
return self._namespace_variable_object(expr, symbol_mapping)
|
480
|
+
|
481
|
+
# Handle operations
|
482
|
+
elif isinstance(expr, BinaryOperation):
|
483
|
+
return self._namespace_binary_operation(expr, symbol_mapping)
|
484
|
+
elif hasattr(expr, 'operand'):
|
485
|
+
return self._namespace_unary_operation(expr, symbol_mapping)
|
486
|
+
elif hasattr(expr, 'function_name'):
|
487
|
+
return self._namespace_binary_function(expr, symbol_mapping)
|
488
|
+
elif isinstance(expr, Constant):
|
489
|
+
return expr
|
490
|
+
else:
|
491
|
+
return expr
|
492
|
+
|
493
|
+
def _namespace_variable_reference(self, expr, symbol_mapping):
|
494
|
+
"""Namespace a VariableReference object."""
|
495
|
+
if expr.symbol in symbol_mapping:
|
496
|
+
namespaced_symbol = symbol_mapping[expr.symbol]
|
497
|
+
if namespaced_symbol in self.variables:
|
498
|
+
return VariableReference(self.variables[namespaced_symbol])
|
499
|
+
return expr
|
500
|
+
|
501
|
+
def _namespace_variable_object(self, expr, symbol_mapping):
|
502
|
+
"""Namespace a Variable object."""
|
503
|
+
namespaced_symbol = symbol_mapping[expr.symbol]
|
504
|
+
if namespaced_symbol in self.variables:
|
505
|
+
# Return VariableReference for use in expressions, not the Variable itself
|
506
|
+
return VariableReference(self.variables[namespaced_symbol])
|
507
|
+
return expr
|
508
|
+
|
509
|
+
def _namespace_binary_operation(self, expr, symbol_mapping):
|
510
|
+
"""Namespace a BinaryOperation."""
|
511
|
+
namespaced_left = self._namespace_expression(expr.left, symbol_mapping)
|
512
|
+
namespaced_right = self._namespace_expression(expr.right, symbol_mapping)
|
513
|
+
return BinaryOperation(expr.operator, namespaced_left, namespaced_right)
|
514
|
+
|
515
|
+
def _namespace_unary_operation(self, expr, symbol_mapping):
|
516
|
+
"""Namespace a UnaryFunction."""
|
517
|
+
namespaced_operand = self._namespace_expression(expr.operand, symbol_mapping)
|
518
|
+
return type(expr)(expr.operator, namespaced_operand)
|
519
|
+
|
520
|
+
def _namespace_binary_function(self, expr, symbol_mapping):
|
521
|
+
"""Namespace a BinaryFunction."""
|
522
|
+
namespaced_left = self._namespace_expression(expr.left, symbol_mapping)
|
523
|
+
namespaced_right = self._namespace_expression(expr.right, symbol_mapping)
|
524
|
+
return type(expr)(expr.function_name, namespaced_left, namespaced_right)
|
525
|
+
|
526
|
+
def _namespace_expression_for_lhs(self, expr, symbol_mapping):
|
527
|
+
"""
|
528
|
+
Create a namespaced version of an expression for LHS, returning Variable objects.
|
529
|
+
"""
|
530
|
+
|
531
|
+
if isinstance(expr, VariableReference):
|
532
|
+
symbol = getattr(expr, 'symbol', None)
|
533
|
+
if symbol and symbol in symbol_mapping:
|
534
|
+
namespaced_symbol = symbol_mapping[symbol]
|
535
|
+
if namespaced_symbol in self.variables:
|
536
|
+
return self.variables[namespaced_symbol]
|
537
|
+
# If we can't find a mapping, return None since VariableReference doesn't have .equals()
|
538
|
+
return None
|
539
|
+
elif hasattr(expr, 'symbol') and expr.symbol in symbol_mapping:
|
540
|
+
# This is a Variable object
|
541
|
+
namespaced_symbol = symbol_mapping[expr.symbol]
|
542
|
+
if namespaced_symbol in self.variables:
|
543
|
+
return self.variables[namespaced_symbol]
|
544
|
+
return expr
|
545
|
+
else:
|
546
|
+
return expr
|
547
|
+
|
548
|
+
def _clone_variable(self, variable: Variable) -> Variable:
|
549
|
+
"""Create a copy of a variable to avoid shared state without corrupting global units."""
|
550
|
+
# Create a new Variable with the same properties but avoid deepcopy
|
551
|
+
# which can corrupt global unit objects
|
552
|
+
cloned = Variable(
|
553
|
+
name=variable.name,
|
554
|
+
expected_dimension=variable.expected_dimension,
|
555
|
+
is_known=variable.is_known
|
556
|
+
)
|
557
|
+
# Set attributes that are not part of constructor
|
558
|
+
cloned.symbol = variable.symbol
|
559
|
+
cloned.quantity = variable.quantity # Keep reference to same quantity - units must not be copied
|
560
|
+
|
561
|
+
# Ensure the cloned variable has fresh validation checks
|
562
|
+
if hasattr(variable, 'validation_checks'):
|
563
|
+
try:
|
564
|
+
setattr(cloned, 'validation_checks', [])
|
565
|
+
except (AttributeError, TypeError):
|
566
|
+
# validation_checks might be read-only or not settable
|
567
|
+
pass
|
568
|
+
return cloned
|
569
|
+
|
570
|
+
def _recreate_validation_checks(self):
|
571
|
+
"""Collect and integrate validation checks from class-level Check objects."""
|
572
|
+
# Clear existing checks
|
573
|
+
self.validation_checks = []
|
574
|
+
|
575
|
+
# Collect Check objects from metaclass
|
576
|
+
class_checks = getattr(self.__class__, '_class_checks', {})
|
577
|
+
|
578
|
+
for check in class_checks.values():
|
579
|
+
# Create a validation function from the Check object
|
580
|
+
def make_check_function(check_obj):
|
581
|
+
def check_function(problem_instance):
|
582
|
+
return check_obj.evaluate(problem_instance.variables)
|
583
|
+
return check_function
|
584
|
+
|
585
|
+
self.validation_checks.append(make_check_function(check))
|
586
|
+
|
587
|
+
def _create_composite_equations(self):
|
588
|
+
"""
|
589
|
+
Create composite equations for common patterns in sub-problems.
|
590
|
+
This handles equations like P = min(header.P, branch.P) automatically.
|
591
|
+
"""
|
592
|
+
if not self.sub_problems:
|
593
|
+
return
|
594
|
+
|
595
|
+
# Common composite patterns to auto-generate
|
596
|
+
for var_name in COMMON_COMPOSITE_VARIABLES:
|
597
|
+
# Check if this variable exists in multiple sub-problems
|
598
|
+
sub_problem_vars = []
|
599
|
+
for namespace in self.sub_problems:
|
600
|
+
namespaced_symbol = f"{namespace}_{var_name}"
|
601
|
+
if namespaced_symbol in self.variables:
|
602
|
+
sub_problem_vars.append(self.variables[namespaced_symbol])
|
603
|
+
|
604
|
+
# If we have the variable in multiple sub-problems and no direct variable exists
|
605
|
+
if len(sub_problem_vars) >= 2 and var_name in self.variables:
|
606
|
+
# Check if a composite equation already exists
|
607
|
+
equation_attr_name = f"{var_name}_eqn"
|
608
|
+
if hasattr(self.__class__, equation_attr_name):
|
609
|
+
# Skip auto-creation since explicit equation exists
|
610
|
+
continue
|
611
|
+
|
612
|
+
# Auto-create composite equation
|
613
|
+
try:
|
614
|
+
from qnty.expressions import min_expr
|
615
|
+
composite_var = self.variables[var_name]
|
616
|
+
if not composite_var.is_known: # Only for unknown variables
|
617
|
+
composite_expr = min_expr(*sub_problem_vars)
|
618
|
+
equals_method = getattr(composite_var, 'equals', None)
|
619
|
+
if equals_method:
|
620
|
+
composite_eq = equals_method(composite_expr)
|
621
|
+
self.add_equation(composite_eq)
|
622
|
+
setattr(self, f"{var_name}_eqn", composite_eq)
|
623
|
+
except Exception as e:
|
624
|
+
self.logger.debug(f"Failed to create composite equation for {var_name}: {e}")
|
625
|
+
|
626
|
+
def _get_equation_lhs_symbol(self, equation: Equation) -> str | None:
|
627
|
+
"""Safely extract the symbol from equation's left-hand side."""
|
628
|
+
return getattr(equation.lhs, 'symbol', None)
|
629
|
+
|
630
|
+
def _is_conditional_equation(self, equation: Equation) -> bool:
|
631
|
+
"""Check if an equation is a conditional equation."""
|
632
|
+
return 'cond(' in str(equation)
|
633
|
+
|
634
|
+
def _equation_has_missing_variables(self, equation: Equation) -> bool:
|
635
|
+
"""Check if an equation references variables that don't exist in this problem."""
|
636
|
+
try:
|
637
|
+
all_vars = equation.get_all_variables()
|
638
|
+
missing_vars = [var for var in all_vars if var not in self.variables]
|
639
|
+
return len(missing_vars) > 0
|
640
|
+
except Exception:
|
641
|
+
return False
|
642
|
+
|
643
|
+
def _has_invalid_self_references(self, equation: Equation) -> bool:
|
644
|
+
"""
|
645
|
+
Check if an equation has invalid self-references.
|
646
|
+
This catches malformed equations like 'c = max(c, c)' from class definition.
|
647
|
+
"""
|
648
|
+
try:
|
649
|
+
# Get LHS variable - check if it has symbol attribute
|
650
|
+
lhs_symbol = self._get_equation_lhs_symbol(equation)
|
651
|
+
if lhs_symbol is None:
|
652
|
+
return False
|
653
|
+
|
654
|
+
# Get all variables referenced in RHS
|
655
|
+
rhs_vars = equation.rhs.get_variables() if hasattr(equation.rhs, 'get_variables') else set()
|
656
|
+
|
657
|
+
# Check if the LHS variable appears multiple times in RHS (indicating self-reference)
|
658
|
+
# This is a heuristic - a proper implementation would parse the expression tree
|
659
|
+
equation_str = str(equation)
|
660
|
+
if lhs_symbol in rhs_vars:
|
661
|
+
# For conditional equations, self-references are often valid (as fallback values)
|
662
|
+
if 'cond(' in equation_str:
|
663
|
+
return False # Allow self-references in conditional equations
|
664
|
+
|
665
|
+
# Count occurrences of the variable in the equation string
|
666
|
+
count = equation_str.count(lhs_symbol)
|
667
|
+
if count > 2: # LHS + multiple RHS occurrences
|
668
|
+
return True
|
669
|
+
|
670
|
+
return False
|
671
|
+
|
672
|
+
except Exception:
|
673
|
+
return False
|
674
|
+
|
675
|
+
|
676
|
+
def _is_conditional_equation_from_subproblem(self, equation: Equation, _equation_name: str) -> bool:
|
677
|
+
"""
|
678
|
+
Check if a conditional equation comes from a sub-problem and should be skipped in composed systems.
|
679
|
+
|
680
|
+
In composed systems, if a sub-problem's conditional variable is already set to a known value,
|
681
|
+
we don't want to include the conditional equation that would override that known value.
|
682
|
+
"""
|
683
|
+
try:
|
684
|
+
# Check if this is a conditional equation with a known LHS variable
|
685
|
+
lhs_symbol = self._get_equation_lhs_symbol(equation)
|
686
|
+
if lhs_symbol is not None:
|
687
|
+
|
688
|
+
# Check if the LHS variable already exists and is known
|
689
|
+
if lhs_symbol in self.variables:
|
690
|
+
var = self.variables[lhs_symbol]
|
691
|
+
# If the variable is already known (set explicitly in composition),
|
692
|
+
# and this conditional equation references missing variables, skip it
|
693
|
+
missing_vars = equation.get_all_variables() - set(self.variables.keys())
|
694
|
+
if missing_vars and var.is_known:
|
695
|
+
# This is a sub-problem's conditional equation for a variable that's already known
|
696
|
+
# No point including it since the value is already determined
|
697
|
+
return True
|
698
|
+
|
699
|
+
return False
|
700
|
+
|
701
|
+
except Exception:
|
702
|
+
return False
|
703
|
+
|
704
|
+
def _should_skip_subproblem_equation(self, equation: Equation, namespace: str) -> bool:
|
705
|
+
"""
|
706
|
+
Check if an equation from a sub-problem should be skipped during integration.
|
707
|
+
|
708
|
+
Skip conditional equations for variables that are set to known values in the composed problem.
|
709
|
+
"""
|
710
|
+
try:
|
711
|
+
# Check if this is a conditional equation
|
712
|
+
if not self._is_conditional_equation(equation):
|
713
|
+
return False
|
714
|
+
|
715
|
+
# Check if the LHS variable would be set to a known value in composition
|
716
|
+
original_symbol = self._get_equation_lhs_symbol(equation)
|
717
|
+
if original_symbol is not None:
|
718
|
+
namespaced_symbol = f"{namespace}_{original_symbol}"
|
719
|
+
|
720
|
+
# Check if this namespaced variable exists and is already known
|
721
|
+
if namespaced_symbol in self.variables:
|
722
|
+
var = self.variables[namespaced_symbol]
|
723
|
+
if var.is_known:
|
724
|
+
# The variable is already set to a known value in composition,
|
725
|
+
# so skip the conditional equation that would override it
|
726
|
+
self.logger.debug(f"Skipping conditional equation for {namespaced_symbol} (already known: {var.quantity})")
|
727
|
+
return True
|
728
|
+
|
729
|
+
return False
|
730
|
+
|
731
|
+
except Exception:
|
732
|
+
return False
|
733
|
+
|
734
|
+
# =============================================================================
|
735
|
+
# VARIABLE MANAGEMENT METHODS
|
736
|
+
# =============================================================================
|
737
|
+
|
738
|
+
def add_variable(self, variable: Variable) -> None:
|
739
|
+
"""
|
740
|
+
Add a variable to the problem.
|
741
|
+
|
742
|
+
The variable will be available for use in equations and can be accessed
|
743
|
+
via both dictionary notation (problem['symbol']) and attribute notation
|
744
|
+
(problem.symbol).
|
745
|
+
|
746
|
+
Args:
|
747
|
+
variable: Variable object to add to the problem
|
748
|
+
|
749
|
+
Note:
|
750
|
+
If a variable with the same symbol already exists, it will be replaced
|
751
|
+
and a warning will be logged.
|
752
|
+
"""
|
753
|
+
if variable.symbol in self.variables:
|
754
|
+
self.logger.warning(f"Variable {variable.symbol} already exists. Replacing.")
|
755
|
+
|
756
|
+
if variable.symbol is not None:
|
757
|
+
self.variables[variable.symbol] = variable
|
758
|
+
# Set parent problem reference for dependency invalidation
|
759
|
+
try:
|
760
|
+
setattr(variable, '_parent_problem', self)
|
761
|
+
except (AttributeError, TypeError):
|
762
|
+
# _parent_problem might not be settable
|
763
|
+
pass
|
764
|
+
# Also set as instance attribute for dot notation access
|
765
|
+
if variable.symbol is not None:
|
766
|
+
setattr(self, variable.symbol, variable)
|
767
|
+
self.is_solved = False
|
768
|
+
self._invalidate_caches()
|
769
|
+
|
770
|
+
def add_variables(self, *variables: Variable) -> None:
|
771
|
+
"""Add multiple variables to the problem."""
|
772
|
+
for var in variables:
|
773
|
+
self.add_variable(var)
|
774
|
+
|
775
|
+
def get_variable(self, symbol: str) -> Variable:
|
776
|
+
"""Get a variable by its symbol."""
|
777
|
+
if symbol not in self.variables:
|
778
|
+
raise VariableNotFoundError(f"Variable '{symbol}' not found in problem '{self.name}'.")
|
779
|
+
return self.variables[symbol]
|
780
|
+
|
781
|
+
def get_known_variables(self) -> dict[str, Variable]:
|
782
|
+
"""Get all known variables."""
|
783
|
+
if self._cache_dirty or self._known_variables_cache is None:
|
784
|
+
self._update_variable_caches()
|
785
|
+
return self._known_variables_cache.copy() if self._known_variables_cache else {}
|
786
|
+
|
787
|
+
def get_unknown_variables(self) -> dict[str, Variable]:
|
788
|
+
"""Get all unknown variables."""
|
789
|
+
if self._cache_dirty or self._unknown_variables_cache is None:
|
790
|
+
self._update_variable_caches()
|
791
|
+
return self._unknown_variables_cache.copy() if self._unknown_variables_cache else {}
|
792
|
+
|
793
|
+
def get_known_symbols(self) -> set[str]:
|
794
|
+
"""Get symbols of all known variables."""
|
795
|
+
return {symbol for symbol, var in self.variables.items() if var.is_known}
|
796
|
+
|
797
|
+
def get_unknown_symbols(self) -> set[str]:
|
798
|
+
"""Get symbols of all unknown variables."""
|
799
|
+
return {symbol for symbol, var in self.variables.items() if not var.is_known}
|
800
|
+
|
801
|
+
def get_known_variable_symbols(self) -> set[str]:
|
802
|
+
"""Alias for get_known_symbols for compatibility."""
|
803
|
+
return self.get_known_symbols()
|
804
|
+
|
805
|
+
def get_unknown_variable_symbols(self) -> set[str]:
|
806
|
+
"""Alias for get_unknown_symbols for compatibility."""
|
807
|
+
return self.get_unknown_symbols()
|
808
|
+
|
809
|
+
# Properties for compatibility
|
810
|
+
@property
|
811
|
+
def known_variables(self) -> dict[str, Variable]:
|
812
|
+
"""Get all variables marked as known."""
|
813
|
+
return self.get_known_variables()
|
814
|
+
|
815
|
+
@property
|
816
|
+
def unknown_variables(self) -> dict[str, Variable]:
|
817
|
+
"""Get all variables marked as unknown."""
|
818
|
+
return self.get_unknown_variables()
|
819
|
+
|
820
|
+
def mark_unknown(self, *symbols: str) -> Problem:
|
821
|
+
"""Mark variables as unknown (to be solved for)."""
|
822
|
+
for symbol in symbols:
|
823
|
+
if symbol in self.variables:
|
824
|
+
self.variables[symbol].mark_unknown()
|
825
|
+
else:
|
826
|
+
raise VariableNotFoundError(f"Variable '{symbol}' not found in problem '{self.name}'")
|
827
|
+
self.is_solved = False
|
828
|
+
self._invalidate_caches()
|
829
|
+
return self
|
830
|
+
|
831
|
+
def mark_known(self, **symbol_values: Qty) -> Problem:
|
832
|
+
"""Mark variables as known and set their values."""
|
833
|
+
for symbol, quantity in symbol_values.items():
|
834
|
+
if symbol in self.variables:
|
835
|
+
self.variables[symbol].mark_known(quantity)
|
836
|
+
else:
|
837
|
+
raise VariableNotFoundError(f"Variable '{symbol}' not found in problem '{self.name}'")
|
838
|
+
self.is_solved = False
|
839
|
+
self._invalidate_caches()
|
840
|
+
return self
|
841
|
+
|
842
|
+
def invalidate_dependents(self, changed_variable_symbol: str) -> None:
|
843
|
+
"""
|
844
|
+
Mark all variables that depend on the changed variable as unknown.
|
845
|
+
This ensures they get recalculated when the problem is re-solved.
|
846
|
+
|
847
|
+
Args:
|
848
|
+
changed_variable_symbol: Symbol of the variable whose value changed
|
849
|
+
"""
|
850
|
+
if not hasattr(self, 'dependency_graph') or not self.dependency_graph:
|
851
|
+
# If dependency graph hasn't been built yet, we can't invalidate
|
852
|
+
return
|
853
|
+
|
854
|
+
# Get all variables that depend on the changed variable
|
855
|
+
dependent_vars = self.dependency_graph.graph.get(changed_variable_symbol, [])
|
856
|
+
|
857
|
+
# Mark each dependent variable as unknown
|
858
|
+
for dependent_symbol in dependent_vars:
|
859
|
+
if dependent_symbol in self.variables:
|
860
|
+
var = self.variables[dependent_symbol]
|
861
|
+
# Only mark as unknown if it was previously solved (known)
|
862
|
+
if var.is_known:
|
863
|
+
var.mark_unknown()
|
864
|
+
# Recursively invalidate variables that depend on this one
|
865
|
+
self.invalidate_dependents(dependent_symbol)
|
866
|
+
|
867
|
+
# Mark problem as needing re-solving
|
868
|
+
self.is_solved = False
|
869
|
+
self._invalidate_caches()
|
870
|
+
|
871
|
+
# =============================================================================
|
872
|
+
# EQUATION MANAGEMENT METHODS
|
873
|
+
# =============================================================================
|
874
|
+
|
875
|
+
def add_equation(self, equation: Equation) -> None:
|
876
|
+
"""
|
877
|
+
Add an equation to the problem.
|
878
|
+
|
879
|
+
The equation will be validated to ensure all referenced variables exist.
|
880
|
+
Missing variables that look like simple identifiers will be auto-created
|
881
|
+
as unknown placeholders.
|
882
|
+
|
883
|
+
Args:
|
884
|
+
equation: Equation object to add to the problem
|
885
|
+
|
886
|
+
Raises:
|
887
|
+
EquationValidationError: If the equation is invalid or cannot be processed
|
888
|
+
|
889
|
+
Note:
|
890
|
+
Adding an equation resets the problem to unsolved state.
|
891
|
+
"""
|
892
|
+
if equation is None:
|
893
|
+
raise EquationValidationError("Cannot add None equation to problem")
|
894
|
+
|
895
|
+
# Fix VariableReferences in equation to point to correct Variables
|
896
|
+
equation = self._fix_variable_references(equation)
|
897
|
+
|
898
|
+
# Validate that all variables in the equation exist
|
899
|
+
try:
|
900
|
+
equation_vars = equation.get_all_variables()
|
901
|
+
except Exception as e:
|
902
|
+
raise EquationValidationError(f"Failed to extract variables from equation: {e}") from e
|
903
|
+
|
904
|
+
missing_vars = [var for var in equation_vars if var not in self.variables]
|
905
|
+
|
906
|
+
if missing_vars:
|
907
|
+
self._handle_missing_variables(missing_vars)
|
908
|
+
|
909
|
+
# Check again for remaining missing variables
|
910
|
+
equation_vars = equation.get_all_variables()
|
911
|
+
remaining_missing = [var for var in equation_vars if var not in self.variables]
|
912
|
+
if remaining_missing:
|
913
|
+
self.logger.warning(f"Equation references missing variables: {remaining_missing}")
|
914
|
+
|
915
|
+
self.equations.append(equation)
|
916
|
+
self.equation_system.add_equation(equation)
|
917
|
+
self.is_solved = False
|
918
|
+
|
919
|
+
def _handle_missing_variables(self, missing_vars: list[str]) -> None:
|
920
|
+
"""Handle missing variables by creating placeholders for simple symbols."""
|
921
|
+
for missing_var in missing_vars:
|
922
|
+
if self._is_simple_variable_symbol(missing_var):
|
923
|
+
self._create_placeholder_variable(missing_var)
|
924
|
+
|
925
|
+
def _is_simple_variable_symbol(self, symbol: str) -> bool:
|
926
|
+
"""Check if a symbol looks like a simple variable identifier."""
|
927
|
+
return (symbol.isidentifier() and
|
928
|
+
not any(char in symbol for char in ['(', ')', '+', '-', '*', '/', ' ']))
|
929
|
+
|
930
|
+
def _create_placeholder_variable(self, symbol: str) -> None:
|
931
|
+
"""Create a placeholder variable for a missing symbol."""
|
932
|
+
|
933
|
+
placeholder_var = Variable(
|
934
|
+
name=f"Auto-created: {symbol}",
|
935
|
+
expected_dimension=DimensionlessUnits.dimensionless.dimension,
|
936
|
+
is_known=False
|
937
|
+
)
|
938
|
+
placeholder_var.symbol = symbol
|
939
|
+
placeholder_var.quantity = Qty(0.0, DimensionlessUnits.dimensionless)
|
940
|
+
self.add_variable(placeholder_var)
|
941
|
+
self.logger.debug(f"Auto-created placeholder variable: {symbol}")
|
942
|
+
|
943
|
+
def add_equations(self, *equations: Equation) -> Problem:
|
944
|
+
"""Add multiple equations to the problem."""
|
945
|
+
for eq in equations:
|
946
|
+
self.add_equation(eq)
|
947
|
+
return self
|
948
|
+
|
949
|
+
# =============================================================================
|
950
|
+
# PROBLEM SOLVING METHODS
|
951
|
+
# =============================================================================
|
952
|
+
|
953
|
+
def solve(self, max_iterations: int = MAX_ITERATIONS_DEFAULT, tolerance: float = TOLERANCE_DEFAULT) -> dict[str, Variable]:
|
954
|
+
"""
|
955
|
+
Solve the engineering problem by finding values for all unknown variables.
|
956
|
+
|
957
|
+
This method orchestrates the complete solving process:
|
958
|
+
1. Builds dependency graph from equations
|
959
|
+
2. Determines optimal solving order using topological sorting
|
960
|
+
3. Solves equations iteratively using symbolic/numerical methods
|
961
|
+
4. Verifies solution against all equations
|
962
|
+
5. Updates variable states and synchronizes instance attributes
|
963
|
+
|
964
|
+
Args:
|
965
|
+
max_iterations: Maximum number of solving iterations (default: 100)
|
966
|
+
tolerance: Numerical tolerance for convergence (default: 1e-10)
|
967
|
+
|
968
|
+
Returns:
|
969
|
+
dict mapping variable symbols to solved Variable objects
|
970
|
+
|
971
|
+
Raises:
|
972
|
+
SolverError: If solving fails or times out
|
973
|
+
|
974
|
+
Example:
|
975
|
+
>>> problem = MyEngineeringProblem()
|
976
|
+
>>> solution = problem.solve()
|
977
|
+
>>> print(f"Force = {solution['F'].quantity}")
|
978
|
+
"""
|
979
|
+
self.logger.info(f"Solving problem: {self.name}")
|
980
|
+
|
981
|
+
try:
|
982
|
+
# Clear previous solution
|
983
|
+
self.solution = {}
|
984
|
+
self.is_solved = False
|
985
|
+
self.solving_history = []
|
986
|
+
|
987
|
+
# Build dependency graph
|
988
|
+
self._build_dependency_graph()
|
989
|
+
|
990
|
+
# Use solver manager to solve the system
|
991
|
+
solve_result = self.solver_manager.solve(
|
992
|
+
self.equations,
|
993
|
+
self.variables,
|
994
|
+
self.dependency_graph,
|
995
|
+
max_iterations,
|
996
|
+
tolerance
|
997
|
+
)
|
998
|
+
|
999
|
+
if solve_result.success:
|
1000
|
+
# Update variables with the result
|
1001
|
+
self.variables = solve_result.variables
|
1002
|
+
self.solving_history.extend(solve_result.steps)
|
1003
|
+
|
1004
|
+
# Sync solved values back to instance attributes
|
1005
|
+
self._sync_variables_to_instance_attributes()
|
1006
|
+
|
1007
|
+
# Verify solution
|
1008
|
+
self.solution = self.variables
|
1009
|
+
verification_passed = self.verify_solution()
|
1010
|
+
|
1011
|
+
# Mark as solved based on solver result and verification
|
1012
|
+
if verification_passed:
|
1013
|
+
self.is_solved = True
|
1014
|
+
self.logger.info("Solution verified successfully")
|
1015
|
+
return self.solution
|
1016
|
+
else:
|
1017
|
+
self.logger.warning("Solution verification failed")
|
1018
|
+
return self.solution
|
1019
|
+
else:
|
1020
|
+
raise SolverError(f"Solving failed: {solve_result.message}")
|
1021
|
+
|
1022
|
+
except SolverError:
|
1023
|
+
raise
|
1024
|
+
except Exception as e:
|
1025
|
+
self.logger.error(f"Solving failed: {e}")
|
1026
|
+
raise SolverError(f"Unexpected error during solving: {e}") from e
|
1027
|
+
|
1028
|
+
def _build_dependency_graph(self):
|
1029
|
+
"""Build the dependency graph for solving order determination."""
|
1030
|
+
# Reset the dependency graph
|
1031
|
+
self.dependency_graph = Order()
|
1032
|
+
|
1033
|
+
# Get known variables
|
1034
|
+
known_vars = self.get_known_symbols()
|
1035
|
+
|
1036
|
+
# Add dependencies from equations
|
1037
|
+
for equation in self.equations:
|
1038
|
+
self.dependency_graph.add_equation(equation, known_vars)
|
1039
|
+
|
1040
|
+
def _sync_variables_to_instance_attributes(self):
|
1041
|
+
"""
|
1042
|
+
Sync variable objects to instance attributes after solving.
|
1043
|
+
This ensures that self.P refers to the same Variable object that's in self.variables.
|
1044
|
+
Variables maintain their original dimensional types (e.g., AreaVariable, PressureVariable).
|
1045
|
+
"""
|
1046
|
+
for var_symbol, var in self.variables.items():
|
1047
|
+
# Update instance attribute if it exists
|
1048
|
+
if hasattr(self, var_symbol):
|
1049
|
+
# Variables preserve their dimensional types during solving
|
1050
|
+
setattr(self, var_symbol, var)
|
1051
|
+
|
1052
|
+
# Also update sub-problem namespace objects
|
1053
|
+
for namespace, sub_problem in self.sub_problems.items():
|
1054
|
+
if hasattr(self, namespace):
|
1055
|
+
namespace_obj = getattr(self, namespace)
|
1056
|
+
for var_symbol in sub_problem.variables:
|
1057
|
+
namespaced_symbol = f"{namespace}_{var_symbol}"
|
1058
|
+
if namespaced_symbol in self.variables and hasattr(namespace_obj, var_symbol):
|
1059
|
+
setattr(namespace_obj, var_symbol, self.variables[namespaced_symbol])
|
1060
|
+
|
1061
|
+
def verify_solution(self, tolerance: float = 1e-10) -> bool:
|
1062
|
+
"""Verify that all equations are satisfied."""
|
1063
|
+
if not self.equations:
|
1064
|
+
return True
|
1065
|
+
|
1066
|
+
try:
|
1067
|
+
for equation in self.equations:
|
1068
|
+
if not equation.check_residual(self.variables, tolerance):
|
1069
|
+
self.logger.debug(f"Equation verification failed: {equation}")
|
1070
|
+
return False
|
1071
|
+
return True
|
1072
|
+
except Exception as e:
|
1073
|
+
self.logger.debug(f"Solution verification error: {e}")
|
1074
|
+
return False
|
1075
|
+
|
1076
|
+
# =============================================================================
|
1077
|
+
# SYSTEM ANALYSIS METHODS
|
1078
|
+
# =============================================================================
|
1079
|
+
|
1080
|
+
def analyze_system(self) -> dict[str, Any]:
|
1081
|
+
"""Analyze the equation system for solvability, cycles, etc."""
|
1082
|
+
try:
|
1083
|
+
self._build_dependency_graph()
|
1084
|
+
known_vars = self.get_known_symbols()
|
1085
|
+
analysis = self.dependency_graph.analyze_system(known_vars)
|
1086
|
+
|
1087
|
+
# Add some additional info
|
1088
|
+
analysis['total_equations'] = len(self.equations)
|
1089
|
+
analysis['is_determined'] = len(self.get_unknown_variables()) <= len(self.equations)
|
1090
|
+
|
1091
|
+
return analysis
|
1092
|
+
except Exception as e:
|
1093
|
+
self.logger.debug(f"Dependency analysis failed: {e}")
|
1094
|
+
# Return basic analysis on failure
|
1095
|
+
return {
|
1096
|
+
'total_variables': len(self.variables),
|
1097
|
+
'known_variables': len(self.get_known_variables()),
|
1098
|
+
'unknown_variables': len(self.get_unknown_variables()),
|
1099
|
+
'total_equations': len(self.equations),
|
1100
|
+
'is_determined': len(self.get_unknown_variables()) <= len(self.equations),
|
1101
|
+
'has_cycles': False,
|
1102
|
+
'solving_order': [],
|
1103
|
+
'can_solve_completely': False,
|
1104
|
+
'unsolvable_variables': []
|
1105
|
+
}
|
1106
|
+
|
1107
|
+
def reset_solution(self):
|
1108
|
+
"""Reset the problem to unsolved state."""
|
1109
|
+
self.is_solved = False
|
1110
|
+
self.solution = {}
|
1111
|
+
self.solving_history = []
|
1112
|
+
|
1113
|
+
# Reset unknown variables to unknown state
|
1114
|
+
for var in self.variables.values():
|
1115
|
+
if not var.is_known:
|
1116
|
+
var.is_known = False
|
1117
|
+
|
1118
|
+
def _invalidate_caches(self) -> None:
|
1119
|
+
"""Invalidate performance caches when variables change."""
|
1120
|
+
self._cache_dirty = True
|
1121
|
+
|
1122
|
+
def _update_variable_caches(self) -> None:
|
1123
|
+
"""Update the variable caches for performance."""
|
1124
|
+
if not self._cache_dirty:
|
1125
|
+
return
|
1126
|
+
|
1127
|
+
self._known_variables_cache = {symbol: var for symbol, var in self.variables.items() if var.is_known}
|
1128
|
+
self._unknown_variables_cache = {symbol: var for symbol, var in self.variables.items() if not var.is_known}
|
1129
|
+
self._cache_dirty = False
|
1130
|
+
|
1131
|
+
# =============================================================================
|
1132
|
+
# VALIDATION AND WARNING SYSTEM
|
1133
|
+
# =============================================================================
|
1134
|
+
|
1135
|
+
def add_validation_check(self, check_function: Callable) -> None:
|
1136
|
+
"""Add a validation check function."""
|
1137
|
+
self.validation_checks.append(check_function)
|
1138
|
+
|
1139
|
+
def validate(self) -> list[dict[str, Any]]:
|
1140
|
+
"""Run all validation checks and return any warnings."""
|
1141
|
+
validation_warnings = []
|
1142
|
+
|
1143
|
+
for check in self.validation_checks:
|
1144
|
+
try:
|
1145
|
+
result = check(self)
|
1146
|
+
if result:
|
1147
|
+
validation_warnings.append(result)
|
1148
|
+
except Exception as e:
|
1149
|
+
self.logger.debug(f"Validation check failed: {e}")
|
1150
|
+
|
1151
|
+
return validation_warnings
|
1152
|
+
|
1153
|
+
def get_warnings(self) -> list[dict[str, Any]]:
|
1154
|
+
"""Get all warnings from the problem."""
|
1155
|
+
warnings = self.warnings.copy()
|
1156
|
+
warnings.extend(self.validate())
|
1157
|
+
return warnings
|
1158
|
+
|
1159
|
+
# =============================================================================
|
1160
|
+
# UTILITY METHODS
|
1161
|
+
# =============================================================================
|
1162
|
+
|
1163
|
+
def copy(self) -> Problem:
|
1164
|
+
"""Create a copy of this problem."""
|
1165
|
+
from copy import deepcopy
|
1166
|
+
return deepcopy(self)
|
1167
|
+
|
1168
|
+
def __str__(self) -> str:
|
1169
|
+
"""String representation of the problem."""
|
1170
|
+
status = "SOLVED" if self.is_solved else "UNSOLVED"
|
1171
|
+
return f"EngineeringProblem('{self.name}', vars={len(self.variables)}, eqs={len(self.equations)}, {status})"
|
1172
|
+
|
1173
|
+
def __repr__(self) -> str:
|
1174
|
+
"""Detailed representation of the problem."""
|
1175
|
+
return self.__str__()
|
1176
|
+
|
1177
|
+
# =============================================================================
|
1178
|
+
# SPECIAL METHODS FOR ATTRIBUTE ACCESS
|
1179
|
+
# =============================================================================
|
1180
|
+
|
1181
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
1182
|
+
"""Custom attribute setting to maintain variable synchronization."""
|
1183
|
+
# During initialization, use normal attribute setting
|
1184
|
+
if not hasattr(self, 'variables') or name.startswith('_'):
|
1185
|
+
super().__setattr__(name, value)
|
1186
|
+
return
|
1187
|
+
|
1188
|
+
# If setting a variable that exists in our variables dict, update both
|
1189
|
+
if isinstance(value, Variable) and name in self.variables:
|
1190
|
+
self.variables[name] = value
|
1191
|
+
|
1192
|
+
super().__setattr__(name, value)
|
1193
|
+
|
1194
|
+
def __getitem__(self, key: str) -> Variable:
|
1195
|
+
"""Allow dict-like access to variables."""
|
1196
|
+
return self.get_variable(key)
|
1197
|
+
|
1198
|
+
def __setitem__(self, key: str, value: Variable) -> None:
|
1199
|
+
"""Allow dict-like assignment of variables."""
|
1200
|
+
# Update the symbol to match the key if they differ
|
1201
|
+
if value.symbol != key:
|
1202
|
+
value.symbol = key
|
1203
|
+
self.add_variable(value)
|
1204
|
+
|
1205
|
+
def _update_equation_variable_references(self, equation: Equation) -> Equation:
|
1206
|
+
"""Update VariableReference objects in equation to use symbols instead of names."""
|
1207
|
+
from qnty.expressions import VariableReference
|
1208
|
+
|
1209
|
+
# Update LHS if it's a VariableReference
|
1210
|
+
updated_lhs = equation.lhs
|
1211
|
+
if isinstance(equation.lhs, VariableReference):
|
1212
|
+
# Find the variable by name and update to use symbol
|
1213
|
+
var_name = equation.lhs.variable.name
|
1214
|
+
matching_var = None
|
1215
|
+
for var in self.variables.values():
|
1216
|
+
if var.name == var_name:
|
1217
|
+
matching_var = var
|
1218
|
+
break
|
1219
|
+
if matching_var and matching_var.symbol:
|
1220
|
+
updated_lhs = VariableReference(matching_var)
|
1221
|
+
|
1222
|
+
# Update RHS by recursively updating expressions
|
1223
|
+
updated_rhs = self._update_expression_variable_references(equation.rhs)
|
1224
|
+
|
1225
|
+
# Create new equation with updated references
|
1226
|
+
return Equation(equation.name, updated_lhs, updated_rhs)
|
1227
|
+
|
1228
|
+
def _update_expression_variable_references(self, expr):
|
1229
|
+
"""Recursively update VariableReference objects in expression tree."""
|
1230
|
+
from qnty.expressions import VariableReference, BinaryOperation, Constant
|
1231
|
+
|
1232
|
+
if isinstance(expr, VariableReference):
|
1233
|
+
# Find the variable by name and update to use symbol
|
1234
|
+
var_name = expr.variable.name
|
1235
|
+
matching_var = None
|
1236
|
+
for var in self.variables.values():
|
1237
|
+
if var.name == var_name:
|
1238
|
+
matching_var = var
|
1239
|
+
break
|
1240
|
+
if matching_var and matching_var.symbol:
|
1241
|
+
return VariableReference(matching_var)
|
1242
|
+
return expr
|
1243
|
+
elif isinstance(expr, BinaryOperation):
|
1244
|
+
updated_left = self._update_expression_variable_references(expr.left)
|
1245
|
+
updated_right = self._update_expression_variable_references(expr.right)
|
1246
|
+
return BinaryOperation(expr.operator, updated_left, updated_right)
|
1247
|
+
elif isinstance(expr, Constant):
|
1248
|
+
return expr
|
1249
|
+
else:
|
1250
|
+
# Return unknown expression types as-is
|
1251
|
+
return expr
|