qnty 0.1.3__py3-none-any.whl → 0.1.5__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.
@@ -98,12 +98,38 @@ class Equation:
98
98
  if var_obj is None:
99
99
  raise ValueError(f"Variable '{target_var}' not found in variable_values")
100
100
 
101
- # Convert result to the target variable's original unit if it had one
101
+ # Convert result to preferred unit or original unit if available
102
+ target_unit_constant = None
103
+
104
+ # First priority: existing quantity unit
102
105
  if var_obj.quantity is not None and var_obj.quantity.unit is not None:
106
+ target_unit_constant = var_obj.quantity.unit
107
+ # Second priority: preferred unit from constructor
108
+ elif hasattr(var_obj, 'preferred_unit') and var_obj.preferred_unit is not None:
109
+ # Look up unit constant from string name
110
+ preferred_unit_name = var_obj.preferred_unit
103
111
  try:
104
- result_qty = result_qty.to(var_obj.quantity.unit)
112
+ # Get the dimension-specific units class
113
+ class_name = var_obj.__class__.__name__
114
+ units_class_name = f'{class_name}Units'
115
+
116
+ # Import field_units dynamically to avoid circular imports
117
+ from ..units import field_units
118
+ units_class = getattr(field_units, units_class_name, None)
119
+ if units_class and hasattr(units_class, preferred_unit_name):
120
+ target_unit_constant = getattr(units_class, preferred_unit_name)
121
+ _logger.debug(f"Found preferred unit constant: {preferred_unit_name}")
122
+ else:
123
+ _logger.debug(f"Could not find unit constant for {preferred_unit_name} in {units_class_name}")
124
+ except (ImportError, AttributeError) as e:
125
+ _logger.debug(f"Failed to lookup preferred unit {preferred_unit_name}: {e}")
126
+
127
+ if target_unit_constant is not None:
128
+ try:
129
+ result_qty = result_qty.to(target_unit_constant)
130
+ _logger.debug(f"Converted {target_var} result to preferred unit: {target_unit_constant.symbol}")
105
131
  except (ValueError, TypeError, AttributeError) as e:
106
- _logger.debug(f"Unit conversion failed for {target_var}: {e}. Using calculated unit.")
132
+ _logger.debug(f"Unit conversion failed for {target_var} to {target_unit_constant}: {e}. Using calculated unit.")
107
133
 
108
134
  # Update the variable and return it
109
135
  var_obj.quantity = result_qty
@@ -16,8 +16,24 @@ ConditionalOperand = Expression | BinaryOperation
16
16
  def _create_unary_function(name: str, docstring: str):
17
17
  """Factory function for creating unary mathematical functions."""
18
18
 
19
- def func(expr: ExpressionOperand) -> UnaryFunction:
20
- return UnaryFunction(name, wrap_operand(expr))
19
+ def func(expr: ExpressionOperand):
20
+ from .nodes import VariableReference
21
+
22
+ wrapped_expr = wrap_operand(expr)
23
+
24
+ # For known quantities (FieldQnty with known values), evaluate immediately
25
+ if hasattr(expr, 'quantity') and expr.quantity is not None:
26
+ try:
27
+ unary_func = UnaryFunction(name, wrapped_expr)
28
+ # Use an empty variable dict since we have the quantity directly
29
+ result = unary_func.evaluate({})
30
+ return result
31
+ except (ValueError, TypeError, AttributeError):
32
+ # Fall back to expression if evaluation fails
33
+ pass
34
+
35
+ # For unknown variables or expressions, return the UnaryFunction
36
+ return UnaryFunction(name, wrapped_expr)
21
37
 
22
38
  func.__name__ = name
23
39
  func.__doc__ = docstring
qnty/expressions/nodes.py CHANGED
@@ -304,32 +304,37 @@ class BinaryOperation(Expression):
304
304
  right_value = right_val.value
305
305
 
306
306
  # ENHANCED FAST PATHS: Check most common optimizations first
307
+ # BUT ONLY when they don't affect dimensional analysis!
308
+
307
309
  # Identity optimizations (1.0 multiplication) - most frequent case
308
- if right_value == 1.0:
310
+ # Only safe when the 1.0 value is dimensionless
311
+ if right_value == 1.0 and right_val._dimension_sig == 1:
309
312
  return left_val
