qnty 0.0.9__py3-none-any.whl → 0.1.1__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 (92) hide show
  1. qnty/__init__.py +2 -3
  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 +1 -1
  12. qnty/equations/equation.py +118 -155
  13. qnty/equations/system.py +68 -65
  14. qnty/expressions/__init__.py +25 -46
  15. qnty/expressions/formatter.py +188 -0
  16. qnty/expressions/functions.py +46 -68
  17. qnty/expressions/nodes.py +540 -384
  18. qnty/expressions/types.py +70 -0
  19. qnty/problems/__init__.py +145 -0
  20. qnty/problems/composition.py +1101 -0
  21. qnty/problems/problem.py +737 -0
  22. qnty/problems/rules.py +145 -0
  23. qnty/problems/solving.py +1216 -0
  24. qnty/problems/validation.py +127 -0
  25. qnty/quantities/__init__.py +28 -5
  26. qnty/quantities/base_qnty.py +677 -0
  27. qnty/quantities/field_converters.py +24004 -0
  28. qnty/quantities/field_qnty.py +1012 -0
  29. qnty/{generated/setters.py → quantities/field_setter.py} +3071 -2961
  30. qnty/{generated/quantities.py → quantities/field_vars.py} +829 -444
  31. qnty/{generated/quantities.pyi → quantities/field_vars.pyi} +1289 -1290
  32. qnty/solving/manager.py +50 -44
  33. qnty/solving/order.py +181 -133
  34. qnty/solving/solvers/__init__.py +2 -9
  35. qnty/solving/solvers/base.py +27 -37
  36. qnty/solving/solvers/iterative.py +115 -135
  37. qnty/solving/solvers/simultaneous.py +93 -165
  38. qnty/units/__init__.py +1 -0
  39. qnty/{generated/units.py → units/field_units.py} +1700 -991
  40. qnty/units/field_units.pyi +2461 -0
  41. qnty/units/prefixes.py +58 -105
  42. qnty/units/registry.py +76 -89
  43. qnty/utils/__init__.py +16 -0
  44. qnty/utils/caching/__init__.py +23 -0
  45. qnty/utils/caching/manager.py +401 -0
  46. qnty/utils/error_handling/__init__.py +66 -0
  47. qnty/utils/error_handling/context.py +39 -0
  48. qnty/utils/error_handling/exceptions.py +96 -0
  49. qnty/utils/error_handling/handlers.py +171 -0
  50. qnty/utils/logging.py +4 -4
  51. qnty/utils/protocols.py +164 -0
  52. qnty/utils/scope_discovery.py +420 -0
  53. {qnty-0.0.9.dist-info → qnty-0.1.1.dist-info}/METADATA +1 -1
  54. qnty-0.1.1.dist-info/RECORD +60 -0
  55. qnty/_backup/problem_original.py +0 -1251
  56. qnty/_backup/quantity.py +0 -63
  57. qnty/codegen/cli.py +0 -125
  58. qnty/codegen/generators/data/unit_data.json +0 -8807
  59. qnty/codegen/generators/data_processor.py +0 -345
  60. qnty/codegen/generators/dimensions_gen.py +0 -434
  61. qnty/codegen/generators/doc_generator.py +0 -141
  62. qnty/codegen/generators/out/dimension_mapping.json +0 -974
  63. qnty/codegen/generators/out/dimension_metadata.json +0 -123
  64. qnty/codegen/generators/out/units_metadata.json +0 -223
  65. qnty/codegen/generators/quantities_gen.py +0 -159
  66. qnty/codegen/generators/setters_gen.py +0 -178
  67. qnty/codegen/generators/stubs_gen.py +0 -167
  68. qnty/codegen/generators/units_gen.py +0 -295
  69. qnty/expressions/cache.py +0 -94
  70. qnty/generated/dimensions.py +0 -514
  71. qnty/problem/__init__.py +0 -91
  72. qnty/problem/base.py +0 -142
  73. qnty/problem/composition.py +0 -385
  74. qnty/problem/composition_mixin.py +0 -382
  75. qnty/problem/equations.py +0 -413
  76. qnty/problem/metaclass.py +0 -302
  77. qnty/problem/reconstruction.py +0 -1016
  78. qnty/problem/solving.py +0 -180
  79. qnty/problem/validation.py +0 -64
  80. qnty/problem/variables.py +0 -239
  81. qnty/quantities/expression_quantity.py +0 -314
  82. qnty/quantities/quantity.py +0 -428
  83. qnty/quantities/typed_quantity.py +0 -215
  84. qnty/validation/__init__.py +0 -0
  85. qnty/validation/registry.py +0 -0
  86. qnty/validation/rules.py +0 -167
  87. qnty-0.0.9.dist-info/RECORD +0 -63
  88. /qnty/{codegen → extensions}/__init__.py +0 -0
  89. /qnty/{codegen/generators → extensions/integration}/__init__.py +0 -0
  90. /qnty/{codegen/generators/utils → extensions/plotting}/__init__.py +0 -0
  91. /qnty/{generated → extensions/reporting}/__init__.py +0 -0
  92. {qnty-0.0.9.dist-info → qnty-0.1.1.dist-info}/WHEEL +0 -0
