Coverage for o2/models/solution.py: 81%
126 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-05-16 11:18 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-05-16 11:18 +0000
1import functools
2import math
3from dataclasses import dataclass, field
4from json import dumps
5from typing import Optional
7from o2.actions.base_actions.base_action import BaseAction
8from o2.models.evaluation import Evaluation
9from o2.models.settings import Settings
10from o2.models.state import State
11from o2.models.timetable import TimetableType
12from o2.util.helper import hash_string, hex_id
13from o2.util.solution_dumper import SolutionDumper
16@dataclass(frozen=True)
17class Solution:
18 """A solution in the optimization process.
20 A solution is a pair of an evaluation, the (new) state, the old,
21 parent state, and the actions that led to this solution. With the last
22 action being the one that led to this solution.
24 NOTE: There are two evaluations defined in the class, with `evaluation` definition
25 just being there to satisfy type checking. The actual evaluation is stored in
26 `_evaluation`, which is loaded from the evaluation file when needed.
27 """
29 actions: list["BaseAction"] = field(default_factory=list)
30 """Actions taken since the base state."""
32 evaluation: Evaluation # type: ignore
33 _evaluation: Optional[Evaluation] = field(init=False, repr=False, default=None)
35 @property
36 def evaluation(self) -> Evaluation:
37 """Return the evaluation of the solution."""
38 # In case this is a pre-archived solution (e.g. from a previous run),
39 # we can take the evaluation from __dict__['evaluation']
40 if (
41 self._evaluation is None
42 and "evaluation" in self.__dict__
43 and self.__dict__["evaluation"] is not None
44 ):
45 self.__dict__["_evaluation"] = self.__dict__["evaluation"]
46 return self.__dict__["_evaluation"]
47 # Else we just load the evaluation from the evaluation file
48 elif self._evaluation is None and Settings.ARCHIVE_SOLUTIONS:
49 self.__dict__["_evaluation"] = SolutionDumper.instance.load_evaluation(self)
50 assert self._evaluation is not None
51 return self._evaluation
53 @evaluation.setter
54 def evaluation(self, value: Evaluation) -> None:
55 """Set the evaluation of the solution."""
56 self.__dict__["_evaluation"] = value
58 state: State # type: ignore
59 _state: Optional[State] = field(init=False, repr=False, default=None)
61 @property
62 def timetable(self) -> TimetableType:
63 """Return the timetable of the solution."""
64 return self.state.timetable
66 @property
67 def state(self) -> State:
68 """Return the state of the solution."""
69 # In case this is a pre-archived solution (e.g. from a previous run),
70 # we can take the state from __dict__['state']
71 if self._state is None and "state" in self.__dict__ and self.__dict__["state"] is not None:
72 self.__dict__["_state"] = self.__dict__["state"]
73 return self.__dict__["_state"]
74 # Else we just load the state from the state file
75 elif self._state is None and Settings.ARCHIVE_SOLUTIONS:
76 self.__dict__["_state"] = SolutionDumper.instance.load_state(self)
77 assert self._state is not None
78 return self._state
80 @state.setter
81 def state(self, value: State) -> None:
82 """Set the state of the solution."""
83 self.__dict__["_state"] = value
85 @property
86 def is_base_solution(self) -> bool:
87 """Check if this state is the base solution."""
88 return not self.actions
90 @property
91 def last_action(self) -> "BaseAction":
92 """Return the last action taken."""
93 return self.actions[-1]
95 @functools.cached_property
96 def point(self) -> tuple[float, float]:
97 """Return the evaluation as a point."""
98 return self.evaluation.to_tuple()
100 @functools.cached_property
101 def pareto_x(self) -> float:
102 """Return the pareto x of the solution."""
103 return self.evaluation.pareto_x
105 @functools.cached_property
106 def pareto_y(self) -> float:
107 """Return the pareto y of the solution."""
108 return self.evaluation.pareto_y
110 def distance_to(self, other: "Solution") -> float:
111 """Calculate the euclidean distance between two evaluations."""
112 return math.sqrt((self.pareto_x - other.pareto_x) ** 2 + (self.pareto_y - other.pareto_y) ** 2)
114 def is_dominated_by(self, other: "Solution") -> bool:
115 """Check if this solution is dominated by the given solution."""
116 if not Settings.EQUAL_DOMINATION_ALLOWED:
117 return other.pareto_x <= self.pareto_x and other.pareto_y <= self.pareto_y
118 return other.pareto_x < self.pareto_x and other.pareto_y < self.pareto_y
120 def has_equal_point(self, solution: "Solution") -> bool:
121 """Check if this solution has the same point as the given solution."""
122 return self.point == solution.point
124 def archive(self) -> None:
125 """Archive the solution.
127 For downwards compatibility, we need to check `__dict__['evaluation']`
128 and `__dict__['state']` as well.
129 """
130 if not Settings.ARCHIVE_SOLUTIONS:
131 return
132 # Solution is already archived
133 if self._evaluation is not None or (
134 "evaluation" in self.__dict__ and self.__dict__["evaluation"] is not None
135 ):
136 # Make sure the computed fields are triggered
137 self.pareto_x # noqa: B018
138 self.pareto_y # noqa: B018
139 self.point # noqa: B018
140 SolutionDumper.instance.dump_evaluation(self)
141 self.__dict__["_evaluation"] = None
142 self.__dict__["evaluation"] = None
143 if self._state is not None or ("state" in self.__dict__ and self.__dict__["state"] is not None):
144 # Make sure the computed fields are triggered
145 self._cache_timetable_hash() # noqa: B018
146 SolutionDumper.instance.dump_state(self)
147 self.__dict__["_state"] = None
148 # Make sure that the legacy state is removed from __dict__
149 self.__dict__["state"] = None
150 # If we still got the parent state (from a previous run), we need to remove it
151 if "parent_state" in self.__dict__:
152 self.__dict__["parent_state"] = None
154 def __eq__(self, value: object) -> bool:
155 """Check if this solution is equal to the given object."""
156 if not isinstance(value, Solution):
157 return False
158 if Settings.CHECK_FOR_TIMETABLE_EQUALITY:
159 return self.__hash__() == value.__hash__()
160 return self.id == value.id
162 def _cache_timetable_hash(self) -> None:
163 """Cache the state hash."""
164 self.__dict__["_timetable_hash"] = hash(self.state.timetable)
166 def __hash__(self) -> int:
167 """Hash the solution (possibly by id or state).
169 Also we cache the hash in a __dict__ field.
170 (manually as functools.cached_property does not work with __hash__)
171 """
172 if Settings.CHECK_FOR_TIMETABLE_EQUALITY:
173 if "_timetable_hash" not in self.__dict__:
174 self._cache_timetable_hash()
175 return self.__dict__["_timetable_hash"]
176 return int(self.id, 16)
178 @functools.cached_property
179 def is_valid(self) -> bool:
180 """Check if the evaluation is valid."""
181 return (
182 # Ensure that there was no error running the simulation,
183 # that results in a <= 0 value.
184 # Or that the solution is empty
185 self.pareto_x > 0 and self.pareto_y > 0
186 )
188 @functools.cached_property
189 def id(self) -> str:
190 """A unique identifier for the solution.
192 Generated only based on the actions.
193 """
194 # When we are the base_solution, we have no actions
195 # -> we cant hash them -> we hash the timetable
196 if not self.actions:
197 self._cache_timetable_hash()
198 return hex_id(self.__dict__["_timetable_hash"])
199 return Solution.hash_action_list(self.actions)
201 @staticmethod
202 def hash_action_list(actions: list["BaseAction"]) -> str:
203 """Hash a list of actions."""
204 if not actions:
205 return ""
206 return hash_string(dumps([a.id for a in actions]))
208 @staticmethod
209 def from_parent(parent: "Solution", action: "BaseAction") -> "Solution":
210 """Create a new solution from a parent solution.
212 Will automatically apply the action to the parent state,
213 and evaluate the new state.
214 """
215 new_state = action.apply(parent.state, enable_prints=False)
216 # If the action did not change the state, we mark the solution as invalid/empty
217 # This is due to the fact that many actions will not change the state, if
218 # the action is not valid.
219 if new_state == parent.state:
220 return Solution.empty_from_parent(parent, action)
221 evaluation = new_state.evaluate()
222 return Solution(
223 evaluation=evaluation,
224 state=new_state,
225 actions=parent.actions + [action],
226 )
228 @staticmethod
229 def empty(state: State, last_action: Optional["BaseAction"] = None) -> "Solution":
230 """Create an empty solution."""
231 return Solution(
232 evaluation=Evaluation.empty(),
233 state=state,
234 actions=[last_action] if last_action else [],
235 )
237 @staticmethod
238 def empty_from_parent(parent: "Solution", last_action: Optional["BaseAction"] = None) -> "Solution":
239 """Create an empty solution from a parent solution."""
240 return Solution(
241 evaluation=Evaluation.empty(),
242 state=parent.state,
243 actions=(parent.actions + [last_action]) if last_action else parent.actions,
244 )