Coverage for o2/models/json_report.py: 100%

131 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-05-16 11:18 +0000

1import datetime 

2from dataclasses import dataclass 

3from typing import Any, Optional 

4 

5from dataclass_wizard import JSONWizard 

6from pydantic import BaseModel 

7 

8from o2.actions.base_actions.base_action import BaseAction 

9from o2.actions.base_actions.modify_calendar_base_action import ModifyCalendarBaseAction 

10from o2.actions.base_actions.modify_resource_base_action import ModifyResourceBaseAction 

11from o2.models.constraints import ConstraintsType 

12from o2.models.legacy_constraints import WorkMasks 

13from o2.models.settings import CostType 

14from o2.models.solution import Solution 

15from o2.models.timetable import Resource, TimetableType 

16from o2.pareto_front import ParetoFront 

17from o2.store import Store 

18from o2.util.helper import CLONE_REGEX 

19 

20 

21class JSONReport(BaseModel): 

22 """Class to represent report in JSON format.""" 

23 

24 name: str 

25 created_at: str 

26 """Time when the report was created.""" 

27 

28 constraints: ConstraintsType 

29 bpmn_definition: str 

30 task_names: dict[str, str] 

31 """Mapping of task IDs to task names.""" 

32 

33 base_solution: "_JSONSolution" 

34 pareto_fronts: list["_JSONParetoFront"] 

35 

36 is_final: bool 

37 

38 approach: Optional[str] = None 

39 cost_type: CostType 

40 """The cost type used for optimization.""" 

41 

42 @staticmethod 

43 def from_store(store: Store, is_final: bool = False) -> "JSONReport": 

44 """Create a JSONReport from a Store instance. 

45 

46 Constructs a complete report containing all solutions and pareto fronts 

47 from the given store. 

48 """ 

49 return JSONReport( 

50 name=store.name, 

51 constraints=store.constraints, 

52 bpmn_definition=store.base_state.bpmn_definition, 

53 task_names=store.base_state.get_task_names(), 

54 base_solution=_JSONSolution.from_state_evaluation(store.base_solution, store), 

55 pareto_fronts=[ 

56 _JSONParetoFront.from_pareto_front(pareto_front, store) 

57 for pareto_front in store.pareto_fronts 

58 ], 

59 is_final=is_final, 

60 approach=str(store.settings.legacy_approach), 

61 cost_type=store.settings.COST_TYPE, 

62 created_at=datetime.datetime.now().isoformat(), 

63 ) 

64 

65 class Config: 

66 """pydantic config for JSONReport.""" 

67 

68 frozen = True 

69 ser_json_inf_nan = "strings" 

70 allow_inf_nan = True 

71 

72 

73@dataclass(frozen=True) 

74class _JSONParetoFront(JSONWizard): 

75 solutions: list["_JSONSolution"] 

76 

77 @staticmethod 

78 def from_pareto_front(pareto_front: "ParetoFront", store: Store) -> "_JSONParetoFront": 

79 return _JSONParetoFront( 

80 solutions=[ 

81 _JSONSolution.from_state_evaluation(solution, store) for solution in pareto_front.solutions 

82 ] 

83 ) 

84 

85 

86@dataclass 

87class _JSONResourceModifiers(JSONWizard): 

88 deleted: Optional[bool] 

89 added: Optional[bool] 

90 shifts_modified: Optional[bool] 

91 tasks_modified: Optional[bool] 

92 

93 

94@dataclass(frozen=True) 

95class _JSONResourceInfo(JSONWizard): 

96 id: str 

97 name: str 

98 

99 worked_time: float 

100 available_time: float 

101 utilization: float 

102 cost_per_week: float 

103 total_cost: float 

104 hourly_rate: float 

105 is_human: bool 

106 max_weekly_capacity: float 

107 max_daily_capacity: float 

108 max_consecutive_capacity: float 

109 # Timetable 

110 timetable_bitmask: WorkMasks 

111 original_timetable_bitmask: WorkMasks 

112 work_hours_per_week: int 

113 never_work_bitmask: WorkMasks 

114 always_work_bitmask: WorkMasks 

115 # Tasks 

116 assigned_tasks: list[str] 

117 added_tasks: list[str] 

118 removed_tasks: list[str] 

119 

120 # Batching 

121 total_batching_waiting_time: float 

122 

123 modifiers: "_JSONResourceModifiers" 

124 

125 @staticmethod 

126 def from_resource( 

127 resource: Resource, 

128 solution: Solution, 

129 store: Store, 

130 ) -> "_JSONResourceInfo": 

131 timetable = solution.state.timetable.get_calendar( 

132 resource.calendar 

133 ) or store.base_state.timetable.get_calendar_for_base_resource(resource.id) 

134 resource_constraints = store.constraints.get_legacy_constraints_for_resource(resource.id) 

135 if solution.is_base_solution: 

136 original_time_table = timetable 

137 else: 

138 original_time_table = store.base_state.timetable.get_calendar_for_base_resource(resource.id) 

139 

140 assert timetable is not None 

141 assert resource_constraints is not None 

142 assert original_time_table is not None 

143 

144 relevant_actions = [ 

145 action 

146 for action in solution.actions 

147 if (isinstance(action, ModifyResourceBaseAction) and action.params["resource_id"] == resource.id) 

148 or (isinstance(action, ModifyCalendarBaseAction) and action.params["calendar_id"] == timetable.id) 

149 ] 

150 

151 deleted = any( 

152 isinstance(action, ModifyResourceBaseAction) and action.params.get("remove_resource") 

153 for action in relevant_actions 

154 ) 

155 

156 added = CLONE_REGEX.match(resource.id) is not None 

157 

158 shifts_modified = any(isinstance(action, ModifyCalendarBaseAction) for action in relevant_actions) 

