plansolve 0.25.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
1
+ """Data models for field service optimization."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Optional, Tuple, Dict, Any, Literal
5
+
6
+ from .constraint_weight import ConstraintWeight
7
+
8
+
9
+ @dataclass
10
+ class Shift:
11
+ """Represents a work shift for a vehicle."""
12
+
13
+ id: str
14
+ min_start_time: str # ISO 8601 datetime string
15
+ max_end_time: str # ISO 8601 datetime string
16
+
17
+ def to_dict(self) -> Dict[str, Any]:
18
+ """Convert to dictionary for API serialization."""
19
+ return {
20
+ "id": self.id,
21
+ "minStartTime": self.min_start_time,
22
+ "maxEndTime": self.max_end_time,
23
+ }
24
+
25
+ @classmethod
26
+ def from_dict(cls, data: Dict[str, Any]) -> "Shift":
27
+ """Create from dictionary."""
28
+ return cls(
29
+ id=data["id"],
30
+ min_start_time=data["minStartTime"],
31
+ max_end_time=data["maxEndTime"],
32
+ )
33
+
34
+
35
+ @dataclass
36
+ class TimeWindow:
37
+ """Represents a time window for a visit."""
38
+
39
+ min_start_time: str # ISO 8601 datetime string
40
+ max_end_time: str # ISO 8601 datetime string
41
+
42
+ def to_dict(self) -> Dict[str, Any]:
43
+ """Convert to dictionary for API serialization."""
44
+ return {
45
+ "minStartTime": self.min_start_time,
46
+ "maxEndTime": self.max_end_time,
47
+ }
48
+
49
+ @classmethod
50
+ def from_dict(cls, data: Dict[str, Any]) -> "TimeWindow":
51
+ """Create from dictionary."""
52
+ return cls(
53
+ min_start_time=data["minStartTime"],
54
+ max_end_time=data["maxEndTime"],
55
+ )
56
+
57
+
58
+ @dataclass
59
+ class Vehicle:
60
+ """Represents a vehicle (field technician) for optimization."""
61
+
62
+ id: str
63
+ location: Tuple[float, float] # [latitude, longitude]
64
+ shifts: List[Shift]
65
+ skills: List[str] = field(default_factory=list)
66
+ name: Optional[str] = None
67
+ departure_time: Optional[str] = None
68
+ visits: Optional[List[str]] = None
69
+
70
+ def to_dict(self) -> Dict[str, Any]:
71
+ """Convert to dictionary for API serialization."""
72
+ result = {
73
+ "id": self.id,
74
+ "location": list(self.location),
75
+ "shifts": [shift.to_dict() for shift in self.shifts],
76
+ "skills": self.skills,
77
+ }
78
+ if self.name is not None:
79
+ result["name"] = self.name
80
+ if self.departure_time is not None:
81
+ result["departureTime"] = self.departure_time
82
+ if self.visits is not None:
83
+ result["visits"] = self.visits
84
+ return result
85
+
86
+ @classmethod
87
+ def from_dict(cls, data: Dict[str, Any]) -> "Vehicle":
88
+ """Create from dictionary."""
89
+ return cls(
90
+ id=data["id"],
91
+ location=tuple(data["location"]),
92
+ shifts=[Shift.from_dict(s) for s in data["shifts"]],
93
+ skills=data.get("skills", []),
94
+ name=data.get("name"),
95
+ departure_time=data.get("departureTime"),
96
+ visits=data.get("visits"),
97
+ )
98
+
99
+
100
+ @dataclass
101
+ class Visit:
102
+ """Represents a visit (customer appointment) for optimization."""
103
+
104
+ id: str
105
+ name: str
106
+ location: Tuple[float, float] # [latitude, longitude]
107
+ time_windows: List[TimeWindow]
108
+ service_duration: str # ISO 8601 duration string
109
+ priority: Literal["HIGH", "MEDIUM", "LOW"]
110
+ required_skills: List[str] = field(default_factory=list)
111
+ pinned: bool = False
112
+ vehicle: Optional[str] = None
113
+ arrival_time: Optional[str] = None
114
+ departure_time: Optional[str] = None
115
+ start_service_time: Optional[str] = None
116
+ driving_time_seconds_from_previous_standstill: Optional[int] = None
117
+
118
+ def to_dict(self) -> Dict[str, Any]:
119
+ """Convert to dictionary for API serialization."""
120
+ result = {
121
+ "id": self.id,
122
+ "name": self.name,
123
+ "location": list(self.location),
124
+ "timeWindows": [tw.to_dict() for tw in self.time_windows],
125
+ "serviceDuration": self.service_duration,
126
+ "priority": self.priority,
127
+ "requiredSkills": self.required_skills,
128
+ "pinned": self.pinned,
129
+ }
130
+ if self.vehicle is not None:
131
+ result["vehicle"] = self.vehicle
132
+ if self.arrival_time is not None:
133
+ result["arrivalTime"] = self.arrival_time
134
+ if self.departure_time is not None:
135
+ result["departureTime"] = self.departure_time
136
+ if self.start_service_time is not None:
137
+ result["startServiceTime"] = self.start_service_time
138
+ if self.driving_time_seconds_from_previous_standstill is not None:
139
+ result["drivingTimeSecondsFromPreviousStandstill"] = self.driving_time_seconds_from_previous_standstill
140
+ return result
141
+
142
+ @classmethod
143
+ def from_dict(cls, data: Dict[str, Any]) -> "Visit":
144
+ """Create from dictionary."""
145
+ return cls(
146
+ id=data["id"],
147
+ name=data["name"],
148
+ location=tuple(data["location"]),
149
+ time_windows=[TimeWindow.from_dict(tw) for tw in data["timeWindows"]],
150
+ service_duration=data["serviceDuration"],
151
+ priority=data["priority"],
152
+ required_skills=data.get("requiredSkills", []),
153
+ pinned=data.get("pinned", False),
154
+ vehicle=data.get("vehicle"),
155
+ arrival_time=data.get("arrivalTime"),
156
+ departure_time=data.get("departureTime"),
157
+ start_service_time=data.get("startServiceTime"),
158
+ driving_time_seconds_from_previous_standstill=data.get("drivingTimeSecondsFromPreviousStandstill"),
159
+ )
160
+
161
+
162
+ @dataclass
163
+ class SolverOptions:
164
+ """Solver options for field service optimization."""
165
+
166
+ spent_limit: Optional[str] = None # ISO-8601 duration format, e.g., "PT5M"
167
+ unimproved_spent_limit: Optional[str] = None # ISO-8601 duration format
168
+
169
+ def to_dict(self) -> Dict[str, Any]:
170
+ """Convert to dictionary for API serialization."""
171
+ result = {}
172
+ if self.spent_limit is not None:
173
+ result["spentLimit"] = self.spent_limit
174
+ if self.unimproved_spent_limit is not None:
175
+ result["unimprovedSpentLimit"] = self.unimproved_spent_limit
176
+ return result
177
+
178
+ @classmethod
179
+ def from_dict(cls, data: Dict[str, Any]) -> "SolverOptions":
180
+ """Create from dictionary."""
181
+ return cls(
182
+ spent_limit=data.get("spentLimit"),
183
+ unimproved_spent_limit=data.get("unimprovedSpentLimit"),
184
+ )
185
+
186
+
187
+ @dataclass
188
+ class Weights:
189
+ """Configuration weights for field service optimization constraints."""
190
+
191
+ pinned_visit_vehicle_assignment: Optional[ConstraintWeight] = None
192
+ pinned_visit_service_time: Optional[ConstraintWeight] = None
193
+ no_missing_skills: Optional[ConstraintWeight] = None
194
+ service_time_missing: Optional[ConstraintWeight] = None
195
+ vehicle_unassigned: Optional[ConstraintWeight] = None
196
+ prefer_high_priority: Optional[ConstraintWeight] = None
197
+ minimize_travel_time: Optional[ConstraintWeight] = None
198
+ prefer_using_idle_vehicles: Optional[ConstraintWeight] = None
199
+ minimize_early_arrival_wait: Optional[ConstraintWeight] = None
200
+ prefer_initial_vehicle_assignment: Optional[ConstraintWeight] = None
201
+ prefer_earlier_visit_dates: Optional[ConstraintWeight] = None
202
+
203
+ def to_dict(self) -> Dict[str, str]:
204
+ """Convert to dictionary for API serialization."""
205
+ result = {}
206
+ if self.pinned_visit_vehicle_assignment is not None:
207
+ result["pinnedVisitVehicleAssignment"] = str(
208
+ self.pinned_visit_vehicle_assignment
209
+ )
210
+ if self.pinned_visit_service_time is not None:
211
+ result["pinnedVisitServiceTime"] = str(self.pinned_visit_service_time)
212
+ if self.no_missing_skills is not None:
213
+ result["noMissingSkills"] = str(self.no_missing_skills)
214
+ if self.service_time_missing is not None:
215
+ result["serviceTimeMissing"] = str(self.service_time_missing)
216
+ if self.vehicle_unassigned is not None:
217
+ result["vehicleUnassigned"] = str(self.vehicle_unassigned)
218
+ if self.prefer_high_priority is not None:
219
+ result["preferHighPriority"] = str(self.prefer_high_priority)
220
+ if self.minimize_travel_time is not None:
221
+ result["minimizeTravelTime"] = str(self.minimize_travel_time)
222
+ if self.prefer_using_idle_vehicles is not None:
223
+ result["preferUsingIdleVehicles"] = str(self.prefer_using_idle_vehicles)
224
+ if self.minimize_early_arrival_wait is not None:
225
+ result["minimizeEarlyArrivalWait"] = str(self.minimize_early_arrival_wait)
226
+ if self.prefer_initial_vehicle_assignment is not None:
227
+ result["preferInitialVehicleAssignment"] = str(
228
+ self.prefer_initial_vehicle_assignment
229
+ )
230
+ if self.prefer_earlier_visit_dates is not None:
231
+ result["PreferEarlierVisitDates"] = str(self.prefer_earlier_visit_dates)
232
+ return result
233
+
234
+ @classmethod
235
+ def from_dict(cls, data: Dict[str, Any]) -> "Weights":
236
+ """Create from dictionary."""
237
+ from .constraint_weight import ConstraintWeight
238
+
239
+ def parse_weight(value: Any) -> Optional[ConstraintWeight]:
240
+ if value is None:
241
+ return None
242
+ if isinstance(value, ConstraintWeight):
243
+ return value
244
+ if isinstance(value, str):
245
+ return ConstraintWeight.try_parse(value)
246
+ return None
247
+
248
+ return cls(
249
+ pinned_visit_vehicle_assignment=parse_weight(
250
+ data.get("pinnedVisitVehicleAssignment")
251
+ ),
252
+ pinned_visit_service_time=parse_weight(data.get("pinnedVisitServiceTime")),
253
+ no_missing_skills=parse_weight(data.get("noMissingSkills")),
254
+ service_time_missing=parse_weight(data.get("serviceTimeMissing")),
255
+ vehicle_unassigned=parse_weight(data.get("vehicleUnassigned")),
256
+ prefer_high_priority=parse_weight(data.get("preferHighPriority")),
257
+ minimize_travel_time=parse_weight(data.get("minimizeTravelTime")),
258
+ prefer_using_idle_vehicles=parse_weight(
259
+ data.get("preferUsingIdleVehicles")
260
+ ),
261
+ minimize_early_arrival_wait=parse_weight(
262
+ data.get("minimizeEarlyArrivalWait")
263
+ ),
264
+ prefer_initial_vehicle_assignment=parse_weight(
265
+ data.get("preferInitialVehicleAssignment")
266
+ ),
267
+ prefer_earlier_visit_dates=parse_weight(
268
+ data.get("PreferEarlierVisitDates")
269
+ ),
270
+ )
271
+
272
+
273
+ @dataclass
274
+ class FieldServiceRequest:
275
+ """Request for field service optimization."""
276
+
277
+ vehicles: List[Vehicle]
278
+ visits: List[Visit]
279
+ weights: Optional[Weights] = None
280
+ options: Optional[SolverOptions] = None
281
+
282
+ def to_dict(self) -> Dict[str, Any]:
283
+ """Convert to dictionary for API serialization."""
284
+ result = {
285
+ "vehicles": [v.to_dict() for v in self.vehicles],
286
+ "visits": [v.to_dict() for v in self.visits],
287
+ }
288
+ if self.weights is not None:
289
+ weights_dict = self.weights.to_dict()
290
+ if weights_dict:
291
+ result["weights"] = weights_dict
292
+ if self.options is not None:
293
+ options_dict = self.options.to_dict()
294
+ if options_dict:
295
+ result["options"] = options_dict
296
+ return result
297
+
298
+ @classmethod
299
+ def from_dict(cls, data: Dict[str, Any]) -> "FieldServiceRequest":
300
+ """Create from dictionary."""
301
+ return cls(
302
+ vehicles=[Vehicle.from_dict(v) for v in data["vehicles"]],
303
+ visits=[Visit.from_dict(v) for v in data["visits"]],
304
+ weights=Weights.from_dict(data["weights"]) if data.get("weights") else None,
305
+ options=(
306
+ SolverOptions.from_dict(data["options"])
307
+ if data.get("options")
308
+ else None
309
+ ),
310
+ )
311
+
312
+
313
+ @dataclass
314
+ class ScheduledVehicle:
315
+ """Scheduled vehicle with assigned visits."""
316
+
317
+ id: str
318
+ location: Tuple[float, float]
319
+ shifts: List[Shift]
320
+ skills: List[str]
321
+ visits: List[str] # List of visit IDs
322
+ daily_return_times: Optional[Dict[str, Any]] = None
323
+ total_driving_time_seconds: Optional[int] = None
324
+ arrival_time: Optional[str] = None
325
+ departure_time: Optional[str] = None
326
+
327
+ @classmethod
328
+ def from_dict(cls, data: Dict[str, Any]) -> "ScheduledVehicle":
329
+ """Create from dictionary."""
330
+ return cls(
331
+ id=data["id"],
332
+ location=tuple(data["location"]),
333
+ shifts=[Shift.from_dict(s) for s in data["shifts"]],
334
+ skills=data.get("skills", []),
335
+ visits=data.get("visits", []),
336
+ daily_return_times=data.get("dailyReturnTimes"),
337
+ total_driving_time_seconds=data.get("totalDrivingTimeSeconds"),
338
+ arrival_time=data.get("arrivalTime"),
339
+ departure_time=data.get("departureTime"),
340
+ )
341
+
342
+
343
+ @dataclass
344
+ class ScheduledVisit:
345
+ """Scheduled visit with timing information."""
346
+
347
+ id: str
348
+ name: str
349
+ location: Tuple[float, float]
350
+ time_windows: List[TimeWindow]
351
+ service_duration: str
352
+ priority: Literal["HIGH", "MEDIUM", "LOW"]
353
+ required_skills: List[str]
354
+ vehicle: str
355
+ previous_visit: Optional[str]
356
+ arrival_time: str
357
+ departure_time: str
358
+ start_service_time: str
359
+ driving_time_seconds_from_previous_standstill: int
360
+
361
+ @classmethod
362
+ def from_dict(cls, data: Dict[str, Any]) -> "ScheduledVisit":
363
+ """Create from dictionary."""
364
+ return cls(
365
+ id=data["id"],
366
+ name=data["name"],
367
+ location=tuple(data["location"]),
368
+ time_windows=[TimeWindow.from_dict(tw) for tw in data["timeWindows"]],
369
+ service_duration=data["serviceDuration"],
370
+ priority=data["priority"],
371
+ required_skills=data.get("requiredSkills", []),
372
+ vehicle=data["vehicle"],
373
+ previous_visit=data.get("previousVisit"),
374
+ arrival_time=data["arrivalTime"],
375
+ departure_time=data["departureTime"],
376
+ start_service_time=data["startServiceTime"],
377
+ driving_time_seconds_from_previous_standstill=data[
378
+ "drivingTimeSecondsFromPreviousStandstill"
379
+ ],
380
+ )
381
+
382
+
383
+ @dataclass
384
+ class FieldServiceResponse:
385
+ """Response from starting a field service optimization."""
386
+
387
+ job_id: str
388
+ solver_job_id: str
389
+ result: str
390
+ error: Optional[str] = None
391
+
392
+ @classmethod
393
+ def from_dict(cls, data: Dict[str, Any]) -> "FieldServiceResponse":
394
+ """Create from dictionary."""
395
+ return cls(
396
+ job_id=data["jobId"],
397
+ solver_job_id=data["solverJobId"],
398
+ result=data["result"],
399
+ error=data.get("error"),
400
+ )
401
+
402
+
403
+ @dataclass
404
+ class FieldServiceResultResponse:
405
+ """Result response from a completed field service optimization."""
406
+
407
+ vehicles: List[ScheduledVehicle]
408
+ visits: List[ScheduledVisit]
409
+ score: Optional[str] = None
410
+ total_driving_time_seconds: int = 0
411
+ weights: Optional[Dict[str, Any]] = None
412
+
413
+ @classmethod
414
+ def from_dict(cls, data: Dict[str, Any]) -> "FieldServiceResultResponse":
415
+ """Create from dictionary."""
416
+ return cls(
417
+ vehicles=[
418
+ ScheduledVehicle.from_dict(v) for v in data.get("vehicles", [])
419
+ ],
420
+ visits=[ScheduledVisit.from_dict(v) for v in data.get("visits", [])],
421
+ score=data.get("score"),
422
+ total_driving_time_seconds=data.get("totalDrivingTimeSeconds", 0),
423
+ weights=data.get("weights"),
424
+ )
425
+
426
+
427
+
@@ -0,0 +1,28 @@
1
+ """Professional services optimization module."""
2
+
3
+ from .api_client import ProfessionalServicesApiClient
4
+ from .availability_time_span import AvailabilityTimeSpan
5
+ from .contract import Contract
6
+ from .models import (
7
+ Employee,
8
+ Task,
9
+ ProfessionalServicesRequest,
10
+ ScheduledEmployee,
11
+ ScheduledTask,
12
+ ProfessionalServicesResponse,
13
+ ProfessionalServicesResultResponse,
14
+ )
15
+
16
+ __all__ = [
17
+ "ProfessionalServicesApiClient",
18
+ "AvailabilityTimeSpan",
19
+ "Contract",
20
+ "Employee",
21
+ "Task",
22
+ "ProfessionalServicesRequest",
23
+ "ScheduledEmployee",
24
+ "ScheduledTask",
25
+ "ProfessionalServicesResponse",
26
+ "ProfessionalServicesResultResponse",
27
+ ]
28
+
@@ -0,0 +1,209 @@
1
+ """Professional services API client."""
2
+
3
+ import time
4
+ from typing import Any, Dict, Optional, Union
5
+
6
+ import requests
7
+
8
+ from ..routes import Routes
9
+ from ..solver_status import SOLVER_STATUS, SolverStatusResponse
10
+ from .models import (
11
+ ProfessionalServicesRequest,
12
+ ProfessionalServicesResponse,
13
+ ProfessionalServicesResultResponse,
14
+ )
15
+
16
+
17
+ class ProfessionalServicesApiClient:
18
+ """Client for professional services optimization API."""
19
+
20
+ def __init__(self, api_key: Optional[str] = None):
21
+ """
22
+ Initialize the professional services API client.
23
+
24
+ Args:
25
+ api_key: Optional API key for authenticated requests
26
+ """
27
+ self.base_url = f"https://plansolve.app{Routes.PROFESSIONAL_SERVICES_SOLVE}"
28
+ self.api_key = api_key
29
+
30
+ def _get_headers(self) -> Dict[str, str]:
31
+ """Get request headers with optional API key."""
32
+ headers = {"Content-Type": "application/json"}
33
+ if self.api_key:
34
+ headers["X-API-KEY"] = self.api_key
35
+ return headers
36
+
37
+ def start(
38
+ self, request: Union[ProfessionalServicesRequest, Dict[str, Any]]
39
+ ) -> ProfessionalServicesResponse:
40
+ """
41
+ Start a new professional services optimization.
42
+
43
+ Args:
44
+ request: Professional services request (ProfessionalServicesRequest dataclass or dict)
45
+
46
+ Returns:
47
+ ProfessionalServicesResponse with jobId, solverJobId, result, and error
48
+
49
+ Raises:
50
+ requests.RequestException: If the API request fails
51
+ """
52
+ # Convert dataclass to dict if needed
53
+ if isinstance(request, ProfessionalServicesRequest):
54
+ api_request = request.to_dict()
55
+ else:
56
+ api_request = request
57
+
58
+ response = requests.post(
59
+ self.base_url, json=api_request, headers=self._get_headers()
60
+ )
61
+ response.raise_for_status()
62
+ return ProfessionalServicesResponse.from_dict(response.json())
63
+
64
+ def get_result(self, job_id: str) -> ProfessionalServicesResultResponse:
65
+ """
66
+ Get the completed optimization result.
67
+
68
+ Args:
69
+ job_id: The job ID to get results for
70
+
71
+ Returns:
72
+ ProfessionalServicesResultResponse with employees and tasks
73
+
74
+ Raises:
75
+ requests.RequestException: If the API request fails
76
+ """
77
+ url = f"{self.base_url}/{job_id}"
78
+ headers = {}
79
+ if self.api_key:
80
+ headers["X-API-KEY"] = self.api_key
81
+ response = requests.get(url, headers=headers)
82
+ response.raise_for_status()
83
+ return ProfessionalServicesResultResponse.from_dict(response.json())
84
+
85
+ def get_status(self, job_id: str) -> SolverStatusResponse:
86
+ """
87
+ Get the status of a running optimization job.
88
+
89
+ Args:
90
+ job_id: The job ID to check status for
91
+
92
+ Returns:
93
+ Status response dictionary
94
+
95
+ Raises:
96
+ requests.RequestException: If the API request fails
97
+ """
98
+ url = f"{self.base_url}/{job_id}/status"
99
+ headers = {}
100
+ if self.api_key:
101
+ headers["X-API-KEY"] = self.api_key
102
+ response = requests.get(url, headers=headers)
103
+ response.raise_for_status()
104
+ return response.json()
105
+
106
+ def analyze(self, job_id: str) -> Dict[str, Any]:
107
+ """
108
+ Get analysis for a completed optimization job.
109
+
110
+ Args:
111
+ job_id: The job ID to get analysis for
112
+
113
+ Returns:
114
+ Analysis response dictionary
115
+
116
+ Raises:
117
+ requests.RequestException: If the API request fails
118
+ """
119
+ url = f"{self.base_url}/{job_id}/analyze"
120
+ headers = {}
121
+ if self.api_key:
122
+ headers["X-API-KEY"] = self.api_key
123
+ response = requests.get(url, headers=headers)
124
+ response.raise_for_status()
125
+ return response.json()
126
+
127
+ def start_and_wait_for_completion(
128
+ self,
129
+ request: Union[ProfessionalServicesRequest, Dict[str, Any]],
130
+ poll_interval_ms: int = 5000,
131
+ max_attempts: int = 1000,
132
+ ) -> ProfessionalServicesResultResponse:
133
+ """
134
+ Start a job and wait for completion, polling for status.
135
+
136
+ Args:
137
+ request: Professional services request (ProfessionalServicesRequest dataclass or dict)
138
+ poll_interval_ms: Milliseconds to wait between status checks
139
+ max_attempts: Maximum number of polling attempts
140
+
141
+ Returns:
142
+ ProfessionalServicesResultResponse with optimization results
143
+
144
+ Raises:
145
+ ValueError: If jobId is not returned or solver times out
146
+ requests.RequestException: If API requests fail
147
+ """
148
+ start_response = self.start(request)
149
+ return self.wait_for_completion(start_response.job_id, poll_interval_ms, max_attempts)
150
+
151
+ def wait_for_completion(
152
+ self,
153
+ job_id: str,
154
+ poll_interval_ms: int = 5000,
155
+ max_attempts: int = 1000,
156
+ ) -> ProfessionalServicesResultResponse:
157
+ """
158
+ Wait for a job to complete, polling for status.
159
+ Matches the logic used in the .NET SDK implementation exactly.
160
+
161
+ Args:
162
+ job_id: The job ID to wait for
163
+ poll_interval_ms: Milliseconds to wait between status checks
164
+ max_attempts: Maximum number of polling attempts
165
+
166
+ Returns:
167
+ ProfessionalServicesResultResponse with optimization results
168
+
169
+ Raises:
170
+ ValueError: If jobId is not provided or solver times out
171
+ requests.RequestException: If API requests fail
172
+ """
173
+ if not job_id:
174
+ raise ValueError("JobId was not returned from wait_for_completion.")
175
+
176
+ attempts = 0
177
+ status: SolverStatusResponse
178
+
179
+ while True:
180
+ time.sleep(poll_interval_ms / 1000.0) # Convert ms to seconds
181
+ status = self.get_status(job_id)
182
+ attempts += 1
183
+
184
+ if not self._is_still_solving(status) or attempts >= max_attempts:
185
+ break
186
+
187
+ if self._is_still_solving(status):
188
+ raise ValueError("Solver did not finish in the allotted time.")
189
+
190
+ return self.get_result(job_id)
191
+
192
+ def _is_still_solving(self, status: SolverStatusResponse) -> bool:
193
+ """
194
+ Determine if the solver is still running based on the status response.
195
+ Matches the logic used in the .NET SDK implementation exactly.
196
+
197
+ Args:
198
+ status: Status response dictionary
199
+
200
+ Returns:
201
+ True if still solving, False otherwise
202
+ """
203
+ return (
204
+ status.get("solving", False)
205
+ or status.get("solverStatus") == SOLVER_STATUS["SOLVING_SCHEDULED"]
206
+ or status.get("solverStatus") != SOLVER_STATUS["NOT_SOLVING"]
207
+ or not status.get("score")
208
+ )
209
+
@@ -0,0 +1,31 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass
6
+ class AvailabilityTimeSpan:
7
+ id: Optional[str] = None
8
+ start: Optional[str] = None
9
+ end: Optional[str] = None
10
+ type: Optional[str] = None
11
+
12
+ def to_dict(self) -> dict:
13
+ result = {}
14
+ if self.id is not None:
15
+ result["id"] = self.id
16
+ if self.start is not None:
17
+ result["start"] = self.start
18
+ if self.end is not None:
19
+ result["end"] = self.end
20
+ if self.type is not None:
21
+ result["type"] = self.type
22
+ return result
23
+
24
+ @classmethod
25
+ def from_dict(cls, data: dict) -> "AvailabilityTimeSpan":
26
+ return cls(
27
+ id=data.get("id"),
28
+ start=data.get("start"),
29
+ end=data.get("end"),
30
+ type=data.get("type"),
31
+ )