# 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.tables import DataTable
from typeguard import typechecked
from daitum_configuration._buildable import Buildable
[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"
# 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 JSON-compatible dict."""
result: dict[str, Any] = {
"cellReference": f"!!!{self._dv_string}",
"trackingId": self._tracking_id,
"specification": {
"@type": self._dv_type.value,
"minimumValue": (
self._dv_min_value if isinstance(self._dv_min_value, (int, float)) else None
),
"maximumValue": (
self._dv_max_value if isinstance(self._dv_max_value, (int, float)) else None
),
"scale": self._scale,
"minimumValueReference": (
f"!!!{self._dv_min_value}" if isinstance(self._dv_min_value, str) else None
),
"maximumValueReference": (
f"!!!{self._dv_max_value}" if isinstance(self._dv_max_value, str) else None
),
"seedSource": (
f"!!!{self._seed_source_string}"
if self._seed_source_string is not None
else None
),
},
"disabled": self._disabled,
"disabledIfInvalid": self._disabled_if_invalid,
"tagSource": (
f"!!!{self._tag_source_string}" if self._tag_source_string is not None else None
),
}
return result