159 

160 tasks_modified = any( 

161 isinstance(action, ModifyResourceBaseAction) and action.params.get("remove_task_from_resource") 

162 for action in relevant_actions 

163 ) 

164 

165 original_tasks = store.base_state.timetable.get_tasks(resource.id) 

166 removed_tasks = [task_id for task_id in original_tasks if task_id not in resource.assigned_tasks] 

167 added_tasks = [task_id for task_id in resource.assigned_tasks if task_id not in original_tasks] 

168 

169 evaluation = solution.evaluation 

170 

171 return _JSONResourceInfo( 

172 id=resource.id, 

173 name=resource.name, 

174 worked_time=evaluation.resource_worked_times.get(resource.id, 0), 

175 work_hours_per_week=timetable.total_hours, 

176 available_time=evaluation.resource_available_times.get(resource.id, 0), 

177 utilization=evaluation.resource_utilizations.get(resource.id, 0), 

178 cost_per_week=timetable.total_hours * resource.cost_per_hour, 

179 total_cost=(evaluation.resource_available_times.get(resource.id, 0) / 60 / 60) 

180 * resource.cost_per_hour, 

181 hourly_rate=resource.cost_per_hour, 

182 is_human=resource_constraints.global_constraints.is_human, 

183 max_weekly_capacity=resource_constraints.global_constraints.max_weekly_cap, 

184 max_daily_capacity=resource_constraints.global_constraints.max_daily_cap, 

185 max_consecutive_capacity=resource_constraints.global_constraints.max_consecutive_cap, 

186 original_timetable_bitmask=original_time_table.work_masks, 

187 timetable_bitmask=timetable.work_masks, 

188 never_work_bitmask=resource_constraints.never_work_masks, 

189 always_work_bitmask=resource_constraints.always_work_masks, 

190 assigned_tasks=resource.assigned_tasks, 

191 modifiers=_JSONResourceModifiers( 

192 deleted=deleted, 

193 added=added, 

194 shifts_modified=shifts_modified, 

195 tasks_modified=tasks_modified, 

196 ), 

197 added_tasks=added_tasks, 

198 removed_tasks=removed_tasks, 

199 total_batching_waiting_time=evaluation.total_batching_waiting_time_per_resource.get( 

200 resource.id, 0 

201 ), 

202 ) 

203 

204 

205@dataclass(frozen=True) 

206class _JSONGlobalInfo(JSONWizard): 

207 average_cost: float 

208 average_time: float 

209 average_resource_utilization: float 

210 total_cost: float 

211 total_time: float 

212 average_batching_waiting_time: float 

213 average_waiting_time: float 

214 total_fixed_cost: float 

215 total_cost_for_available_time: float 

216 total_cost_for_worked_time: float 

217 total_processing_time: float 

218 avg_batch_processing_time_per_task_instance: float 

219 total_waiting_time: float 

220 total_task_idle_time: float 

221 avg_idle_wt_per_task_instance: float 

222 

223 

224@dataclass(frozen=True) 

225class _JSONAction(JSONWizard): 

226 """Class to represent action in JSON format.""" 

227 

228 type: str 

229 params: Any 

230 

231 @staticmethod 

232 def from_action(action: "BaseAction") -> "_JSONAction": 

233 return _JSONAction(action.__class__.__name__, action.params) 

234 

235 

236@dataclass(frozen=True) 

237class _JSONSolution(JSONWizard): 

238 is_base_solution: bool 

239 solution_no: int 

240 global_info: "_JSONGlobalInfo" 

241 resource_info: dict[str, "_JSONResourceInfo"] 

242 deleted_resources_info: dict[str, "_JSONResourceInfo"] 

243 timetable: TimetableType 

244 

245 actions: list[_JSONAction] 

246 

247 @staticmethod 

248 def from_state_evaluation(solution: Solution, store: Store) -> "_JSONSolution": 

249 state = solution.state 

250 evaluation = solution.evaluation 

251 return _JSONSolution( 

252 timetable=state.timetable, 

253 is_base_solution=solution.is_base_solution, 

254 solution_no=store.solution_tree.get_index_of_solution(solution), 

255 global_info=_JSONGlobalInfo( 

256 average_cost=evaluation.avg_cost_by_case, 

257 average_time=evaluation.avg_cycle_time_by_case, 

258 average_resource_utilization=evaluation.avg_resource_utilization_by_case, 

259 total_cost=evaluation.total_cost, 

260 total_time=evaluation.total_duration, 

261 average_batching_waiting_time=evaluation.avg_batching_waiting_time_by_case, 

262 average_waiting_time=evaluation.avg_waiting_time_by_case, 

263 total_fixed_cost=evaluation.total_fixed_cost, 

264 total_cost_for_available_time=evaluation.total_cost_for_available_time, 

265 total_cost_for_worked_time=evaluation.total_cost_for_worked_time, 

266 total_processing_time=evaluation.total_processing_time, 

267 avg_batch_processing_time_per_task_instance=evaluation.avg_batch_processing_time_per_task_instance, 

268 total_waiting_time=evaluation.total_waiting_time, 

269 total_task_idle_time=evaluation.total_task_idle_time, 

270 avg_idle_wt_per_task_instance=evaluation.avg_idle_wt_per_task_instance, 

271 ), 

272 resource_info={ 

273 resource.id: _JSONResourceInfo.from_resource(resource, solution, store) 

274 for resource in state.timetable.get_all_resources() 

275 }, 

276 deleted_resources_info={ 

277 resource.id: _JSONResourceInfo.from_resource(resource, solution, store) 

278 for resource in state.timetable.get_deleted_resources(store.base_state) 

279 }, 

280 actions=[_JSONAction.from_action(action) for action in solution.actions], # type: ignore 

281 )