310
- elif left_value == 1.0:
313
+ elif left_value == 1.0 and left_val._dimension_sig == 1:
311
314
  return right_val
312
315
 
313
316
  # Zero optimizations - second most common
314
- elif right_value == 0.0:
315
- return Quantity(0.0, right_val.unit)
316
- elif left_value == 0.0:
317
- return Quantity(0.0, left_val.unit)
317
+ # Zero multiplication always gives zero, but we need proper units via full multiplication
318
+ elif right_value == 0.0 or left_value == 0.0:
319
+ # Use full multiplication to get correct dimensional result for zero
320
+ return left_val * right_val
318
321
 
319
322
  # Additional fast paths for common values
320
- elif right_value == -1.0:
323
+ # Only safe when the scalar value is dimensionless
324
+ elif right_value == -1.0 and right_val._dimension_sig == 1:
321
325
  return Quantity(-left_value, left_val.unit)
322
- elif left_value == -1.0:
326
+ elif left_value == -1.0 and left_val._dimension_sig == 1:
323
327
  return Quantity(-right_value, right_val.unit)
324
328
 
325
329
  # ADDITIONAL COMMON CASES: Powers of 2 and 0.5 (very common in engineering)
326
- elif right_value == 2.0:
330
+ # Only safe when the scalar value is dimensionless
331
+ elif right_value == 2.0 and right_val._dimension_sig == 1:
327
332
  return Quantity(left_value * 2.0, left_val.unit)
328
- elif left_value == 2.0:
333
+ elif left_value == 2.0 and left_val._dimension_sig == 1:
329
334
  return Quantity(right_value * 2.0, right_val.unit)
330
- elif right_value == 0.5:
335
+ elif right_value == 0.5 and right_val._dimension_sig == 1:
331
336
  return Quantity(left_value * 0.5, left_val.unit)
332
- elif left_value == 0.5:
337
+ elif left_value == 0.5 and left_val._dimension_sig == 1:
333
338
  return Quantity(right_value * 0.5, right_val.unit)
334
339
 
335
340
  # OPTIMIZED REGULAR CASE: Use the enhanced multiplication from base_qnty
@@ -432,7 +437,10 @@ class BinaryOperation(Expression):
432
437
 
433
438
  # Perform comparison using optimized dispatch
434
439
  result = self._perform_comparison(left_val.value, right_val.value)
435
- return Quantity(1.0 if result else 0.0, DimensionlessUnits.dimensionless)
440
+
441
+ # Import BooleanQuantity locally to avoid circular imports
442
+ from ..quantities.base_qnty import BooleanQuantity
443
+ return BooleanQuantity(result)
436
444
 
437
445
  def _normalize_comparison_units(self, left_val: "Quantity", right_val: "Quantity") -> tuple["Quantity", "Quantity"]:
438
446
  """Normalize units for comparison operations."""
@@ -506,14 +514,14 @@ class UnaryFunction(Expression):
506
514
 
507
515
  # Function dispatch table for better performance and maintainability
508
516
  functions = {
509
- "sin": lambda x: Quantity(math.sin(x.value), DimensionlessUnits.dimensionless),
510
- "cos": lambda x: Quantity(math.cos(x.value), DimensionlessUnits.dimensionless),
511
- "tan": lambda x: Quantity(math.tan(x.value), DimensionlessUnits.dimensionless),
517
+ "sin": lambda x: math.sin(self._to_radians_if_angle(x)),
518
+ "cos": lambda x: math.cos(self._to_radians_if_angle(x)),
519
+ "tan": lambda x: math.tan(self._to_radians_if_angle(x)),
512
520
  "sqrt": lambda x: Quantity(math.sqrt(x.value), x.unit),
513
521
  "abs": lambda x: Quantity(abs(x.value), x.unit),
514
- "ln": lambda x: Quantity(math.log(x.value), DimensionlessUnits.dimensionless),
515
- "log10": lambda x: Quantity(math.log10(x.value), DimensionlessUnits.dimensionless),
516
- "exp": lambda x: Quantity(math.exp(x.value), DimensionlessUnits.dimensionless),
522
+ "ln": lambda x: math.log(x.value),
523
+ "log10": lambda x: math.log10(x.value),
524
+ "exp": lambda x: math.exp(x.value),
517
525
  }
