# Copyright 2026 Daitum
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
:class:`DecisionVariable` — a single decision variable bound on a parameter or data field.
"""
from enum import Enum
from typing import Any
from daitum_model import Calculation, DataType, Parameter
from daitum_model.fields import DataField, Field
from daitum_model.serialisation import Buildable
from daitum_model.tables import DataTable
from typeguard import typechecked
[docs]
class DVType(Enum):
"""Domain of a decision variable.
Values:
RANGE: Discrete integer in a contiguous range.
LIST: Discrete integer drawn from an allowed list.
REAL: Continuous floating-point value.
"""
RANGE = "range"
LIST = "list"
REAL = "real"
def _prefixed(value: str | None) -> str | None:
"""Return ``"!!!<value>"`` for a non-None resolved reference string, else ``None``."""
return f"!!!{value}" if value is not None else None
# pylint: disable=too-many-instance-attributes,too-many-arguments,too-many-positional-arguments
[docs]
@typechecked
class DVSpecification(Buildable):
"""The nested ``specification`` object of a :class:`DecisionVariable`.
A bound is emitted as a literal (``minimumValue``/``maximumValue``) when numeric, or as a
``!!!``-prefixed reference (``minimumValueReference``/``maximumValueReference``) when it
resolves to a model object. ``@type`` is the DV type; all keys are always present.
"""
_always_emit = (
"minimum_value",
"maximum_value",
"minimum_value_reference",
"maximum_value_reference",
"seed_source",
)
def __init__(
self,
dv_type: DVType,
min_value: "int | float | str",
max_value: "int | float | str | None",
scale: "int | float",
seed_source_string: str | None,
):
self._type_name = dv_type.value
self.minimum_value = min_value if isinstance(min_value, (int, float)) else None
self.maximum_value = max_value if isinstance(max_value, (int, float)) else None
self.scale = scale
self.minimum_value_reference = _prefixed(min_value if isinstance(min_value, str) else None)
self.maximum_value_reference = _prefixed(max_value if isinstance(max_value, str) else None)
self.seed_source = _prefixed(seed_source_string)
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-branches,too-few-public-methods
[docs]
@typechecked
class DecisionVariable(Buildable):
"""
A single decision variable bound on a model named value or data field.
Construct via :meth:`ModelConfiguration.add_decision_variable`; configure
bounds and behaviour with the chained ``set_*`` methods.
"""
_tracking_counter = 0
def __init__(
self,
dv: Parameter | DataField,
dv_table: DataTable | None = None,
dv_type: DVType = DVType.RANGE,
):
self._dv_string: str | None = None
self._dv_min_value: int | float | str = 0
self._dv_max_value: int | None | float | str = None
self._tracking_id = DecisionVariable._tracking_counter
DecisionVariable._tracking_counter += 1
self._dv_type = dv_type
self._scale: int | float = 0
self._seed_source_string: str | None = None
self._tag_source_string: str | None = None
self._disabled: bool = False
self._disabled_if_invalid: bool = False
self._dv = dv
self._dv_table = dv_table
self._dv_min: float | int | Field | Calculation | Parameter = 0
self._dv_max: int | None | float | Field | Calculation | Parameter = None
self._set_dv()
[docs]
def set_min(self, dv_min: float | int | Field | Calculation | Parameter) -> "DecisionVariable":
"""Set the lower bound. Pass a literal/named value for a model-level DV, or a
:class:`~daitum_model.fields.Field` for a per-row DV."""
self._dv_min = dv_min
if dv_min != 0:
self._set_dv_min()
return self
[docs]
def set_max(
self, dv_max: int | None | float | Field | Calculation | Parameter
) -> "DecisionVariable":
"""Set the upper bound. ``None`` removes the bound. See :meth:`set_min` for the
literal-vs-field rule."""
if dv_max is not None:
if isinstance(self._dv_min, (int, float)) and isinstance(dv_max, (int, float)):
if self._dv_min > dv_max:
raise ValueError("dv_min_value can not be greater than dv_max_value")
self._dv_max = dv_max
if dv_max is not None:
self._set_dv_max()
return self
[docs]
def set_scale(self, scale: int | float) -> "DecisionVariable":
"""Set the granularity at which the solver explores this variable."""
self._scale = scale
return self
[docs]
def set_seed_source(self, seed_source: Parameter | Calculation | Field) -> "DecisionVariable":
"""Set the model source used to seed this variable's initial value.
Pass a :class:`~daitum_model.Parameter` or
:class:`~daitum_model.Calculation` for a model-level decision
variable, or a :class:`~daitum_model.fields.Field` resolved against
this variable's ``dv_table`` for a per-row decision variable. The
source is emitted as a ``!!!``-prefixed reference, matching the
format of :attr:`cellReference`.
"""
self._seed_source_string = self._resolve_source_string(seed_source, "seed_source")
return self
[docs]
def set_tag_source(self, tag_source: Parameter | Calculation | Field) -> "DecisionVariable":
"""Set the model source for reading tags on this decision variable.
Pass a :class:`~daitum_model.Parameter` or
:class:`~daitum_model.Calculation` for a model-level decision
variable, or a :class:`~daitum_model.fields.Field` resolved against
this variable's ``dv_table`` for a per-row decision variable. For
multiple tags per variable, the referenced value should be an array.
Tags are consumed by step-level ``includedTags`` filters on
:class:`~daitum_configuration.StepConfiguration`. The source is
emitted as a ``!!!``-prefixed reference, matching the format of
:attr:`cellReference`.
"""
self._tag_source_string = self._resolve_source_string(tag_source, "tag_source")
return self
def _resolve_source_string(
self, source: Parameter | Calculation | Field, param_name: str
) -> str:
"""Resolve a seed/tag source to its serialised reference string.
Mirrors the model-level-vs-per-row rule used by :attr:`cellReference`:
a per-row DV (``dv_table`` set) requires a :class:`Field`; a
model-level DV requires a :class:`Parameter` or :class:`Calculation`.
"""
if self._dv_table is None:
if not isinstance(source, Parameter | Calculation):
raise ValueError(
f"{param_name} must be a Parameter or Calculation for a "
f"model-level decision variable, got {type(source).__name__}"
)
return source.to_string()
if isinstance(source, Parameter | Calculation):
return source.to_string()
return f"{self._dv_table.id}[{source.id}]"
[docs]
def set_disabled(self, disabled: bool) -> "DecisionVariable":
"""Disable this variable, holding it at its seed value."""
self._disabled = disabled
return self
[docs]
def set_disabled_if_invalid(self, disabled_if_invalid: bool) -> "DecisionVariable":
"""Disable this variable automatically when its row fails validation."""
self._disabled_if_invalid = disabled_if_invalid
return self
def _set_dv(self):
if self._dv_table is None:
if not isinstance(self._dv, Parameter):
raise ValueError(f"Invalid input value {self._dv}")
self._set_dv_parameter(self._dv)
else:
if not isinstance(self._dv, DataField):
raise ValueError(f"Invalid input value {self._dv}")
self._set_dv_field(self._dv, self._dv_table)
def _set_dv_parameter(self, dv: Parameter):
if self._dv_type in {DVType.RANGE, DVType.LIST}:
if dv.to_data_type() != DataType.INTEGER:
raise ValueError(f"{dv.to_data_type()} is not integer")
if self._dv_type == DVType.REAL:
if dv.to_data_type() != DataType.DECIMAL:
raise ValueError(f"{dv.to_data_type()} is not decimal")
self._dv_string = dv.to_string()
def _set_dv_field(self, field: DataField, table: DataTable):
table.get_field(field.id)
if self._dv_type in {DVType.RANGE, DVType.LIST}:
if field.to_data_type() != DataType.INTEGER:
raise ValueError(f"{field.to_data_type()} is not integer")
if self._dv_type == DVType.REAL:
if field.to_data_type() != DataType.DECIMAL:
raise ValueError(f"{field.to_data_type()} is not decimal")
self._dv_string = f"{table.id}[{field.id}]"
def _set_dv_min(self):
if self._dv_table is None:
if isinstance(self._dv_min, Field):
raise ValueError(f"Invalid input value {self._dv_min}")
self._set_dv_minmax_one_arg(self._dv_min, bound_type="min")
else:
if not isinstance(self._dv_min, Field):
raise ValueError(f"Invalid input value {self._dv_min}")
self._set_dv_minmax_two_args(self._dv_min, self._dv_table, bound_type="min")
def _set_dv_max(self):
if self._dv_table is None:
if isinstance(self._dv_max, Field) or self._dv_max is None:
raise ValueError(f"Invalid input value {self._dv_max}")
self._set_dv_minmax_one_arg(self._dv_max, bound_type="max")
else:
if not isinstance(self._dv_max, Field):
raise ValueError(f"Invalid input value {self._dv_max}")
self._set_dv_minmax_two_args(self._dv_max, self._dv_table, bound_type="max")
def _set_dv_minmax_one_arg(self, value: int | float | Parameter | Calculation, bound_type: str):
if bound_type not in ("min", "max"):
raise TypeError(f"bound_type must be 'min' or 'max', got {bound_type}")
target_attr = f"_dv_{bound_type}_value"
if self._dv_type in {DVType.RANGE, DVType.LIST}:
if not isinstance(value, (int, float)):
if value.to_data_type() != DataType.INTEGER:
raise ValueError(f"{value} is not integer")
setattr(self, target_attr, value.to_string())
else:
if not isinstance(value, int):
raise ValueError(f"{value} is not integer")
setattr(self, target_attr, value)
elif self._dv_type == DVType.REAL:
if not isinstance(value, (int, float)):
if value.to_data_type() != DataType.DECIMAL:
raise ValueError(f"{value} is not decimal")
setattr(self, target_attr, value.to_string())
else:
if not isinstance(value, float):
raise ValueError(f"{value} is not decimal")
setattr(self, target_attr, value)
def _set_dv_minmax_two_args(self, field: Field, table: DataTable, bound_type: str):
if bound_type not in ("min", "max"):
raise TypeError(f"bound must be 'min' or 'max', got {bound_type}")
table.get_field(field.id)
if self._dv_type in {DVType.RANGE, DVType.LIST}:
if field.to_data_type() != DataType.INTEGER:
raise ValueError(f"{field.to_data_type()} is not integer")
elif self._dv_type == DVType.REAL:
if field.to_data_type() != DataType.DECIMAL:
raise ValueError(f"{field.to_data_type()} is not decimal")
setattr(self, f"_dv_{bound_type}_value", f"{table.id}[{field.id}]")
[docs]
def build(self) -> dict[str, Any]:
"""Serialise to a dict; the literal/reference split lives in :class:`DVSpecification`."""
specification = DVSpecification(
self._dv_type,
self._dv_min_value,
self._dv_max_value,
self._scale,
self._seed_source_string,
)
return {
"cellReference": f"!!!{self._dv_string}",
"trackingId": self._tracking_id,
"specification": specification.build(),
"disabled": self._disabled,
"disabledIfInvalid": self._disabled_if_invalid,
"tagSource": _prefixed(self._tag_source_string),
}