Source code for daitum_configuration.model_configuration.decision_variable

# 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), }