518
526
 
519
527
  func = functions.get(self.function_name)
@@ -522,6 +530,35 @@ class UnaryFunction(Expression):
522
530
  else:
523
531
  raise ValueError(f"Unknown function: {self.function_name}")
524
532
 
533
+ def _to_radians_if_angle(self, quantity: "Quantity") -> float:
534
+ """Convert angle quantities to radians for trigonometric functions."""
535
+ from ..dimensions import field_dims
536
+
537
+ # Check if this is an angle dimension by comparing dimension signature
538
+ # Need to handle the case where angle dimensions might not exactly match due to implementation details
539
+ try:
540
+ # Import angle plane dimension for comparison
541
+ angle_plane_dim = field_dims.ANGLE_PLANE
542
+
543
+ # If this looks like an angle (has angle dimension or unit name suggests it)
544
+ if (hasattr(quantity, '_dimension_sig') and quantity._dimension_sig == angle_plane_dim) or \
545
+ (hasattr(quantity, 'unit') and hasattr(quantity.unit, 'name') and
546
+ any(angle_word in str(quantity.unit.name).lower() for angle_word in ['degree', 'radian', 'grad', 'gon'])):
547
+
548
+ # Import the radian unit for conversion
549
+ from ..units.field_units import AnglePlaneUnits
550
+
551
+ # Convert to radians and return the numeric value
552
+ radian_quantity = quantity.to(AnglePlaneUnits.radian.definition)
553
+ return radian_quantity.value
554
+ else:
555
+ # Non-angle quantities: return raw value (backward compatibility)
556
+ return quantity.value
557
+ except (ImportError, AttributeError, ValueError):
558
+ # If anything goes wrong with angle detection/conversion, fall back to raw value
559
+ return quantity.value
560
+
561
+
525
562
  def get_variables(self) -> set[str]:
526
563
  return self.operand.get_variables()
527
564
 
@@ -538,6 +575,15 @@ class UnaryFunction(Expression):
538
575
  return UnaryFunction(self.function_name, simplified_operand)
539
576
 
540
577
  def __str__(self) -> str:
578
+ # Try auto-evaluation first if possible
579
+ can_eval, variables = self._can_auto_evaluate()
580
+ if can_eval and variables:
581
+ try:
582
+ result = self.evaluate(variables)
583
+ return str(result)
584
+ except (ValueError, TypeError, AttributeError):
585
+ # Fall back to symbolic representation
586
+ pass
541
587
  return ExpressionFormatter.format_unary_function(self) # type: ignore[arg-type]
542
588
 
543
589
 
@@ -186,6 +186,8 @@ class ConfigurableVariable:
186
186
  return self._variable._arithmetic_mode
187
187
  # Otherwise default to 'expression'
188
188
  return 'expression'
189
+
190
+
189
191
  return getattr(self._variable, name)
190
192
 
191
193
  # Delegate arithmetic operations to the wrapped variable
@@ -233,10 +235,10 @@ class ConfigurableVariable:
233
235
  def __ge__(self, other):
234
236
  return self._variable.__ge__(other)
235
237
 
236
- def __eq__(self, other):
238
+ def __eq__(self, other): # type: ignore[override]
237
239
  return self._variable.__eq__(other)
238
240
 
239
- def __ne__(self, other):
241
+ def __ne__(self, other): # type: ignore[override]
240
242
  return self._variable.__ne__(other)
241
243
 
242
244
  def __setattr__(self, name, value):
@@ -257,6 +259,30 @@ class ConfigurableVariable:
257
259
  else:
258
260
  return original_setter
259
261
 
262
+ def update(self, value=None, unit=None, quantity=None, is_known=None):
263
+ """Override update method to track configuration changes."""
264
+ result = self._variable.update(value, unit, quantity, is_known)
265
+ if self._proxy and self._original_symbol:
266
+ # Track this configuration change
267
+ self._proxy.track_configuration(self._original_symbol, self._variable.quantity, self._variable.is_known)
268
+ return result
269
+
270
+ def mark_known(self):
271
+ """Override mark_known to track configuration changes."""
272
+ result = self._variable.mark_known()
273
+ if self._proxy and self._original_symbol:
274
+ # Track this configuration change
275
+ self._proxy.track_configuration(self._original_symbol, self._variable.quantity, self._variable.is_known)
276
+ return result
277
+
278
+ def mark_unknown(self):
279
+ """Override mark_unknown to track configuration changes."""
280
+ result = self._variable.mark_unknown()
281
+ if self._proxy and self._original_symbol:
282
+ # Track this configuration change
283
+ self._proxy.track_configuration(self._original_symbol, self._variable.quantity, self._variable.is_known)
284
+ return result
285
+
260
286
 
