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,695 @@
1
+ """
2
+ Main Problem class with consolidated functionality.
3
+
4
+ This module combines the core Problem functionality including:
5
+ - Problem base class with state management and initialization (formerly base.py)
6
+ - Variable lifecycle management (formerly variables.py)
7
+ - Equation processing pipeline (formerly equations.py)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+ from copy import deepcopy
14
+ from typing import Any
15
+
16
+ from qnty.expressions import BinaryOperation, Constant, VariableReference
17
+ from qnty.solving.order import Order
18
+ from qnty.solving.solvers import SolverManager
19
+ from qnty.utils.logging import get_logger
20
+
21
+ from ..constants import SOLVER_DEFAULT_MAX_ITERATIONS, SOLVER_DEFAULT_TOLERANCE
22
+ from ..equations import Equation, EquationSystem
23
+ from ..quantities import FieldQnty, Quantity
24
+ from ..units import DimensionlessUnits
25
+ from .solving import EquationReconstructor
26
+
27
+ # Constants for equation processing
28
+ MATHEMATICAL_OPERATORS = ["+", "-", "*", "/", " / ", " * ", " + ", " - "]
29
+ COMMON_COMPOSITE_VARIABLES = ["P", "c", "S", "E", "W", "Y"]
30
+ MAX_ITERATIONS_DEFAULT = SOLVER_DEFAULT_MAX_ITERATIONS
31
+ TOLERANCE_DEFAULT = SOLVER_DEFAULT_TOLERANCE
32
+
33
+
34
+ # Custom Exceptions
35
+ class VariableNotFoundError(KeyError):
36
+ """Raised when trying to access a variable that doesn't exist."""
37
+
38
+ pass
39
+
40
+
41
+ class EquationValidationError(ValueError):
42
+ """Raised when an equation fails validation."""
43
+
44
+ pass
45
+
46
+
47
+ class SolverError(RuntimeError):
48
+ """Raised when the solving process fails."""
49
+
50
+ pass
51
+
52
+
53
+ class ValidationMixin:
54
+ """Mixin class providing validation functionality."""
55
+
56
+ # These attributes will be provided by other mixins in the final Problem class
57
+ logger: Any
58
+ warnings: list[dict[str, Any]]
59
+ validation_checks: list[Callable]
60
+
61
+ def add_validation_check(self, check_function: Callable) -> None:
62
+ """Add a validation check function."""
63
+ self.validation_checks.append(check_function)
64
+
65
+ def validate(self) -> list[dict[str, Any]]:
66
+ """Run all validation checks and return any warnings."""
67
+ validation_warnings = []
68
+
69
+ for check in self.validation_checks:
70
+ try:
71
+ result = check(self)
72
+ if result:
73
+ validation_warnings.append(result)
74
+ except Exception as e:
75
+ self.logger.debug(f"Validation check failed: {e}")
76
+
77
+ return validation_warnings
78
+
79
+ def get_warnings(self) -> list[dict[str, Any]]:
80
+ """Get all warnings from the problem."""
81
+ warnings = self.warnings.copy()
82
+ warnings.extend(self.validate())
83
+ return warnings
84
+
85
+ def _recreate_validation_checks(self):
86
+ """Collect and integrate validation checks from class-level Check objects."""
87
+ # Clear existing checks
88
+ self.validation_checks = []
89
+
90
+ # Collect Check objects from metaclass
91
+ class_checks = getattr(self.__class__, "_class_checks", {})
92
+
93
+ for check in class_checks.values():
94
+ # Create a validation function from the Check object
95
+ def make_check_function(check_obj):
96
+ def check_function(problem_instance):
97
+ return check_obj.evaluate(problem_instance.variables)
98
+
99
+ return check_function
100
+
101
+ self.validation_checks.append(make_check_function(check))
102
+
103
+
104
+ class Problem(ValidationMixin):
105
+ """
106
+ Main container class for engineering problems.
107
+
108
+ This class coordinates all aspects of engineering problem definition, solving, and analysis.
109
+ It supports both programmatic problem construction and class-level inheritance patterns
110
+ for defining domain-specific engineering problems.
111
+
112
+ Key Features:
113
+ - Automatic dependency graph construction and topological solving order
114
+ - Dual solving approach: SymPy symbolic solving with numerical fallback
115
+ - Sub-problem composition with automatic variable namespacing
116
+ - Comprehensive validation and error handling
117
+ - Professional report generation capabilities
118
+
119
+ Usage Patterns:
120
+ 1. Inheritance Pattern (Recommended for domain problems):
121
+ class MyProblem(Problem):
122
+ x = Variable("x", Qty(5.0, length))
123
+ y = Variable("y", Qty(0.0, length), is_known=False)
124
+ eq = y.equals(x * 2)
125
+
126
+ 2. Programmatic Pattern (For dynamic problems):
127
+ problem = Problem("Dynamic Problem")
128
+ problem.add_variables(x, y)
129
+ problem.add_equation(y.equals(x * 2))
130
+
131
+ 3. Composition Pattern (For reusable sub-problems):
132
+ class ComposedProblem(Problem):
133
+ sub1 = create_sub_problem()
134
+ sub2 = create_sub_problem()
135
+ # Equations can reference sub1.variable, sub2.variable
136
+
137
+ Attributes:
138
+ name (str): Human-readable name for the problem
139
+ description (str): Detailed description of the problem
140
+ variables (dict[str, Variable]): All variables in the problem
141
+ equations (list[Equation]): All equations in the problem
142
+ is_solved (bool): Whether the problem has been successfully solved
143
+ solution (dict[str, Variable]): Solved variable values
144
+ sub_problems (dict[str, Problem]): Integrated sub-problems
145
+ """
146
+
147
+ def __init__(self, name: str | None = None, description: str = ""):
148
+ # Handle subclass mode (class-level name/description) vs explicit name
149
+ self.name = name or getattr(self.__class__, "name", self.__class__.__name__)
150
+ self.description = description or getattr(self.__class__, "description", "")
151
+
152
+ # Core storage
153
+ self.variables: dict[str, FieldQnty] = {}
154
+ self.equations: list[Equation] = []
155
+
156
+ # Internal systems
157
+ self.equation_system = EquationSystem()
158
+ self.dependency_graph = Order()
159
+
160
+ # Solving state
161
+ self.is_solved = False
162
+ self.solution: dict[str, FieldQnty] = {}
163
+ self.solving_history: list[dict[str, Any]] = []
164
+
165
+ # Performance optimization caches
166
+ self._known_variables_cache: dict[str, FieldQnty] | None = None
167
+ self._unknown_variables_cache: dict[str, FieldQnty] | None = None
168
+ self._cache_dirty = True
169
+
170
+ # Validation and warning system
171
+ self.warnings: list[dict[str, Any]] = []
172
+ self.validation_checks: list[Callable] = []
173
+
174
+ self.logger = get_logger()
175
+ self.solver_manager = SolverManager(self.logger)
176
+
177
+ # Sub-problem composition support
178
+ self.sub_problems: dict[str, Any] = {}
179
+ self.variable_aliases: dict[str, str] = {}
180
+
181
+ # Initialize equation reconstructor
182
+ self.equation_reconstructor = None
183
+ self._init_reconstructor()
184
+
185
+ def _init_reconstructor(self):
186
+ """Initialize the equation reconstructor."""
187
+ try:
188
+ self.equation_reconstructor = EquationReconstructor(self)
189
+ except Exception as e:
190
+ self.logger.debug(f"Could not initialize equation reconstructor: {e}")
191
+ self.equation_reconstructor = None
192
+
193
+ # ========== CACHE MANAGEMENT ==========
194
+
195
+ def _invalidate_caches(self) -> None:
196
+ """Invalidate performance caches when variables change."""
197
+ self._cache_dirty = True
198
+
199
+ def _update_variable_caches(self) -> None:
200
+ """Update the variable caches for performance."""
201
+ if not self._cache_dirty:
202
+ return
203
+
204
+ self._known_variables_cache = {symbol: var for symbol, var in self.variables.items() if var.is_known}
205
+ self._unknown_variables_cache = {symbol: var for symbol, var in self.variables.items() if not var.is_known}
206
+ self._cache_dirty = False
207
+
208
+ # ========== VARIABLE MANAGEMENT ==========
209
+
210
+ def add_variable(self, variable: FieldQnty) -> None:
211
+ """
212
+ Add a variable to the problem.
213
+
214
+ The variable will be available for use in equations and can be accessed
215
+ via both dictionary notation (problem['symbol']) and attribute notation
216
+ (problem.symbol).
217
+
218
+ Args:
219
+ variable: Variable object to add to the problem
220
+
221
+ Note:
222
+ If a variable with the same symbol already exists, it will be replaced
223
+ and a warning will be logged.
224
+ """
225
+ if variable.symbol in self.variables:
226
+ self.logger.warning(f"Variable {variable.symbol} already exists. Replacing.")
227
+
228
+ if variable.symbol is not None:
229
+ self.variables[variable.symbol] = variable
230
+ # Set parent problem reference for dependency invalidation
231
+ if hasattr(variable, "_parent_problem"):
232
+ setattr(variable, "_parent_problem", self)
233
+ # Also set as instance attribute for dot notation access
234
+ if variable.symbol is not None:
235
+ setattr(self, variable.symbol, variable)
236
+ self.is_solved = False
237
+ self._invalidate_caches()
238
+
239
+ def add_variables(self, *variables: FieldQnty) -> None:
240
+ """Add multiple variables to the problem."""
241
+ for var in variables:
242
+ self.add_variable(var)
243
+
244
+ def get_variable(self, symbol: str) -> FieldQnty:
245
+ """Get a variable by its symbol."""
246
+ if symbol not in self.variables:
247
+ raise VariableNotFoundError(f"Variable '{symbol}' not found in problem '{self.name}'.")
248
+ return self.variables[symbol]
249
+
250
+ def get_known_variables(self) -> dict[str, FieldQnty]:
251
+ """Get all known variables."""
252
+ if self._cache_dirty or self._known_variables_cache is None:
253
+ self._update_variable_caches()
254
+ return self._known_variables_cache.copy() if self._known_variables_cache else {}
255
+
256
+ def get_unknown_variables(self) -> dict[str, FieldQnty]:
257
+ """Get all unknown variables."""
258
+ if self._cache_dirty or self._unknown_variables_cache is None:
259
+ self._update_variable_caches()
260
+ return self._unknown_variables_cache.copy() if self._unknown_variables_cache else {}
261
+
262
+ def get_known_symbols(self) -> set[str]:
263
+ """Get symbols of all known variables."""
264
+ return {symbol for symbol, var in self.variables.items() if var.is_known}
265
+
266
+ def get_unknown_symbols(self) -> set[str]:
267
+ """Get symbols of all unknown variables."""
268
+ return {symbol for symbol, var in self.variables.items() if not var.is_known}
269
+
270
+ def get_known_variable_symbols(self) -> set[str]:
271
+ """Alias for get_known_symbols for compatibility."""
272
+ return self.get_known_symbols()
273
+
274
+ def get_unknown_variable_symbols(self) -> set[str]:
275
+ """Alias for get_unknown_symbols for compatibility."""
276
+ return self.get_unknown_symbols()
277
+
278
+ # Properties for compatibility
279
+ @property
280
+ def known_variables(self) -> dict[str, FieldQnty]:
281
+ """Get all variables marked as known."""
282
+ return self.get_known_variables()
283
+
284
+ @property
285
+ def unknown_variables(self) -> dict[str, FieldQnty]:
286
+ """Get all variables marked as unknown."""
287
+ return self.get_unknown_variables()
288
+
289
+ def mark_unknown(self, *symbols: str):
290
+ """Mark variables as unknown (to be solved for)."""
291
+ for symbol in symbols:
292
+ if symbol in self.variables:
293
+ self.variables[symbol].mark_unknown()
294
+ else:
295
+ raise VariableNotFoundError(f"Variable '{symbol}' not found in problem '{self.name}'")
296
+ self.is_solved = False
297
+ self._invalidate_caches()
298
+ return self
299
+
300
+ def mark_known(self, **symbol_values: Quantity):
301
+ """Mark variables as known and set their values."""
302
+ for symbol, quantity in symbol_values.items():
303
+ if symbol in self.variables:
304
+ # Set the quantity first, then mark as known
305
+ self.variables[symbol].quantity = quantity
306
+ self.variables[symbol].mark_known()
307
+ else:
308
+ raise VariableNotFoundError(f"Variable '{symbol}' not found in problem '{self.name}'")
309
+ self.is_solved = False
310
+ self._invalidate_caches()
311
+ return self
312
+
313
+ def invalidate_dependents(self, changed_variable_symbol: str) -> None:
314
+ """
315
+ Mark all variables that depend on the changed variable as unknown.
316
+ This ensures they get recalculated when the problem is re-solved.
317
+
318
+ Args:
319
+ changed_variable_symbol: Symbol of the variable whose value changed
320
+ """
321
+ if not hasattr(self, "dependency_graph") or not self.dependency_graph:
322
+ # If dependency graph hasn't been built yet, we can't invalidate
323
+ return
324
+
325
+ # Get all variables that depend on the changed variable
326
+ dependent_vars = self.dependency_graph.graph.get(changed_variable_symbol, [])
327
+
328
+ # Mark each dependent variable as unknown
329
+ for dependent_symbol in dependent_vars:
330
+ if dependent_symbol in self.variables:
331
+ var = self.variables[dependent_symbol]
332
+ # Only mark as unknown if it was previously solved (known)
333
+ if var.is_known:
334
+ var.mark_unknown()
335
+ # Recursively invalidate variables that depend on this one
336
+ self.invalidate_dependents(dependent_symbol)
337
+
338
+ # Mark problem as needing re-solving
339
+ self.is_solved = False
340
+ self._invalidate_caches()
341
+
342
+ def _create_placeholder_variable(self, symbol: str) -> None:
343
+ """Create a placeholder variable for a missing symbol."""
344
+ placeholder_var = FieldQnty(name=f"Auto-created: {symbol}", expected_dimension=DimensionlessUnits.dimensionless.dimension, is_known=False)
345
+ placeholder_var.symbol = symbol
346
+ placeholder_var.quantity = Quantity(0.0, DimensionlessUnits.dimensionless)
347
+ self.add_variable(placeholder_var)
348
+ self.logger.debug(f"Auto-created placeholder variable: {symbol}")
349
+
350
+ def _clone_variable(self, variable: FieldQnty) -> FieldQnty:
351
+ """Create a copy of a variable to avoid shared state without corrupting global units."""
352
+ # Create a new variable of the same exact type to preserve .equals() method
353
+ # This ensures domain-specific variables (Length, Pressure, etc.) keep their type
354
+ variable_type = type(variable)
355
+
356
+ # Use __new__ to avoid constructor parameter issues
357
+ cloned = variable_type.__new__(variable_type)
358
+
359
+ # Initialize manually with the same attributes as the original
360
+ cloned.name = variable.name
361
+ cloned.symbol = variable.symbol
362
+ cloned.expected_dimension = variable.expected_dimension
363
+ cloned.quantity = variable.quantity # Keep reference to same quantity - units must not be copied
364
+ cloned.is_known = variable.is_known
365
+
366
+ # Ensure the cloned variable has fresh validation checks
367
+ if hasattr(variable, "validation_checks") and hasattr(cloned, "validation_checks"):
368
+ cloned.validation_checks = []
369
+ return cloned
370
+
371
+ def _sync_variables_to_instance_attributes(self):
372
+ """
373
+ Sync variable objects to instance attributes after solving.
374
+ This ensures that self.P refers to the same Variable object that's in self.variables.
375
+ Variables maintain their original dimensional types (e.g., AreaVariable, PressureVariable).
376
+ """
377
+ for var_symbol, var in self.variables.items():
378
+ # Update instance attribute if it exists
379
+ if hasattr(self, var_symbol):
380
+ # Variables preserve their dimensional types during solving
381
+ setattr(self, var_symbol, var)
382
+
383
+ # Also update sub-problem namespace objects
384
+ for namespace, sub_problem in self.sub_problems.items():
385
+ if hasattr(self, namespace):
386
+ namespace_obj = getattr(self, namespace)
387
+ for var_symbol in sub_problem.variables:
388
+ namespaced_symbol = f"{namespace}_{var_symbol}"
389
+ if namespaced_symbol in self.variables and hasattr(namespace_obj, var_symbol):
390
+ setattr(namespace_obj, var_symbol, self.variables[namespaced_symbol])
391
+
392
+ # ========== EQUATION MANAGEMENT ==========
393
+
394
+ def add_equation(self, equation: Equation) -> None:
395
+ """
396
+ Add an equation to the problem.
397
+
398
+ The equation will be validated to ensure all referenced variables exist.
399
+ Missing variables that look like simple identifiers will be auto-created
400
+ as unknown placeholders.
401
+
402
+ Args:
403
+ equation: Equation object to add to the problem
404
+
405
+ Raises:
406
+ EquationValidationError: If the equation is invalid or cannot be processed
407
+
408
+ Note:
409
+ Adding an equation resets the problem to unsolved state.
410
+ """
411
+ if equation is None:
412
+ raise EquationValidationError("Cannot add None equation to problem")
413
+
414
+ # Fix VariableReferences in equation to point to correct Variables
415
+ equation = self._fix_variable_references(equation)
416
+
417
+ # Validate that all variables in the equation exist
418
+ try:
419
+ equation_vars = equation.get_all_variables()
420
+ except Exception as e:
421
+ raise EquationValidationError(f"Failed to extract variables from equation: {e}") from e
422
+
423
+ missing_vars = [var for var in equation_vars if var not in self.variables]
424
+
425
+ if missing_vars:
426
+ self._handle_missing_variables(missing_vars)
427
+
428
+ # Check again for remaining missing variables
429
+ equation_vars = equation.get_all_variables()
430
+ remaining_missing = [var for var in equation_vars if var not in self.variables]
431
+ if remaining_missing:
432
+ self.logger.warning(f"Equation references missing variables: {remaining_missing}")
433
+
434
+ self.equations.append(equation)
435
+ self.equation_system.add_equation(equation)
436
+ self.is_solved = False
437
+
438
+ def add_equations(self, *equations: Equation):
439
+ """Add multiple equations to the problem."""
440
+ for eq in equations:
441
+ self.add_equation(eq)
442
+ return self
443
+
444
+ def _handle_missing_variables(self, missing_vars: list[str]) -> None:
445
+ """Handle missing variables by creating placeholders for simple symbols."""
446
+ for missing_var in missing_vars:
447
+ if self._is_simple_variable_symbol(missing_var):
448
+ self._create_placeholder_variable(missing_var)
449
+
450
+ def _is_simple_variable_symbol(self, symbol: str) -> bool:
451
+ """Check if a symbol looks like a simple variable identifier."""
452
+ return symbol.isidentifier() and not any(char in symbol for char in ["(", ")", "+", "-", "*", "/", " "])
453
+
454
+ def _fix_variable_references(self, equation: Equation) -> Equation:
455
+ """
456
+ Fix VariableReferences in equation expressions to point to Variables in problem.variables.
457
+
458
+ This resolves issues where expression trees contain VariableReferences pointing to
459
+ proxy Variables from class creation time instead of the actual Variables in the problem.
460
+ """
461
+ try:
462
+ # Fix the RHS expression
463
+ fixed_rhs = self._fix_expression_variables(equation.rhs)
464
+
465
+ # Create new equation with fixed RHS (LHS should already be correct)
466
+ return Equation(equation.name, equation.lhs, fixed_rhs)
467
+
468
+ except Exception as e:
469
+ self.logger.debug(f"Error fixing variable references in equation {equation.name}: {e}")
470
+ return equation # Return original if fixing fails
471
+
472
+ def _fix_expression_variables(self, expr):
473
+ """
474
+ Recursively fix VariableReferences in an expression tree to point to correct Variables.
475
+ """
476
+
477
+ if isinstance(expr, VariableReference):
478
+ # Check if this VariableReference points to the wrong Variable
479
+ symbol = getattr(expr, "symbol", None)
480
+ if symbol and symbol in self.variables:
481
+ correct_var = self.variables[symbol]
482
+ if expr.variable is not correct_var:
483
+ # Create new VariableReference pointing to correct Variable
484
+ return VariableReference(correct_var)
485
+ return expr
486
+
487
+ elif isinstance(expr, BinaryOperation):
488
+ # Recursively fix left and right operands
489
+ fixed_left = self._fix_expression_variables(expr.left)
490
+ fixed_right = self._fix_expression_variables(expr.right)
491
+ return BinaryOperation(expr.operator, fixed_left, fixed_right)
492
+
493
+ elif hasattr(expr, "operand"):
494
+ # Recursively fix operand
495
+ fixed_operand = self._fix_expression_variables(expr.operand)
496
+ return type(expr)(expr.operator, fixed_operand)
497
+
498
+ elif hasattr(expr, "function_name"):
499
+ # Recursively fix left and right operands
500
+ fixed_left = self._fix_expression_variables(expr.left)
501
+ fixed_right = self._fix_expression_variables(expr.right)
502
+ return type(expr)(expr.function_name, fixed_left, fixed_right)
503
+
504
+ elif isinstance(expr, Constant):
505
+ return expr
506
+
507
+ else:
508
+ # Unknown expression type, return as-is
509
+ return expr
510
+
511
+ # ========== SOLVING ==========
512
+
513
+ def solve(self, max_iterations: int = MAX_ITERATIONS_DEFAULT, tolerance: float = TOLERANCE_DEFAULT) -> dict[str, Any]:
514
+ """
515
+ Solve the engineering problem by finding values for all unknown variables.
516
+
517
+ This method orchestrates the complete solving process:
518
+ 1. Builds dependency graph from equations
519
+ 2. Determines optimal solving order using topological sorting
520
+ 3. Solves equations iteratively using symbolic/numerical methods
521
+ 4. Verifies solution against all equations
522
+ 5. Updates variable states and synchronizes instance attributes
523
+
524
+ Args:
525
+ max_iterations: Maximum number of solving iterations (default: 100)
526
+ tolerance: Numerical tolerance for convergence (default: SOLVER_DEFAULT_TOLERANCE)
527
+
528
+ Returns:
529
+ dict mapping variable symbols to solved Variable objects
530
+
531
+ Raises:
532
+ SolverError: If solving fails or times out
533
+
534
+ Example:
535
+ >>> problem = MyEngineeringProblem()
536
+ >>> solution = problem.solve()
537
+ >>> print(f"Force = {solution['F'].quantity}")
538
+ """
539
+ self.logger.info(f"Solving problem: {self.name}")
540
+
541
+ try:
542
+ # Clear previous solution
543
+ self.solution = {}
544
+ self.is_solved = False
545
+ self.solving_history = []
546
+
547
+ # Build dependency graph
548
+ self._build_dependency_graph()
549
+
550
+ # Use solver manager to solve the system
551
+ solve_result = self.solver_manager.solve(self.equations, self.variables, self.dependency_graph, max_iterations, tolerance)
552
+
553
+ if solve_result.success:
554
+ # Update variables with the result
555
+ self.variables = solve_result.variables
556
+ self.solving_history.extend(solve_result.steps)
557
+
558
+ # Sync solved values back to instance attributes
559
+ self._sync_variables_to_instance_attributes()
560
+
561
+ # Verify solution
562
+ self.solution = self.variables
563
+ verification_passed = self.verify_solution()
564
+
565
+ # Mark as solved based on solver result and verification
566
+ if verification_passed:
567
+ self.is_solved = True
568
+ self.logger.info("Solution verified successfully")
569
+ return self.solution
570
+ else:
571
+ self.logger.warning("Solution verification failed")
572
+ return self.solution
573
+ else:
574
+ raise SolverError(f"Solving failed: {solve_result.message}")
575
+
576
+ except SolverError:
577
+ raise
578
+ except Exception as e:
579
+ self.logger.error(f"Solving failed: {e}")
580
+ raise SolverError(f"Unexpected error during solving: {e}") from e
581
+
582
+ def _build_dependency_graph(self):
583
+ """Build the dependency graph for solving order determination."""
584
+ # Reset the dependency graph
585
+ self.dependency_graph = Order()
586
+
587
+ # Get known variables
588
+ known_vars = self.get_known_symbols()
589
+
590
+ # Add dependencies from equations
591
+ for equation in self.equations:
592
+ self.dependency_graph.add_equation(equation, known_vars)
593
+
594
+ def verify_solution(self, tolerance: float = SOLVER_DEFAULT_TOLERANCE) -> bool:
595
+ """Verify that all equations are satisfied."""
596
+ if not self.equations:
597
+ return True
598
+
599
+ try:
600
+ for equation in self.equations:
601
+ if not equation.check_residual(self.variables, tolerance):
602
+ self.logger.debug(f"Equation verification failed: {equation}")
603
+ return False
604
+ return True
605
+ except Exception as e:
606
+ self.logger.debug(f"Solution verification error: {e}")
607
+ return False
608
+
609
+ def analyze_system(self) -> dict[str, Any]:
610
+ """Analyze the equation system for solvability, cycles, etc."""
611
+ try:
612
+ self._build_dependency_graph()
613
+ known_vars = self.get_known_symbols()
614
+ analysis = self.dependency_graph.analyze_system(known_vars)
615
+
616
+ # Add some additional info
617
+ analysis["total_equations"] = len(self.equations)
618
+ analysis["is_determined"] = len(self.get_unknown_variables()) <= len(self.equations)
619
+
620
+ return analysis
621
+ except Exception as e:
622
+ self.logger.debug(f"Dependency analysis failed: {e}")
623
+ # Return basic analysis on failure
624
+ return {
625
+ "total_variables": len(self.variables),
626
+ "known_variables": len(self.get_known_variables()),
627
+ "unknown_variables": len(self.get_unknown_variables()),
628
+ "total_equations": len(self.equations),
629
+ "is_determined": len(self.get_unknown_variables()) <= len(self.equations),
630
+ "has_cycles": False,
631
+ "solving_order": [],
632
+ "can_solve_completely": False,
633
+ "unsolvable_variables": [],
634
+ }
635
+
636
+ # ========== UTILITY METHODS ==========
637
+
638
+ def reset_solution(self):
639
+ """Reset the problem to unsolved state."""
640
+ self.is_solved = False
641
+ self.solution = {}
642
+ self.solving_history = []
643
+
644
+ # Reset unknown variables to unknown state
645
+ for var in self.variables.values():
646
+ if not var.is_known:
647
+ var.is_known = False
648
+
649
+ def copy(self):
650
+ """Create a copy of this problem."""
651
+ return deepcopy(self)
652
+
653
+ def __str__(self) -> str:
654
+ """String representation of the problem."""
655
+ status = "SOLVED" if self.is_solved else "UNSOLVED"
656
+ return f"EngineeringProblem('{self.name}', vars={len(self.variables)}, eqs={len(self.equations)}, {status})"
657
+
658
+ def __repr__(self) -> str:
659
+ """Detailed representation of the problem."""
660
+ return self.__str__()
661
+
662
+ def __setattr__(self, name: str, value: Any) -> None:
663
+ """Custom attribute setting to maintain variable synchronization."""
664
+ # During initialization, use normal attribute setting
665
+ if not hasattr(self, "variables") or name.startswith("_"):
666
+ super().__setattr__(name, value)
667
+ return
668
+
669
+ # If setting a variable that exists in our variables dict, update both
670
+ if isinstance(value, FieldQnty) and hasattr(self, "variables") and name in self.variables:
671
+ self.variables[name] = value
672
+
673
+ super().__setattr__(name, value)
674
+
675
+ def __getitem__(self, key: str):
676
+ """Allow dict-like access to variables."""
677
+ return self.get_variable(key)
678
+
679
+ def __setitem__(self, key: str, value) -> None:
680
+ """Allow dict-like assignment of variables."""
681
+ if isinstance(value, FieldQnty):
682
+ # Update the symbol to match the key if they differ
683
+ if value.symbol != key:
684
+ value.symbol = key
685
+ self.add_variable(value)
686
+
687
+ # ========== CLASS-LEVEL EXTRACTION ==========
688
+ # Note: _extract_from_class_variables() is provided by CompositionMixin in the full Problem class
689
+
690
+
691
+ # Alias for backward compatibility
692
+ EngineeringProblem = Problem
693
+
694
+ # Export all relevant classes and exceptions
695
+ __all__ = ["Problem", "EngineeringProblem", "VariableNotFoundError", "EquationValidationError", "SolverError", "ValidationMixin"]