@@ -0,0 +1,737 @@
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
+ # Track original variable states for re-solving
182
+ self._original_variable_states: dict[str, bool] = {}
183
+ self._original_variable_units: dict[str, Any] = {}
184
+
185
+ # Initialize equation reconstructor
186
+ self.equation_reconstructor = None
187
+ self._init_reconstructor()
188
+
189
+ def _init_reconstructor(self):
190
+ """Initialize the equation reconstructor."""
191
+ try:
192
+ self.equation_reconstructor = EquationReconstructor(self)
193
+ except Exception as e:
194
+ self.logger.debug(f"Could not initialize equation reconstructor: {e}")
195
+ self.equation_reconstructor = None
196
+
197
+ # ========== CACHE MANAGEMENT ==========
198
+
199
+ def _invalidate_caches(self) -> None:
200
+ """Invalidate performance caches when variables change."""
201
+ self._cache_dirty = True
202
+
203
+ def _update_variable_caches(self) -> None:
204
+ """Update the variable caches for performance."""
205
+ if not self._cache_dirty:
206
+ return
207
+
208
+ self._known_variables_cache = {symbol: var for symbol, var in self.variables.items() if var.is_known}
209
+ self._unknown_variables_cache = {symbol: var for symbol, var in self.variables.items() if not var.is_known}
210
+ self._cache_dirty = False
211
+
212
+ # ========== VARIABLE MANAGEMENT ==========
213
+
214
+ def add_variable(self, variable: FieldQnty) -> None:
215
+ """
216
+ Add a variable to the problem.
217
+
218
+ The variable will be available for use in equations and can be accessed
219
+ via both dictionary notation (problem['symbol']) and attribute notation
220
+ (problem.symbol).
221
+
222
+ Args:
223
+ variable: Variable object to add to the problem
224
+
225
+ Note:
226
+ If a variable with the same symbol already exists, it will be replaced
227
+ and a warning will be logged.
228
+ """
229
+ if variable.symbol in self.variables:
230
+ self.logger.warning(f"Variable {variable.symbol} already exists. Replacing.")
231
+
232
+ if variable.symbol is not None:
233
+ self.variables[variable.symbol] = variable
234
+ # Track original is_known state and unit for re-solving
235
+ self._original_variable_states[variable.symbol] = variable.is_known
236
+ # Store the unit from original quantity to preserve unit info
237
+ if variable.quantity is not None:
238
+ self._original_variable_units[variable.symbol] = variable.quantity.unit
239
+ # Set parent problem reference for dependency invalidation
240
+ if hasattr(variable, "_parent_problem"):
241
+ setattr(variable, "_parent_problem", self)
242
+ # Also set as instance attribute for dot notation access
243
+ if variable.symbol is not None:
244
+ setattr(self, variable.symbol, variable)
245
+ self.is_solved = False
246
+ self._invalidate_caches()
247
+
248
+ def add_variables(self, *variables: FieldQnty) -> None:
249
+ """Add multiple variables to the problem."""
250
+ for var in variables:
251
+ self.add_variable(var)
252
+
253
+ def get_variable(self, symbol: str) -> FieldQnty:
254
+ """Get a variable by its symbol."""
255
+ if symbol not in self.variables:
256
+ raise VariableNotFoundError(f"Variable '{symbol}' not found in problem '{self.name}'.")
257
+ return self.variables[symbol]
258
+
259
+ def get_known_variables(self) -> dict[str, FieldQnty]:
260
+ """Get all known variables."""
261
+ if self._cache_dirty or self._known_variables_cache is None:
262
+ self._update_variable_caches()
263
+ return self._known_variables_cache.copy() if self._known_variables_cache else {}
264
+
265
+ def get_unknown_variables(self) -> dict[str, FieldQnty]:
266
+ """Get all unknown variables."""
267
+ if self._cache_dirty or self._unknown_variables_cache is None:
268
+ self._update_variable_caches()
269
+ return self._unknown_variables_cache.copy() if self._unknown_variables_cache else {}
270
+
271
+ def get_known_symbols(self) -> set[str]:
272
+ """Get symbols of all known variables."""
273
+ return {symbol for symbol, var in self.variables.items() if var.is_known}
274
+
275
+ def get_unknown_symbols(self) -> set[str]:
276
+ """Get symbols of all unknown variables."""
277
+ return {symbol for symbol, var in self.variables.items() if not var.is_known}
278
+
279
+ def get_known_variable_symbols(self) -> set[str]:
280
+ """Alias for get_known_symbols for compatibility."""
281
+ return self.get_known_symbols()
282
+
283
+ def get_unknown_variable_symbols(self) -> set[str]:
284
+ """Alias for get_unknown_symbols for compatibility."""
285
+ return self.get_unknown_symbols()
286
+
287
+ # Properties for compatibility
288
+ @property
289
+ def known_variables(self) -> dict[str, FieldQnty]:
290
+ """Get all variables marked as known."""
291
+ return self.get_known_variables()
292
+
293
+ @property
294
+ def unknown_variables(self) -> dict[str, FieldQnty]:
295
+ """Get all variables marked as unknown."""
296
+ return self.get_unknown_variables()
297
+
298
+ def mark_unknown(self, *symbols: str):
299
+ """Mark variables as unknown (to be solved for)."""
300
+ for symbol in symbols:
301
+ if symbol in self.variables:
302
+ self.variables[symbol].mark_unknown()
303
+ else:
304
+ raise VariableNotFoundError(f"Variable '{symbol}' not found in problem '{self.name}'")
305
+ self.is_solved = False
306
+ self._invalidate_caches()
307
+ return self
308
+
309
+ def mark_known(self, **symbol_values: Quantity):
310
+ """Mark variables as known and set their values."""
311
+ for symbol, quantity in symbol_values.items():
312
+ if symbol in self.variables:
313
+ # Set the quantity first, then mark as known
314
+ self.variables[symbol].quantity = quantity
315
+ self.variables[symbol].mark_known()
316
+ else:
317
+ raise VariableNotFoundError(f"Variable '{symbol}' not found in problem '{self.name}'")
318
+ self.is_solved = False
319
+ self._invalidate_caches()
320
+ return self
321
+
322
+ def invalidate_dependents(self, changed_variable_symbol: str) -> None:
323
+ """
324
+ Mark all variables that depend on the changed variable as unknown.
325
+ This ensures they get recalculated when the problem is re-solved.
326
+
327
+ Args:
328
+ changed_variable_symbol: Symbol of the variable whose value changed
329
+ """
330
+ if not hasattr(self, "dependency_graph") or not self.dependency_graph:
331
+ # If dependency graph hasn't been built yet, we can't invalidate
332
+ return
333
+
334
+ # Get all variables that depend on the changed variable
335
+ dependent_vars = self.dependency_graph.graph.get(changed_variable_symbol, [])
336
+
337
+ # Mark each dependent variable as unknown
338
+ for dependent_symbol in dependent_vars:
339
+ if dependent_symbol in self.variables:
340
+ var = self.variables[dependent_symbol]
341
+ # Only mark as unknown if it was previously solved (known)
342
+ if var.is_known:
343
+ var.mark_unknown()
344
+ # Recursively invalidate variables that depend on this one
345
+ self.invalidate_dependents(dependent_symbol)
346
+
347
+ # Mark problem as needing re-solving
348
+ self.is_solved = False
349
+ self._invalidate_caches()
350
+
351
+ def _create_placeholder_variable(self, symbol: str) -> None:
352
+ """Create a placeholder variable for a missing symbol."""
353
+ placeholder_var = FieldQnty(name=f"Auto-created: {symbol}", expected_dimension=DimensionlessUnits.dimensionless.dimension, is_known=False)
354
+ placeholder_var.symbol = symbol
355
+ placeholder_var.quantity = Quantity(0.0, DimensionlessUnits.dimensionless)
356
+ self.add_variable(placeholder_var)
357
+ self.logger.debug(f"Auto-created placeholder variable: {symbol}")
358
+
359
+ def _clone_variable(self, variable: FieldQnty) -> FieldQnty:
360
+ """Create a copy of a variable to avoid shared state without corrupting global units."""
361
+ # Create a new variable of the same exact type to preserve .equals() method
362
+ # This ensures domain-specific variables (Length, Pressure, etc.) keep their type
363
+ variable_type = type(variable)
364
+
365
+ # Create a new instance properly initialized
366
+ # Pass the name to the constructor which all FieldQnty subclasses accept
367
+ cloned = variable_type(variable.name)
368
+
369
+ # Copy over the attributes from the original
370
+ cloned.symbol = variable.symbol
371
+ cloned.expected_dimension = variable.expected_dimension
372
+ cloned.quantity = variable.quantity # Keep reference to same quantity - units must not be copied
373
+ cloned.is_known = variable.is_known
374
+
375
+ # Ensure the cloned variable has fresh validation checks
376
+ if hasattr(variable, "validation_checks") and hasattr(cloned, "validation_checks"):
377
+ cloned.validation_checks = []
378
+ return cloned
379
+
380
+ def _update_variables_with_solution(self, solved_variables: dict[str, FieldQnty]):
381
+ """
382
+ Update variables with solution, preserving original units for display.
383
+ """
384
+ for symbol, solved_var in solved_variables.items():
385
+ if symbol in self.variables:
386
+ original_var = self.variables[symbol]
387
+
388
+ # If we have a solved quantity and an original unit to preserve
389
+ if (solved_var.quantity is not None and
390
+ symbol in self._original_variable_units and
391
+ symbol in self._original_variable_states and
392
+ not self._original_variable_states[symbol]): # Was originally unknown
393
+
394
+ # Convert solved quantity to original unit for display
395
+ original_unit = self._original_variable_units[symbol]
396
+ try:
397
+ # Convert the solved quantity to the original unit
398
+ converted_value = solved_var.quantity.to(original_unit).value
399
+ from ..quantities.base_qnty import Quantity
400
+ original_var.quantity = Quantity(converted_value, original_unit)
401
+ original_var.is_known = True
402
+ except Exception:
403
+ # If conversion fails, use the solved quantity as-is
404
+ original_var.quantity = solved_var.quantity
405
+ original_var.is_known = solved_var.is_known
406
+ else:
407
+ # For originally known variables or if no unit conversion needed
408
+ original_var.quantity = solved_var.quantity
409
+ original_var.is_known = solved_var.is_known
410
+
411
+ def _sync_variables_to_instance_attributes(self):
412
+ """
413
+ Sync variable objects to instance attributes after solving.
414
+ This ensures that self.P refers to the same Variable object that's in self.variables.
415
+ Variables maintain their original dimensional types (e.g., AreaVariable, PressureVariable).
416
+ """
417
+ for var_symbol, var in self.variables.items():
418
+ # Update instance attribute if it exists
419
+ if hasattr(self, var_symbol):
420
+ # Variables preserve their dimensional types during solving
421
+ setattr(self, var_symbol, var)
422
+
423
+ # Also update sub-problem namespace objects
424
+ for namespace, sub_problem in self.sub_problems.items():
425
+ if hasattr(self, namespace):
426
+ namespace_obj = getattr(self, namespace)
427
+ for var_symbol in sub_problem.variables:
428
+ namespaced_symbol = f"{namespace}_{var_symbol}"
429
+ if namespaced_symbol in self.variables and hasattr(namespace_obj, var_symbol):
430
+ setattr(namespace_obj, var_symbol, self.variables[namespaced_symbol])
431
+
432
+ # ========== EQUATION MANAGEMENT ==========
433
+
434
+ def add_equation(self, equation: Equation) -> None:
435
+ """
436
+ Add an equation to the problem.
437
+
438
+ The equation will be validated to ensure all referenced variables exist.
439
+ Missing variables that look like simple identifiers will be auto-created
440
+ as unknown placeholders.
441
+
442
+ Args:
443
+ equation: Equation object to add to the problem
444
+
445
+ Raises:
446
+ EquationValidationError: If the equation is invalid or cannot be processed
447
+
448
+ Note:
449
+ Adding an equation resets the problem to unsolved state.
450
+ """
451
+ if equation is None:
452
+ raise EquationValidationError("Cannot add None equation to problem")
453
+
454
+ # Fix VariableReferences in equation to point to correct Variables
455
+ equation = self._fix_variable_references(equation)
456
+
457
+ # Validate that all variables in the equation exist
458
+ try:
459
+ equation_vars = equation.get_all_variables()
460
+ except Exception as e:
461
+ raise EquationValidationError(f"Failed to extract variables from equation: {e}") from e
462
+
463
+ missing_vars = [var for var in equation_vars if var not in self.variables]
464
+
465
+ if missing_vars:
466
+ self._handle_missing_variables(missing_vars)
467
+
468
+ # Check again for remaining missing variables
469
+ equation_vars = equation.get_all_variables()
470
+ remaining_missing = [var for var in equation_vars if var not in self.variables]
471
+ if remaining_missing:
472
+ self.logger.warning(f"Equation references missing variables: {remaining_missing}")
473
+
474
+ self.equations.append(equation)
475
+ self.equation_system.add_equation(equation)
476
+ self.is_solved = False
477
+
478
+ def add_equations(self, *equations: Equation):
479
+ """Add multiple equations to the problem."""
480
+ for eq in equations:
481
+ self.add_equation(eq)
482
+ return self
483
+
484
+ def _handle_missing_variables(self, missing_vars: list[str]) -> None:
485
+ """Handle missing variables by creating placeholders for simple symbols."""
486
+ for missing_var in missing_vars:
487
+ if self._is_simple_variable_symbol(missing_var):
488
+ self._create_placeholder_variable(missing_var)
489
+
490
+ def _is_simple_variable_symbol(self, symbol: str) -> bool:
491
+ """Check if a symbol looks like a simple variable identifier."""
492
+ return symbol.isidentifier() and not any(char in symbol for char in ["(", ")", "+", "-", "*", "/", " "])
493
+
494
+ def _fix_variable_references(self, equation: Equation) -> Equation:
495
+ """
496
+ Fix VariableReferences in equation expressions to point to Variables in problem.variables.
497
+
498
+ This resolves issues where expression trees contain VariableReferences pointing to
499
+ proxy Variables from class creation time instead of the actual Variables in the problem.
500
+ """
501
+ try:
502
+ # Fix the RHS expression
503
+ fixed_rhs = self._fix_expression_variables(equation.rhs)
504
+
505
+ # Create new equation with fixed RHS (LHS should already be correct)
506
+ return Equation(equation.name, equation.lhs, fixed_rhs)
507
+
508
+ except Exception as e:
509
+ self.logger.debug(f"Error fixing variable references in equation {equation.name}: {e}")
510
+ return equation # Return original if fixing fails
511
+
512
+ def _fix_expression_variables(self, expr):
513
+ """
514
+ Recursively fix VariableReferences in an expression tree to point to correct Variables.
515
+ """
516
+
517
+ if isinstance(expr, VariableReference):
518
+ # Check if this VariableReference points to the wrong Variable
519
+ symbol = getattr(expr, "symbol", None)
520
+ if symbol and symbol in self.variables:
521
+ correct_var = self.variables[symbol]
522
+ if expr.variable is not correct_var:
523
+ # Create new VariableReference pointing to correct Variable
524
+ return VariableReference(correct_var)
525
+ return expr
526
+
527
+ elif isinstance(expr, BinaryOperation):
528
+ # Recursively fix left and right operands
529
+ fixed_left = self._fix_expression_variables(expr.left)
530
+ fixed_right = self._fix_expression_variables(expr.right)
531
+ return BinaryOperation(expr.operator, fixed_left, fixed_right)
532
+
533
+ elif hasattr(expr, "operand"):
534
+ # Recursively fix operand
535
+ fixed_operand = self._fix_expression_variables(expr.operand)
536
+ return type(expr)(expr.operator, fixed_operand)
537
+
538
+ elif hasattr(expr, "function_name"):
539
+ # Recursively fix left and right operands
540
+ fixed_left = self._fix_expression_variables(expr.left)
541
+ fixed_right = self._fix_expression_variables(expr.right)
542
+ return type(expr)(expr.function_name, fixed_left, fixed_right)
543
+
544
+ elif isinstance(expr, Constant):
545
+ return expr
546
+
547
+ else:
548
+ # Unknown expression type, return as-is
549
+ return expr
550
+
551
+ # ========== SOLVING ==========
552
+
553
+ def solve(self, max_iterations: int = MAX_ITERATIONS_DEFAULT, tolerance: float = TOLERANCE_DEFAULT) -> dict[str, Any]:
554
+ """
555
+ Solve the engineering problem by finding values for all unknown variables.
556
+
557
+ This method orchestrates the complete solving process:
558
+ 1. Builds dependency graph from equations
559
+ 2. Determines optimal solving order using topological sorting
560
+ 3. Solves equations iteratively using symbolic/numerical methods
561
+ 4. Verifies solution against all equations
562
+ 5. Updates variable states and synchronizes instance attributes
563
+
564
+ Args:
565
+ max_iterations: Maximum number of solving iterations (default: 100)
566
+ tolerance: Numerical tolerance for convergence (default: SOLVER_DEFAULT_TOLERANCE)
567
+
568
+ Returns:
569
+ dict mapping variable symbols to solved Variable objects
570
+
571
+ Raises:
572
+ SolverError: If solving fails or times out
573
+
574
+ Example:
575
+ >>> problem = MyEngineeringProblem()
576
+ >>> solution = problem.solve()
577
+ >>> print(f"Force = {solution['F'].quantity}")
578
+ """
579
+ self.logger.info(f"Solving problem: {self.name}")
580
+
581
+ try:
582
+ # Reset solution state and restore original variable states
583
+ self.reset_solution()
584
+
585
+ # Build dependency graph
586
+ self._build_dependency_graph()
587
+
588
+ # Use solver manager to solve the system
589
+ solve_result = self.solver_manager.solve(self.equations, self.variables, self.dependency_graph, max_iterations, tolerance)
590
+
591
+ if solve_result.success:
592
+ # Update variables with the result, preserving original units where possible
593
+ self._update_variables_with_solution(solve_result.variables)
594
+ self.solving_history.extend(solve_result.steps)
595
+
596
+ # Sync solved values back to instance attributes
597
+ self._sync_variables_to_instance_attributes()
598
+
599
+ # Verify solution
600
+ self.solution = self.variables
601
+ verification_passed = self.verify_solution()
602
+
603
+ # Mark as solved based on solver result and verification
604
+ if verification_passed:
605
+ self.is_solved = True
606
+ self.logger.info("Solution verified successfully")
607
+ return self.solution
608
+ else:
609
+ self.logger.warning("Solution verification failed")
610
+ return self.solution
611
+ else:
612
+ raise SolverError(f"Solving failed: {solve_result.message}")
613
+
614
+ except SolverError:
615
+ raise
616
+ except Exception as e:
617
+ self.logger.error(f"Solving failed: {e}")
618
+ raise SolverError(f"Unexpected error during solving: {e}") from e
619
+
620
+ def _build_dependency_graph(self):
621
+ """Build the dependency graph for solving order determination."""
622
+ # Reset the dependency graph
623
+ self.dependency_graph = Order()
624
+
625
+ # Get known variables
626
+ known_vars = self.get_known_symbols()
627
+
628
+ # Add dependencies from equations
629
+ for equation in self.equations:
630
+ self.dependency_graph.add_equation(equation, known_vars)
631
+
632
+ def verify_solution(self, tolerance: float = SOLVER_DEFAULT_TOLERANCE) -> bool:
633
+ """Verify that all equations are satisfied."""
634
+ if not self.equations:
635
+ return True
636
+
637
+ try:
638
+ for equation in self.equations:
639
+ if not equation.check_residual(self.variables, tolerance):
640
+ self.logger.debug(f"Equation verification failed: {equation}")
641
+ return False
642
+ return True
643
+ except Exception as e:
644
+ self.logger.debug(f"Solution verification error: {e}")
645
+ return False
646
+
647
+ def analyze_system(self) -> dict[str, Any]:
648
+ """Analyze the equation system for solvability, cycles, etc."""
649
+ try:
650
+ self._build_dependency_graph()
651
+ known_vars = self.get_known_symbols()
652
+ analysis = self.dependency_graph.analyze_system(known_vars)
653
+
654
+ # Add some additional info
655
+ analysis["total_equations"] = len(self.equations)
656
+ analysis["is_determined"] = len(self.get_unknown_variables()) <= len(self.equations)
657
+
658
+ return analysis
659
+ except Exception as e:
660
+ self.logger.debug(f"Dependency analysis failed: {e}")
661
+ # Return basic analysis on failure
662
+ return {
663
+ "total_variables": len(self.variables),
664
+ "known_variables": len(self.get_known_variables()),
665
+ "unknown_variables": len(self.get_unknown_variables()),
666
+ "total_equations": len(self.equations),
667
+ "is_determined": len(self.get_unknown_variables()) <= len(self.equations),
668
+ "has_cycles": False,
669
+ "solving_order": [],
670
+ "can_solve_completely": False,
671
+ "unsolvable_variables": [],
672
+ }
673
+
674
+ # ========== UTILITY METHODS ==========
675
+
676
+ def reset_solution(self):
677
+ """Reset the problem to unsolved state."""
678
+ self.is_solved = False
679
+ self.solution = {}
680
+ self.solving_history = []
681
+
682
+ # Reset variables to their original known/unknown states
683
+ for symbol, var in self.variables.items():
684
+ if symbol in self._original_variable_states:
685
+ original_state = self._original_variable_states[symbol]
686
+ var.is_known = original_state
687
+ # If variable was originally unknown, reset it to None so solver can update it
688
+ if not original_state:
689
+ var.quantity = None
690
+
691
+ def copy(self):
692
+ """Create a copy of this problem."""
693
+ return deepcopy(self)
694
+
695
+ def __str__(self) -> str:
696
+ """String representation of the problem."""
697
+ status = "SOLVED" if self.is_solved else "UNSOLVED"
698
+ return f"EngineeringProblem('{self.name}', vars={len(self.variables)}, eqs={len(self.equations)}, {status})"
699
+
700
+ def __repr__(self) -> str:
701
+ """Detailed representation of the problem."""
702
+ return self.__str__()
703
+
704
+ def __setattr__(self, name: str, value: Any) -> None:
705
+ """Custom attribute setting to maintain variable synchronization."""
706
+ # During initialization, use normal attribute setting
707
+ if not hasattr(self, "variables") or name.startswith("_"):
708
+ super().__setattr__(name, value)
709
+ return
710
+
711
+ # If setting a variable that exists in our variables dict, update both
712
+ if isinstance(value, FieldQnty) and hasattr(self, "variables") and name in self.variables:
713
+ self.variables[name] = value
714
+
715
+ super().__setattr__(name, value)
716
+
717
+ def __getitem__(self, key: str):
718
+ """Allow dict-like access to variables."""
719
+ return self.get_variable(key)
720
+
721
+ def __setitem__(self, key: str, value) -> None:
722
+ """Allow dict-like assignment of variables."""
723
+ if isinstance(value, FieldQnty):
724
+ # Update the symbol to match the key if they differ
725
+ if value.symbol != key:
726
+ value.symbol = key
727
+ self.add_variable(value)
728
+
729
+ # ========== CLASS-LEVEL EXTRACTION ==========
730
+ # Note: _extract_from_class_variables() is provided by CompositionMixin in the full Problem class
731
+
732
+
733
+ # Alias for backward compatibility
734
+ EngineeringProblem = Problem
735
+
736
+ # Export all relevant classes and exceptions
737
+ __all__ = ["Problem", "EngineeringProblem", "VariableNotFoundError", "EquationValidationError", "SolverError", "ValidationMixin"]