261
287
  class TrackingSetterWrapper:
262
288
  """
@@ -307,30 +333,6 @@ class TrackingSetterWrapper:
307
333
  )
308
334
  return result
309
335
 
310
- def update(self, value=None, unit=None, quantity=None, is_known=None):
311
- """Override update method to track configuration changes."""
312
- result = self._variable.update(value, unit, quantity, is_known)
313
- if self._proxy and self._original_symbol:
314
- # Track this configuration change
315
- self._proxy.track_configuration(self._original_symbol, self._variable.quantity, self._variable.is_known)
316
- return result
317
-
318
- def mark_known(self):
319
- """Override mark_known to track configuration changes."""
320
- result = self._variable.mark_known()
321
- if self._proxy and self._original_symbol:
322
- # Track this configuration change
323
- self._proxy.track_configuration(self._original_symbol, self._variable.quantity, self._variable.is_known)
324
- return result
325
-
326
- def mark_unknown(self):
327
- """Override mark_unknown to track configuration changes."""
328
- result = self._variable.mark_unknown()
329
- if self._proxy and self._original_symbol:
330
- # Track this configuration change
331
- self._proxy.track_configuration(self._original_symbol, self._variable.quantity, self._variable.is_known)
332
- return result
333
-
334
336
 
335
337
  class DelayedVariableReference(ArithmeticOperationsMixin):
336
338
  """
qnty/problems/problem.py CHANGED
@@ -372,6 +372,10 @@ class Problem(ValidationMixin):
372
372
  cloned.quantity = variable.quantity # Keep reference to same quantity - units must not be copied
373
373
  cloned.is_known = variable.is_known
374
374
 
375
+ # Copy unit preference if it exists
376
+ if hasattr(variable, '_preferred_unit'):
377
+ cloned._preferred_unit = variable._preferred_unit
378
+
375
379
  # Ensure the cloned variable has fresh validation checks
376
380
  if hasattr(variable, "validation_checks") and hasattr(cloned, "validation_checks"):
377
381
  cloned.validation_checks = []
@@ -673,5 +673,43 @@ class Quantity:
673
673
  return UnitConversions.to(self, target_unit)
674
674
 
675
675
 
676
+ class BooleanQuantity(Quantity):
677
+ """A quantity that represents a boolean value but maintains Quantity compatibility.
678
+
679
+ This class is used for comparison operations that need to return boolean results
680
+ while maintaining the Expression interface requirement of returning Quantity objects.
681
+ """
682
+
683
+ __slots__ = ("_boolean_value",)
684
+
685
+ def __init__(self, boolean_value: bool):
686
+ """Initialize with a boolean value."""
687
+ # Store the actual boolean value
688
+ self._boolean_value = boolean_value
689
+
690
+ # Initialize parent with numeric representation
691
+ super().__init__(
692
+ 1.0 if boolean_value else 0.0,
693
+ DimensionlessUnits.dimensionless
694
+ )
695
+
696
+ def __str__(self) -> str:
697
+ """Display as True/False instead of 1.0/0.0."""
698
+ return str(self._boolean_value)
699
+
700
+ def __repr__(self) -> str:
701
+ """Display as BooleanQuantity(True/False)."""
702
+ return f"BooleanQuantity({self._boolean_value})"
703
+
704
+ def __bool__(self) -> bool:
705
+ """Return the actual boolean value."""
706
+ return self._boolean_value
707
+
708
+ @property
709
+ def boolean_value(self) -> bool:
710
+ """Access the boolean value directly."""
711
+ return self._boolean_value
712
+
713
+
676
714
  # Initialize cache manager at module load
677
715
  _cache_manager.initialize_common_operations()
@@ -13,8 +13,8 @@ from typing import TYPE_CHECKING, Final
13
13
  if TYPE_CHECKING:
