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.
plansolve/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """
2
+ PlanSolve Python SDK
3
+
4
+ A Python client library for the PlanSolve optimization API.
5
+ """
6
+
7
+ from .client import PlanSolveClient
8
+ from .field_service.constraint_weight import ConstraintWeight
9
+ from .field_service.api_client import FieldServiceApiClient
10
+ from .professional_services.api_client import ProfessionalServicesApiClient
11
+
12
+ # Field Service Models
13
+ from .field_service.models import (
14
+ Shift,
15
+ TimeWindow,
16
+ Vehicle,
17
+ Visit,
18
+ SolverOptions,
19
+ Weights,
20
+ FieldServiceRequest,
21
+ ScheduledVehicle,
22
+ ScheduledVisit,
23
+ FieldServiceResponse,
24
+ FieldServiceResultResponse,
25
+ )
26
+
27
+ # Professional Services Models
28
+ from .professional_services.models import (
29
+ Employee,
30
+ Task,
31
+ ProfessionalServicesRequest,
32
+ ScheduledEmployee,
33
+ ScheduledTask,
34
+ ProfessionalServicesResponse,
35
+ ProfessionalServicesResultResponse,
36
+ )
37
+
38
+ __version__ = "0.25.0"
39
+
40
+ __all__ = [
41
+ "PlanSolveClient",
42
+ "FieldServiceApiClient",
43
+ "ProfessionalServicesApiClient",
44
+ "ConstraintWeight",
45
+ # Field Service Models
46
+ "Shift",
47
+ "TimeWindow",
48
+ "Vehicle",
49
+ "Visit",
50
+ "SolverOptions",
51
+ "Weights",
52
+ "FieldServiceRequest",
53
+ "ScheduledVehicle",
54
+ "ScheduledVisit",
55
+ "FieldServiceResponse",
56
+ "FieldServiceResultResponse",
57
+ # Professional Services Models
58
+ "Employee",
59
+ "Task",
60
+ "ProfessionalServicesRequest",
61
+ "ScheduledEmployee",
62
+ "ScheduledTask",
63
+ "ProfessionalServicesResponse",
64
+ "ProfessionalServicesResultResponse",
65
+ ]
66
+
plansolve/client.py ADDED
@@ -0,0 +1,40 @@
1
+ """Main PlanSolve client class."""
2
+
3
+ from typing import Optional
4
+
5
+ from .field_service.api_client import FieldServiceApiClient
6
+ from .professional_services.api_client import ProfessionalServicesApiClient
7
+ from .shift.api_client import ShiftApiClient
8
+
9
+
10
+ class PlanSolveClient:
11
+ """Main client class for interacting with the PlanSolve API."""
12
+
13
+ def __init__(self, api_key: Optional[str] = None):
14
+ """
15
+ Initialize the PlanSolve client.
16
+
17
+ Args:
18
+ api_key: Optional API key for authenticated requests
19
+ """
20
+ self.field_service = FieldServiceApiClient(api_key)
21
+ self.professional_services = ProfessionalServicesApiClient(api_key)
22
+ self.shift = ShiftApiClient(api_key)
23
+
24
+ def get_status(self, job_id: str) -> dict:
25
+ """
26
+ Get the status of a running optimization job.
27
+
28
+ Args:
29
+ job_id: The job ID to check status for
30
+
31
+ Returns:
32
+ Status response dictionary
33
+
34
+ Note:
35
+ This method uses the field service endpoint for status checking.
36
+ For professional services, use professional_services.get_status() instead.
37
+ """
38
+ return self.field_service.get_status(job_id)
39
+
40
+
@@ -0,0 +1,34 @@
1
+ """Field service optimization module."""
2
+
3
+ from .api_client import FieldServiceApiClient
4
+ from .constraint_weight import ConstraintWeight
5
+ from .models import (
6
+ Shift,
7
+ TimeWindow,
8
+ Vehicle,
9
+ Visit,
10
+ SolverOptions,
11
+ Weights,
12
+ FieldServiceRequest,
13
+ ScheduledVehicle,
14
+ ScheduledVisit,
15
+ FieldServiceResponse,
16
+ FieldServiceResultResponse,
17
+ )
18
+
19
+ __all__ = [
20
+ "FieldServiceApiClient",
21
+ "ConstraintWeight",
22
+ "Shift",
23
+ "TimeWindow",
24
+ "Vehicle",
25
+ "Visit",
26
+ "SolverOptions",
27
+ "Weights",
28
+ "FieldServiceRequest",
29
+ "ScheduledVehicle",
30
+ "ScheduledVisit",
31
+ "FieldServiceResponse",
32
+ "FieldServiceResultResponse",
33
+ ]
34
+
@@ -0,0 +1,212 @@
1
+ """Field service 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
+ FieldServiceRequest,
12
+ FieldServiceResponse,
13
+ FieldServiceResultResponse,
14
+ )
15
+
16
+
17
+ class FieldServiceApiClient:
18
+ """Client for field service optimization API."""
19
+
20
+ def __init__(self, api_key: Optional[str] = None):
21
+ """
22
+ Initialize the field service API client.
23
+
24
+ Args:
25
+ api_key: Optional API key for authenticated requests
26
+ """
27
+ self.base_url = f"https://plansolve.app{Routes.FIELD_SERVICE_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[FieldServiceRequest, Dict[str, Any]]
39
+ ) -> FieldServiceResponse:
40
+ """
41
+ Start a new field service optimization.
42
+
43
+ Args:
44
+ request: Field service request (FieldServiceRequest dataclass or dict)
45
+
46
+ Returns:
47
+ FieldServiceResponse 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, FieldServiceRequest):
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 FieldServiceResponse.from_dict(response.json())
63
+
64
+ def get_result(self, job_id: str) -> FieldServiceResultResponse:
65
+ """
66
+ Get the completed optimization result.
67
+
68
+ Args:
69
+ job_id: The job ID to get results for
70
+
71
+ Returns:
72
+ FieldServiceResultResponse with vehicles, visits, score, etc.
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 FieldServiceResultResponse.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[FieldServiceRequest, Dict[str, Any]],
130
+ poll_interval_ms: int = 5000,
131
+ max_attempts: int = 10,
132
+ ) -> FieldServiceResultResponse:
133
+ """
134
+ Start a job and wait for completion, polling for status.
135
+ Matches the logic used in the .NET SDK implementation exactly.
136
+
137
+ Args:
138
+ request: Field service request (FieldServiceRequest dataclass or dict)
139
+ poll_interval_ms: Milliseconds to wait between status checks
140
+ max_attempts: Maximum number of polling attempts
141
+
142
+ Returns:
143
+ FieldServiceResultResponse with optimization results
144
+
145
+ Raises:
146
+ ValueError: If jobId is not returned or solver times out
147
+ requests.RequestException: If API requests fail
148
+ """
149
+ start_response = self.start(request)
150
+ return self.wait_for_completion(start_response.job_id, poll_interval_ms, max_attempts)
151
+
152
+ def wait_for_completion(
153
+ self,
154
+ job_id: str,
155
+ poll_interval_ms: int = 5000,
156
+ max_attempts: int = 10,
157
+ ) -> FieldServiceResultResponse:
158
+ """
159
+ Wait for a job to complete, polling for status.
160
+ Matches the logic used in the .NET SDK implementation exactly.
161
+
162
+ Args:
163
+ job_id: The job ID to wait for
164
+ poll_interval_ms: Milliseconds to wait between status checks
165
+ max_attempts: Maximum number of polling attempts
166
+
167
+ Returns:
168
+ FieldServiceResultResponse with optimization results
169
+
170
+ Raises:
171
+ ValueError: If jobId is not provided or solver times out
172
+ requests.RequestException: If API requests fail
173
+ """
174
+ if not job_id:
175
+ raise ValueError("JobId was not returned from wait_for_completion.")
176
+
177
+ attempts = 0
178
+ status: SolverStatusResponse
179
+
180
+ while True:
181
+ time.sleep(poll_interval_ms / 1000.0) # Convert ms to seconds
182
+ status = self.get_status(job_id)
183
+ attempts += 1
184
+
185
+ if not self._is_still_solving(status) or attempts >= max_attempts:
186
+ break
187
+
188
+ if self._is_still_solving(status):
189
+ raise ValueError("Solver did not finish in the allotted time.")
190
+
191
+ return self.get_result(job_id)
192
+
193
+ def _is_still_solving(self, status: SolverStatusResponse) -> bool:
194
+ """
195
+ Determine if the solver is still running based on the status response.
196
+ Matches the logic used in the .NET SDK implementation exactly.
197
+
198
+ Args:
199
+ status: Status response dictionary
200
+
201
+ Returns:
202
+ True if still solving, False otherwise
203
+ """
204
+ # Match .NET logic: status.Solving || status.SolverStatus == SolverStatus.SOLVING_SCHEDULED ||
205
+ # (status.SolverStatus != SolverStatus.NOT_SOLVING) || string.IsNullOrEmpty(status.Score)
206
+ return (
207
+ status.get("solving", False)
208
+ or status.get("solverStatus") == SOLVER_STATUS["SOLVING_SCHEDULED"]
209
+ or status.get("solverStatus") != SOLVER_STATUS["NOT_SOLVING"]
210
+ or not status.get("score")
211
+ )
212
+
@@ -0,0 +1,143 @@
1
+ """Constraint weight representation for field service optimization."""
2
+
3
+ import re
4
+ from typing import Optional
5
+
6
+
7
+ class ConstraintWeight:
8
+ """
9
+ Represents a constraint weight in the format "Xhard/Ymedium/Zsoft" where X, Y, Z are numeric values.
10
+ Only one of hard, medium, or soft can be non-zero at a time.
11
+ Examples: "1hard/0medium/0soft", "0hard/1medium/0soft", "0hard/0medium/1soft"
12
+ """
13
+
14
+ def __init__(self, hard: int, medium: int, soft: int):
15
+ """
16
+ Create a new ConstraintWeight with the specified values.
17
+ Only one of hard, medium, or soft can be non-zero.
18
+
19
+ Args:
20
+ hard: Hard constraint weight value
21
+ medium: Medium constraint weight value
22
+ soft: Soft constraint weight value
23
+
24
+ Raises:
25
+ ValueError: When more than one value is non-zero
26
+ """
27
+ non_zero_count = sum(1 for x in [hard, medium, soft] if x != 0)
28
+ if non_zero_count > 1:
29
+ raise ValueError(
30
+ f"Only one of hard, medium, or soft can be non-zero. "
31
+ f"Received: hard={hard}, medium={medium}, soft={soft}"
32
+ )
33
+
34
+ self.hard = hard
35
+ self.medium = medium
36
+ self.soft = soft
37
+
38
+ @staticmethod
39
+ def parse(value: str) -> "ConstraintWeight":
40
+ """
41
+ Parse a weight string in the format "Xhard/Ymedium/Zsoft".
42
+ Only one of X, Y, or Z can be non-zero.
43
+
44
+ Args:
45
+ value: The weight string to parse
46
+
47
+ Returns:
48
+ A ConstraintWeight instance
49
+
50
+ Raises:
51
+ ValueError: When the string format is invalid or more than one value is non-zero
52
+ """
53
+ if not value or not value.strip():
54
+ raise ValueError("Weight value cannot be null or empty")
55
+
56
+ # Pattern: (\d+)hard/(\d+)medium/(\d+)soft
57
+ pattern = r"^(\d+)hard/(\d+)medium/(\d+)soft$"
58
+ match = re.match(pattern, value, re.IGNORECASE)
59
+
60
+ if not match:
61
+ raise ValueError(
62
+ f"Invalid weight format: '{value}'. Expected format: 'Xhard/Ymedium/Zsoft'"
63
+ )
64
+
65
+ hard = int(match.group(1))
66
+ medium = int(match.group(2))
67
+ soft = int(match.group(3))
68
+
69
+ return ConstraintWeight(hard, medium, soft)
70
+
71
+ @staticmethod
72
+ def try_parse(value: Optional[str]) -> Optional["ConstraintWeight"]:
73
+ """
74
+ Attempt to parse a weight string. Returns the ConstraintWeight if successful, None otherwise.
75
+
76
+ Args:
77
+ value: The weight string to parse, or None
78
+
79
+ Returns:
80
+ ConstraintWeight instance if successful, None otherwise
81
+ """
82
+ if not value or not value.strip():
83
+ return None
84
+
85
+ try:
86
+ return ConstraintWeight.parse(value)
87
+ except (ValueError, AttributeError):
88
+ return None
89
+
90
+ def __str__(self) -> str:
91
+ """
92
+ Return the string representation in the format "Xhard/Ymedium/Zsoft".
93
+
94
+ Returns:
95
+ String representation of the weight
96
+ """
97
+ return f"{self.hard}hard/{self.medium}medium/{self.soft}soft"
98
+
99
+ def __repr__(self) -> str:
100
+ """Return a string representation of the ConstraintWeight."""
101
+ return f"ConstraintWeight(hard={self.hard}, medium={self.medium}, soft={self.soft})"
102
+
103
+ @staticmethod
104
+ def create_hard(value: int) -> "ConstraintWeight":
105
+ """
106
+ Create a hard constraint weight (Xhard/0medium/0soft).
107
+
108
+ Args:
109
+ value: The hard constraint value
110
+
111
+ Returns:
112
+ ConstraintWeight instance
113
+ """
114
+ return ConstraintWeight(value, 0, 0)
115
+
116
+ @staticmethod
117
+ def create_medium(value: int) -> "ConstraintWeight":
118
+ """
119
+ Create a medium constraint weight (0hard/Xmedium/0soft).
120
+
121
+ Args:
122
+ value: The medium constraint value
123
+
124
+ Returns:
125
+ ConstraintWeight instance
126
+ """
127
+ return ConstraintWeight(0, value, 0)
128
+
129
+ @staticmethod
130
+ def create_soft(value: int) -> "ConstraintWeight":
131
+ """
132
+ Create a soft constraint weight (0hard/0medium/Xsoft).
133
+
134
+ Args:
135
+ value: The soft constraint value
136
+
137
+ Returns:
138
+ ConstraintWeight instance
139
+ """
140
+ return ConstraintWeight(0, 0, value)
141
+
142
+
143
+