"""
Base variable abstraction for the CausaLoop system.
This module provides the foundational BaseVariable class and related
abstractions that form the core of the variable system. BaseVariable
represents the minimal variable abstraction without causal features,
focusing on domain validation and basic value management.
"""
from __future__ import annotations
from typing import Optional, Any
from .types import (
VariableType,
Domain,
Interval,
Categorical,
Boolean
)
[docs]
class BaseVariable():
"""
Minimal variable abstraction without causal features.
BaseVariable provides the foundation for all variables in the CausaLoop
system. It encapsulates a named value within a defined domain, with
automatic validation and type inference. This class does not include
causal tracking, multi-source resolution, or temporal logic - those
features are added in subclasses.
Parameters
----------
name : str
Unique identifier for the variable within its context.
Should be descriptive and follow naming conventions of the domain.
domain : Domain
The domain defining valid values for this variable.
Determines validation rules and variable type.
value : Any, optional
Initial value for the variable. Must be valid according to the
domain's validation rules. If None, variable starts uninitialized.
units : str, optional
Physical or logical units for the variable's value.
Used for dimensional analysis and unit conversion.
Examples: "mg/dL", "seconds", "USD", "count".
Attributes
----------
name : str
Variable identifier.
domain : Domain
Domain defining valid values.
units : Optional[str]
Measurement units, if applicable.
_value : Optional[Any]
Internal storage for the current value.
Raises
------
ValueError
If the provided initial value fails domain validation.
Examples
--------
>>> from causaloop import BaseVariable, Interval
>>> # Valid construction
>>> temp = BaseVariable("temperature", Interval(-273.15, 1000), 25.0, "°C")
>>> temp.value
25.0
>>> # Invalid construction - value outside domain
>>> try:
... var = BaseVariable("var", Interval(0, 1), 1.1, "g/mol")
... except ValueError as e:
... print(e)
Value 1.1 invalid for domain Interval(0, 1)
>>> # Construction with None value
>>> uninitialized = BaseVariable("pressure", Interval(0, 100))
>>> uninitialized.value is None
True
See Also
--------
Variable : Extended variable with multi-source resolution.
CausalVariable : Variable with causal tracking and temporal logic.
Domain : Base class for value domains.
"""
def __init__(
self,
name: str,
domain: Domain,
value: Optional[Any] = None,
units: Optional[str] = None,
):
"""
Initialize a BaseVariable with name, domain, and optional value/units.
Parameters
----------
name : str
Unique identifier for the variable.
domain : Domain
Domain object defining valid values.
value : Any, optional
Initial value. Must pass domain validation if not None.
units : str, optional
Measurement units for the variable's value.
Raises
------
ValueError
If value is not None and fails domain validation.
Examples
--------
>>> from causaloop import BaseVariable, Interval, Categorical
>>> var = BaseVariable("glucose", Interval(50, 100), 65)
>>> var.value
65
>>> var = BaseVariable("valid", Categorical(["yes", "no"]))
>>> var.value is None
True
Notes
-----
The constructor validates the initial value against the domain
immediately. This ensures variables are never in an invalid state.
To create a variable without an initial value, pass value=None.
"""
self.name = name
self.domain = domain
# Validate initial value if provided
if value is not None and not domain.validate(value):
raise ValueError(
f"Value {value!r} invalid for domain {domain}"
)
self._value = value
self.units = units
@property
def value(self) -> Optional[Any]:
"""
Get the current value of the variable.
Returns
-------
Optional[Any]
The current value, or None if the variable is uninitialized.
Examples
--------
>>> from causaloop import BaseVariable, Interval, Categorical
>>> var = BaseVariable("count", Interval(0, 100), 42.)
>>> var.value
42.0
>>> var = BaseVariable("status", Categorical(["on", "off"]))
>>> var.value is not None
False
"""
return self._value
@value.setter
def value(self, val: Any) -> None:
"""
Set the variable's value with domain validation.
Parameters
----------
val : Any
New value to assign to the variable.
Must be valid according to the variable's domain.
Raises
------
ValueError
If the value fails domain validation.
Examples
--------
>>> from causaloop import BaseVariable, Interval
>>> var = BaseVariable("score", Interval(0, 100), 50)
>>> var.value = 75 # Valid
>>> var.value
75
>>> try:
... var.value = 150 # Outside domain
... except ValueError as e:
... print(e)
Value 150 invalid for domain Interval(0, 100)
Notes
-----
The setter performs full validation using the domain's validate
method. For Interval domains, this includes type conversion attempts
(e.g., string "75" to float 75.0).
Setting value to None is allowed and represents an uninitialized
state. Use clear() method for explicit uninitialization.
"""
if val is not None and not self.domain.validate(val):
raise ValueError(
f"Value {val!r} invalid for domain {self.domain}"
)
self._value = val
@property
def type(self) -> VariableType:
"""
Infer the variable's data type from its domain.
Returns
-------
VariableType
The variable type inferred from the domain class.
Notes
-----
Type inference is based on the domain class hierarchy:
- Interval → CONTINUOUS
- Categorical → CATEGORICAL
- Boolean → BOOLEAN
- All others → DISCRETE
This inference is used for serialization, visualization, and
automatic solver selection.
Examples
--------
>>> from causaloop import BaseVariable, Interval, Boolean, Categorical
>>> var = BaseVariable("temp", Interval(0, 100), 25)
>>> var.type
<VariableType.CONTINUOUS: 'continuous'>
>>> var = BaseVariable("flag", Boolean(), True)
>>> var.type
<VariableType.BOOLEAN: 'boolean'>
>>> var = BaseVariable("category", Categorical(["A", "B"]), "A")
>>> var.type
<VariableType.CATEGORICAL: 'categorical'>
"""
if isinstance(self.domain, Interval):
return VariableType.CONTINUOUS
if isinstance(self.domain, Categorical):
return VariableType.CATEGORICAL
if isinstance(self.domain, Boolean):
return VariableType.BOOLEAN
return VariableType.DISCRETE
[docs]
def clear(self) -> None:
"""
Clear the variable's value, setting it to None.
Examples
--------
>>> from causaloop import BaseVariable, Interval
>>> var = BaseVariable("counter", Interval(0, 100), 42)
>>> var.value
42
>>> var.clear()
>>> var.value is None
True
Notes
-----
Clearing a variable sets its value to None, representing an
uninitialized state. This is different from setting a value
that happens to be None within the domain (e.g., for optional
categorical variables).
Use this method when you need to reset a variable without
destroying the object.
"""
self._value = None
[docs]
def is_valid(self, value: Any) -> bool:
"""
Check if a value would be valid for the variable's domain.
Parameters
----------
value : Any
Value to check against the variable's domain.
Returns
-------
bool
True if the value is valid for this variable's domain,
False otherwise.
Examples
--------
>>> from causaloop import BaseVariable, Interval
>>> var = BaseVariable("age", Interval(0, 120))
>>> var.is_valid(25)
True
>>> var.is_valid(-5)
False
>>> var.is_valid("twenty") # Non-numeric string
False
Notes
-----
This method performs the same validation as the value setter
but without actually setting the value. Useful for pre-checking
values before assignment.
See Also
--------
value : Property setter that uses the same validation.
"""
return value is None or self.domain.validate(value)
def __repr__(self) -> str:
"""
Return a string representation of the variable.
Returns
-------
str
String representation showing class name, variable name,
and current value.
Examples
--------
>>> from causaloop import BaseVariable, Interval
>>> var = BaseVariable("temperature", Interval(0, 100), 25.0)
>>> repr(var)
"BaseVariable('temperature', value=25.0)"
>>> var.clear()
>>> repr(var)
"BaseVariable('temperature', value=None)"
Notes
-----
The representation is designed for debugging and logging.
It shows the essential information: class, name, and value.
For more detailed information including domain and units,
use __str__() method (if implemented in subclasses).
"""
return f"{self.__class__.__name__}({self.name!r}, value={self.value})"
def __str__(self) -> str:
"""
Return a human-readable string representation.
Returns
-------
str
Formatted string with name, value, units, and type.
Examples
--------
>>> from causaloop import BaseVariable, Interval
>>> var = BaseVariable("glucose", Interval(50, 300), 95.0, "mg/dL")
>>> str(var)
'glucose = 95.0 mg/dL (continuous)'
"""
value_str = str(self.value) if self.value is not None else "uninitialized"
unit_str = f" {self.units}" if self.units else ""
type_str = f" ({self.type.value})"
return f"{self.name} = {value_str}{unit_str}{type_str}"
[docs]
def to_dict(self) -> dict:
"""
Convert variable to dictionary representation.
Returns
-------
dict
Dictionary containing variable metadata and current state.
Examples
--------
>>> from causaloop import BaseVariable, Interval
>>> var = BaseVariable("pressure", Interval(0, 100), 85.5, "kPa")
>>> var.to_dict()
{
'name': 'pressure',
'type': 'continuous',
'value': 85.5,
'units': 'kPa',
'domain': 'Interval(0, 100)'
}
Notes
-----
This representation is suitable for serialization (JSON, YAML)
and persistence. The domain is represented as its string
representation for compactness.
"""
return {
"name": self.name,
"type": self.type.value,
"value": self.value,
"units": self.units,
"domain": str(self.domain)
}