14
14
  from ..quantities.field_qnty import FieldQnty
15
15
 
16
- from ..units import field_units
17
16
  from .base_qnty import Quantity
17
+ from ..units import field_units
18
18
 
19
19
  # ===== BASE CONVERTER CLASSES =====
20
20
 
@@ -33,7 +33,10 @@ class UnitConverter:
33
33
  units_class = getattr(field_units, units_class_name, None)
34
34
  if units_class and hasattr(units_class, unit_name):
35
35
  return getattr(units_class, unit_name)
36
- raise ValueError(f'Unknown unit: {unit_name} for {self.variable.__class__.__name__}')
36
+ # Raise error with suggestions
37
+ from ..utils.unit_suggestions import create_unit_validation_error
38
+ var_type = getattr(self.variable, '__class__', type(self.variable)).__name__
39
+ raise create_unit_validation_error(unit_name, var_type)
37
40
 
38
41
  def _convert_quantity(self, unit_constant, modify_original: bool = False):
39
42
  """Convert quantity to specified unit."""
@@ -43,6 +43,7 @@ class QuantityManagementMixin:
43
43
  self._is_known: bool = False
44
44
  self._name: str = ""
45
45
  self._symbol: str | None = None
46
+ self._preferred_unit: str | None = None
46
47
 
47
48
  @property
48
49
  def quantity(self) -> Quantity | None:
@@ -90,6 +91,15 @@ class QuantityManagementMixin:
90
91
 
91
92
  return self
92
93
 
94
+ def _set_preferred_unit(self, unit: str) -> None:
95
+ """Set the preferred unit for solver results."""
96
+ self._preferred_unit = unit
97
+
98
+ @property
99
+ def preferred_unit(self) -> str | None:
100
+ """Get the preferred unit for this variable."""
101
+ return self._preferred_unit
102
+
93
103
 
94
104
  class FlexibleConstructorMixin:
95
105
  """Handles flexible variable initialization patterns maintaining backward compatibility."""
@@ -180,10 +190,9 @@ class FlexibleConstructorMixin:
180
190
  return result
181
191
 
182
192
  # Fallback to direct quantity creation
183
- from ..units import DimensionlessUnits
184
193
  from .base_qnty import Quantity
185
194
 
186
- # Try to find the unit in the registry or use dimensionless fallback
195
+ # Try to find the unit in the registry
187
196
  try:
188
197
  from ..units.registry import registry
189
198
 
@@ -194,8 +203,10 @@ class FlexibleConstructorMixin:
194
203
  except Exception:
195
204
  pass
196
205
 
197
- # Final fallback to dimensionless
198
- return Quantity(value, DimensionlessUnits.dimensionless)
206
+ # If unit is not found, raise error with suggestions instead of falling back to dimensionless
207
+ from ..utils.unit_suggestions import create_unit_validation_error
208
+ var_type = getattr(self, '__class__', type(self)).__name__
209
+ raise create_unit_validation_error(str(unit), var_type)
199
210
 
200
211
  def _find_unit_property(self, setter: TypeSafeSetter, unit: str) -> str | None:
201
212
  """Find unit property with simple lookup."""
@@ -609,6 +620,12 @@ class ExpressionMixin:
609
620
 
610
621
  def __gt__(self, other) -> Expression:
611
622
  return self.gt(other)
623
+
624
+ def __eq__(self, other) -> Expression: # type: ignore[override]
625
+ return self.eq(other)
626
+
627
+ def __ne__(self, other) -> Expression: # type: ignore[override]
628
+ return self.ne(other)
612
629
 
613
630
  def eq(self, other) -> Expression:
614
631
  """Create equality comparison expression."""
@@ -827,7 +844,10 @@ class UnitConverter:
827
844
  except Exception:
828
845
  pass
829
846
 
830
- raise ValueError(f"Unknown unit: {unit_str}")
847
+ # Raise error with suggestions
848
+ from ..utils.unit_suggestions import create_unit_validation_error
849
+ var_type = getattr(self.variable, '__class__', type(self.variable)).__name__
850
+ raise create_unit_validation_error(unit_str, var_type)
831
851
 
832
852
  def _convert_quantity(self, to_unit_constant, modify_original: bool = True):
833
853
  """Convert the variable's quantity to the specified unit."""