open-pharma-plugins 2.2.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.
- mcp_framework.py +495 -0
- open_pharma_plugins-2.2.0.dist-info/METADATA +135 -0
- open_pharma_plugins-2.2.0.dist-info/RECORD +136 -0
- open_pharma_plugins-2.2.0.dist-info/WHEEL +5 -0
- open_pharma_plugins-2.2.0.dist-info/entry_points.txt +8 -0
- open_pharma_plugins-2.2.0.dist-info/licenses/LICENSE +202 -0
- open_pharma_plugins-2.2.0.dist-info/top_level.txt +8 -0
- open_pharma_plugins_campaign_studio/__init__.py +14 -0
- open_pharma_plugins_campaign_studio/__main__.py +11 -0
- open_pharma_plugins_campaign_studio/_campaign_store.py +162 -0
- open_pharma_plugins_campaign_studio/_claim_engine.py +262 -0
- open_pharma_plugins_campaign_studio/_renderer.py +119 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/legal.json +21 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/logo.svg +4 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/palette.json +11 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/product.png +1 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/typography.json +14 -0
- open_pharma_plugins_campaign_studio/fixtures/sample_approved_claims.json +119 -0
- open_pharma_plugins_campaign_studio/models/__init__.py +34 -0
- open_pharma_plugins_campaign_studio/models/_common.py +12 -0
- open_pharma_plugins_campaign_studio/models/brief.py +72 -0
- open_pharma_plugins_campaign_studio/models/claims.py +15 -0
- open_pharma_plugins_campaign_studio/models/copy.py +47 -0
- open_pharma_plugins_campaign_studio/models/journey.py +21 -0
- open_pharma_plugins_campaign_studio/models/message.py +25 -0
- open_pharma_plugins_campaign_studio/models/mlr.py +29 -0
- open_pharma_plugins_campaign_studio/models/validation.py +32 -0
- open_pharma_plugins_campaign_studio/policy/rules.json +79 -0
- open_pharma_plugins_campaign_studio/templates/banner.svg.j2 +26 -0
- open_pharma_plugins_campaign_studio/templates/email.html.j2 +54 -0
- open_pharma_plugins_campaign_studio/tools/__init__.py +0 -0
- open_pharma_plugins_campaign_studio/tools/create_campaign_brief.py +212 -0
- open_pharma_plugins_campaign_studio/tools/generate_audience_journey.py +129 -0
- open_pharma_plugins_campaign_studio/tools/generate_channel_copy.py +199 -0
- open_pharma_plugins_campaign_studio/tools/generate_message_architecture.py +121 -0
- open_pharma_plugins_campaign_studio/tools/package_mlr_submission.py +230 -0
- open_pharma_plugins_campaign_studio/tools/render_banner.py +99 -0
- open_pharma_plugins_campaign_studio/tools/render_email.py +101 -0
- open_pharma_plugins_campaign_studio/tools/render_poster.py +222 -0
- open_pharma_plugins_campaign_studio/tools/retrieve_approved_claims.py +71 -0
- open_pharma_plugins_campaign_studio/tools/retrieve_brand_components.py +76 -0
- open_pharma_plugins_campaign_studio/tools/validate_claims_and_fair_balance.py +285 -0
- open_pharma_plugins_competitive_intelligence/__init__.py +13 -0
- open_pharma_plugins_competitive_intelligence/__main__.py +11 -0
- open_pharma_plugins_competitive_intelligence/_artifacts.py +87 -0
- open_pharma_plugins_competitive_intelligence/_cache.py +144 -0
- open_pharma_plugins_competitive_intelligence/_clinical_trials.py +569 -0
- open_pharma_plugins_competitive_intelligence/_dailymed.py +260 -0
- open_pharma_plugins_competitive_intelligence/_fda.py +255 -0
- open_pharma_plugins_competitive_intelligence/_pubmed.py +342 -0
- open_pharma_plugins_competitive_intelligence/_regulatory.py +140 -0
- open_pharma_plugins_competitive_intelligence/_runs.py +278 -0
- open_pharma_plugins_competitive_intelligence/_transport.py +83 -0
- open_pharma_plugins_competitive_intelligence/_watchlist.py +113 -0
- open_pharma_plugins_competitive_intelligence/_web_search.py +331 -0
- open_pharma_plugins_competitive_intelligence/models.py +525 -0
- open_pharma_plugins_competitive_intelligence/tools/__init__.py +0 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_extract_events.py +247 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_landscape.py +195 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_refresh.py +101 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_report.py +402 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_news.py +48 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_publications.py +46 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_regulatory.py +64 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_trials.py +85 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_status.py +106 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_timeline.py +453 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_track.py +121 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_trial_detail.py +55 -0
- open_pharma_plugins_field_training/__init__.py +13 -0
- open_pharma_plugins_field_training/__main__.py +11 -0
- open_pharma_plugins_field_training/_content_store.py +131 -0
- open_pharma_plugins_field_training/_grounding.py +75 -0
- open_pharma_plugins_field_training/_html_renderers.py +546 -0
- open_pharma_plugins_field_training/fixtures/sample_product_message.pdf +156 -0
- open_pharma_plugins_field_training/fixtures/sample_training_deck.pptx +0 -0
- open_pharma_plugins_field_training/models.py +265 -0
- open_pharma_plugins_field_training/tools/__init__.py +0 -0
- open_pharma_plugins_field_training/tools/get_document_page.py +67 -0
- open_pharma_plugins_field_training/tools/ingest_document.py +147 -0
- open_pharma_plugins_field_training/tools/list_documents.py +51 -0
- open_pharma_plugins_field_training/tools/render_output.py +118 -0
- open_pharma_plugins_field_training/tools/search_content.py +57 -0
- open_pharma_plugins_hcp_intelligence/__init__.py +14 -0
- open_pharma_plugins_hcp_intelligence/__main__.py +11 -0
- open_pharma_plugins_hcp_intelligence/_crm_store.py +70 -0
- open_pharma_plugins_hcp_intelligence/batch.py +891 -0
- open_pharma_plugins_hcp_intelligence/batch_cli.py +221 -0
- open_pharma_plugins_hcp_intelligence/batch_csv.py +206 -0
- open_pharma_plugins_hcp_intelligence/fixtures/sample_accounts.csv +27 -0
- open_pharma_plugins_hcp_intelligence/models.py +356 -0
- open_pharma_plugins_hcp_intelligence/tools/__init__.py +0 -0
- open_pharma_plugins_hcp_intelligence/tools/get_account.py +48 -0
- open_pharma_plugins_hcp_intelligence/tools/list_accounts.py +66 -0
- open_pharma_plugins_hcp_intelligence/tools/search_clinical_trials.py +175 -0
- open_pharma_plugins_hcp_intelligence/tools/search_congresses.py +134 -0
- open_pharma_plugins_hcp_intelligence/tools/search_grants.py +181 -0
- open_pharma_plugins_hcp_intelligence/tools/search_guidelines.py +238 -0
- open_pharma_plugins_hcp_intelligence/tools/search_hco_web.py +73 -0
- open_pharma_plugins_hcp_intelligence/tools/search_hcp_web.py +199 -0
- open_pharma_plugins_hcp_intelligence/tools/search_orcid.py +214 -0
- open_pharma_plugins_hcp_intelligence/tools/search_publications.py +207 -0
- open_pharma_plugins_hcp_intelligence/tools/update_account.py +84 -0
- open_pharma_plugins_next_best_engagement/__init__.py +14 -0
- open_pharma_plugins_next_best_engagement/__main__.py +11 -0
- open_pharma_plugins_next_best_engagement/_optimizer.py +400 -0
- open_pharma_plugins_next_best_engagement/_renderer.py +149 -0
- open_pharma_plugins_next_best_engagement/_scoring.py +82 -0
- open_pharma_plugins_next_best_engagement/_universe.py +145 -0
- open_pharma_plugins_next_best_engagement/fixtures/sample_universe.csv +81 -0
- open_pharma_plugins_next_best_engagement/models.py +135 -0
- open_pharma_plugins_next_best_engagement/tools/__init__.py +0 -0
- open_pharma_plugins_next_best_engagement/tools/load_universe.py +47 -0
- open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +80 -0
- open_pharma_plugins_next_best_engagement/tools/render_plan.py +90 -0
- open_pharma_plugins_territory_alignment/__init__.py +14 -0
- open_pharma_plugins_territory_alignment/__main__.py +11 -0
- open_pharma_plugins_territory_alignment/data.py +300 -0
- open_pharma_plugins_territory_alignment/fixtures/constraints.csv +11 -0
- open_pharma_plugins_territory_alignment/fixtures/current_alignment.csv +81 -0
- open_pharma_plugins_territory_alignment/fixtures/hcps.csv +81 -0
- open_pharma_plugins_territory_alignment/fixtures/reps.csv +9 -0
- open_pharma_plugins_territory_alignment/geo.py +175 -0
- open_pharma_plugins_territory_alignment/models.py +201 -0
- open_pharma_plugins_territory_alignment/scoring.py +125 -0
- open_pharma_plugins_territory_alignment/solver.py +504 -0
- open_pharma_plugins_territory_alignment/tools/__init__.py +0 -0
- open_pharma_plugins_territory_alignment/tools/ta_align.py +138 -0
- open_pharma_plugins_territory_alignment/tools/ta_cluster.py +247 -0
- open_pharma_plugins_territory_alignment/tools/ta_compare.py +173 -0
- open_pharma_plugins_territory_alignment/tools/ta_evaluate.py +119 -0
- open_pharma_plugins_territory_alignment/tools/ta_status.py +34 -0
- open_pharma_plugins_territory_alignment/tools/ta_visualize.py +504 -0
- shared/__init__.py +11 -0
- shared/env.py +217 -0
- shared/filesystem.py +110 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from mcp_framework import build_registry
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.1"
|
|
4
|
+
|
|
5
|
+
SPECS, get_handler, list_tools = build_registry(__name__, ["tools"])
|
|
6
|
+
|
|
7
|
+
USAGE_NOTE = (
|
|
8
|
+
"Territory Alignment assigns HCPs to representatives (strategic mode) "
|
|
9
|
+
"and plans visit routes (operational mode), balancing workload, travel, "
|
|
10
|
+
"relationship continuity, and priority coverage. Supports named scenario "
|
|
11
|
+
"comparison for what-if planning."
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
SYSTEM_DEPS = []
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Data loading, scenario persistence, and fixture fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from shared.filesystem import atomic_write_json, atomic_write_text, ensure_private_dir
|
|
12
|
+
|
|
13
|
+
from .models import HCP, Constraint, CurrentAssignment, NewHire, Rep
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _data_dir() -> Path:
|
|
17
|
+
from shared.env import get_env
|
|
18
|
+
|
|
19
|
+
custom = get_env("OPEN_PHARMA_TA_DATA_DIR", "")
|
|
20
|
+
if custom:
|
|
21
|
+
return Path(custom)
|
|
22
|
+
return Path(__file__).parent / "fixtures"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _scenarios_dir() -> Path:
|
|
26
|
+
from shared.env import get_env
|
|
27
|
+
|
|
28
|
+
custom = get_env("OPEN_PHARMA_TA_SCENARIOS_DIR", "")
|
|
29
|
+
if custom:
|
|
30
|
+
d = Path(custom)
|
|
31
|
+
else:
|
|
32
|
+
d = Path.home() / ".open-pharma-plugins" / "territory-alignment" / "scenarios"
|
|
33
|
+
return ensure_private_dir(d)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# -- state --
|
|
37
|
+
|
|
38
|
+
_hcps: list[HCP] = []
|
|
39
|
+
_reps: list[Rep] = []
|
|
40
|
+
_current_alignment: list[CurrentAssignment] = []
|
|
41
|
+
_constraints: list[Constraint] = []
|
|
42
|
+
_loaded: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_loaded() -> bool:
|
|
46
|
+
return _loaded
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_all() -> dict[str, Any]:
|
|
50
|
+
"""Load all CSV files from the data directory."""
|
|
51
|
+
global _hcps, _reps, _current_alignment, _constraints, _loaded
|
|
52
|
+
|
|
53
|
+
data_dir = _data_dir()
|
|
54
|
+
_hcps = _load_hcps(data_dir / "hcps.csv")
|
|
55
|
+
_reps = _load_reps(data_dir / "reps.csv")
|
|
56
|
+
_current_alignment = _load_alignment(data_dir / "current_alignment.csv")
|
|
57
|
+
_constraints = _load_constraints(data_dir / "constraints.csv")
|
|
58
|
+
_loaded = True
|
|
59
|
+
|
|
60
|
+
return get_summary()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_hcps() -> list[HCP]:
|
|
64
|
+
return list(_hcps)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_reps(
|
|
68
|
+
vacancies: list[str] | None = None,
|
|
69
|
+
new_hires: list[NewHire] | None = None,
|
|
70
|
+
) -> list[Rep]:
|
|
71
|
+
"""Return reps with vacancies removed and new hires added."""
|
|
72
|
+
vacancy_set = set(vacancies or [])
|
|
73
|
+
result = [r for r in _reps if r.rep_id not in vacancy_set]
|
|
74
|
+
for nh in new_hires or []:
|
|
75
|
+
result.append(
|
|
76
|
+
Rep(
|
|
77
|
+
rep_id=nh.rep_id,
|
|
78
|
+
name=nh.name,
|
|
79
|
+
base_lat=nh.base_lat,
|
|
80
|
+
base_lng=nh.base_lng,
|
|
81
|
+
product_expertise=nh.product_expertise,
|
|
82
|
+
max_weekly_hours=nh.max_weekly_hours,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_current_alignment() -> list[CurrentAssignment]:
|
|
89
|
+
return list(_current_alignment)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_constraints() -> list[Constraint]:
|
|
93
|
+
return list(_constraints)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_summary() -> dict[str, Any]:
|
|
97
|
+
if not _loaded:
|
|
98
|
+
return {"loaded": False}
|
|
99
|
+
|
|
100
|
+
segments: dict[str, int] = {}
|
|
101
|
+
geo_count = 0
|
|
102
|
+
for h in _hcps:
|
|
103
|
+
segments[h.segment] = segments.get(h.segment, 0) + 1
|
|
104
|
+
if h.lat is not None and h.lng is not None:
|
|
105
|
+
geo_count += 1
|
|
106
|
+
|
|
107
|
+
assigned_hcps = {a.hcp_id for a in _current_alignment}
|
|
108
|
+
unassigned = [h.hcp_id for h in _hcps if h.hcp_id not in assigned_hcps]
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
"loaded": True,
|
|
112
|
+
"data_source": str(_data_dir()),
|
|
113
|
+
"hcp_count": len(_hcps),
|
|
114
|
+
"rep_count": len(_reps),
|
|
115
|
+
"alignment_count": len(_current_alignment),
|
|
116
|
+
"constraint_count": len(_constraints),
|
|
117
|
+
"segments": segments,
|
|
118
|
+
"geocoded_pct": round(geo_count / max(len(_hcps), 1) * 100, 1),
|
|
119
|
+
"unassigned_hcps": unassigned,
|
|
120
|
+
"scenarios": list_scenarios(),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# -- scenarios --
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _sanitize_scenario_name(name: str) -> str:
|
|
128
|
+
import re
|
|
129
|
+
|
|
130
|
+
clean = re.sub(r"[^a-zA-Z0-9_\-]", "_", name)
|
|
131
|
+
if not clean:
|
|
132
|
+
raise ValueError("scenario name must contain at least one alphanumeric character")
|
|
133
|
+
return clean
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def save_scenario(name: str, result: dict[str, Any]) -> str:
|
|
137
|
+
safe = _sanitize_scenario_name(name)
|
|
138
|
+
d = _scenarios_dir()
|
|
139
|
+
json_path = d / f"{safe}.json"
|
|
140
|
+
atomic_write_json(json_path, result)
|
|
141
|
+
_write_scenario_csvs(d, safe, result)
|
|
142
|
+
return str(json_path)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _write_scenario_csvs(directory: Path, name: str, result: dict[str, Any]) -> None:
|
|
146
|
+
assignments = result.get("assignments", [])
|
|
147
|
+
if assignments:
|
|
148
|
+
_ASSIGN_COLS = [
|
|
149
|
+
"hcp_id",
|
|
150
|
+
"hcp_name",
|
|
151
|
+
"primary_rep",
|
|
152
|
+
"previous_rep",
|
|
153
|
+
"is_changed",
|
|
154
|
+
"change_reason",
|
|
155
|
+
"estimated_travel_min",
|
|
156
|
+
"estimated_annual_visits",
|
|
157
|
+
"segment",
|
|
158
|
+
"tier",
|
|
159
|
+
]
|
|
160
|
+
a_path = directory / f"{name}_assignments.csv"
|
|
161
|
+
buffer = io.StringIO(newline="")
|
|
162
|
+
writer = csv.DictWriter(buffer, fieldnames=_ASSIGN_COLS, extrasaction="ignore")
|
|
163
|
+
writer.writeheader()
|
|
164
|
+
writer.writerows(assignments)
|
|
165
|
+
atomic_write_text(a_path, buffer.getvalue())
|
|
166
|
+
|
|
167
|
+
territories = result.get("territory_summary", [])
|
|
168
|
+
if territories:
|
|
169
|
+
_TERR_COLS = [
|
|
170
|
+
"rep_id",
|
|
171
|
+
"rep_name",
|
|
172
|
+
"hcp_count",
|
|
173
|
+
"total_potential",
|
|
174
|
+
"workload_hours_weekly",
|
|
175
|
+
"travel_hours_weekly",
|
|
176
|
+
"segment_high",
|
|
177
|
+
"segment_medium",
|
|
178
|
+
"segment_low",
|
|
179
|
+
"relationships_kept",
|
|
180
|
+
"relationships_new",
|
|
181
|
+
]
|
|
182
|
+
t_path = directory / f"{name}_territory_summary.csv"
|
|
183
|
+
buffer = io.StringIO(newline="")
|
|
184
|
+
writer = csv.DictWriter(buffer, fieldnames=_TERR_COLS, extrasaction="ignore")
|
|
185
|
+
writer.writeheader()
|
|
186
|
+
writer.writerows(territories)
|
|
187
|
+
atomic_write_text(t_path, buffer.getvalue())
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_scenario(name: str) -> dict[str, Any] | None:
|
|
191
|
+
path = _scenarios_dir() / f"{_sanitize_scenario_name(name)}.json"
|
|
192
|
+
if path.exists():
|
|
193
|
+
return json.loads(path.read_text())
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def list_scenarios() -> list[dict[str, str]]:
|
|
198
|
+
result = []
|
|
199
|
+
for f in sorted(_scenarios_dir().glob("*.json")):
|
|
200
|
+
result.append({"name": f.stem, "file": str(f)})
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# -- CSV loaders --
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _load_hcps(path: Path) -> list[HCP]:
|
|
208
|
+
if not path.exists():
|
|
209
|
+
return []
|
|
210
|
+
rows: list[HCP] = []
|
|
211
|
+
with open(path, newline="") as f:
|
|
212
|
+
for raw in csv.DictReader(f):
|
|
213
|
+
_coerce_hcp(raw)
|
|
214
|
+
rows.append(HCP.model_validate(raw))
|
|
215
|
+
return rows
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _coerce_hcp(raw: dict[str, Any]) -> None:
|
|
219
|
+
for field in ("lat", "lng", "annual_potential"):
|
|
220
|
+
if field in raw:
|
|
221
|
+
val = raw[field]
|
|
222
|
+
if not val:
|
|
223
|
+
raw[field] = None if field in ("lat", "lng") else 0.0
|
|
224
|
+
else:
|
|
225
|
+
raw[field] = float(val)
|
|
226
|
+
for field in ("consent_email", "consent_phone", "consent_visit"):
|
|
227
|
+
if field in raw:
|
|
228
|
+
raw[field] = raw[field].lower() in ("true", "1", "yes")
|
|
229
|
+
if "tier" in raw and raw["tier"]:
|
|
230
|
+
raw["tier"] = int(raw["tier"])
|
|
231
|
+
if "product_requirements" in raw:
|
|
232
|
+
val = raw["product_requirements"]
|
|
233
|
+
raw["product_requirements"] = [v.strip() for v in val.split(";")] if val else []
|
|
234
|
+
_coerce_extras(raw, _HCP_KNOWN_FIELDS)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
_HCP_KNOWN_FIELDS = set(HCP.model_fields.keys())
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _load_reps(path: Path) -> list[Rep]:
|
|
241
|
+
if not path.exists():
|
|
242
|
+
return []
|
|
243
|
+
rows: list[Rep] = []
|
|
244
|
+
with open(path, newline="") as f:
|
|
245
|
+
for raw in csv.DictReader(f):
|
|
246
|
+
_coerce_rep(raw)
|
|
247
|
+
rows.append(Rep.model_validate(raw))
|
|
248
|
+
return rows
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _coerce_rep(raw: dict[str, Any]) -> None:
|
|
252
|
+
for field in ("base_lat", "base_lng", "max_weekly_hours"):
|
|
253
|
+
if field in raw and raw[field]:
|
|
254
|
+
raw[field] = float(raw[field])
|
|
255
|
+
if "max_daily_calls" in raw and raw["max_daily_calls"]:
|
|
256
|
+
raw["max_daily_calls"] = int(raw["max_daily_calls"])
|
|
257
|
+
for list_field in ("product_expertise", "available_days"):
|
|
258
|
+
if list_field in raw:
|
|
259
|
+
val = raw[list_field]
|
|
260
|
+
raw[list_field] = [v.strip() for v in val.split(";")] if val else []
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _load_alignment(path: Path) -> list[CurrentAssignment]:
|
|
264
|
+
if not path.exists():
|
|
265
|
+
return []
|
|
266
|
+
rows: list[CurrentAssignment] = []
|
|
267
|
+
with open(path, newline="") as f:
|
|
268
|
+
for raw in csv.DictReader(f):
|
|
269
|
+
rows.append(CurrentAssignment.model_validate(raw))
|
|
270
|
+
return rows
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _load_constraints(path: Path) -> list[Constraint]:
|
|
274
|
+
if not path.exists():
|
|
275
|
+
return []
|
|
276
|
+
rows: list[Constraint] = []
|
|
277
|
+
with open(path, newline="") as f:
|
|
278
|
+
for raw in csv.DictReader(f):
|
|
279
|
+
rows.append(Constraint.model_validate(raw))
|
|
280
|
+
return rows
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _coerce_extras(raw: dict[str, Any], known: set[str]) -> None:
|
|
284
|
+
for key in raw:
|
|
285
|
+
if key in known:
|
|
286
|
+
continue
|
|
287
|
+
val = raw[key]
|
|
288
|
+
if not isinstance(val, str) or not val:
|
|
289
|
+
continue
|
|
290
|
+
low = val.lower()
|
|
291
|
+
if low in ("true", "false"):
|
|
292
|
+
raw[key] = low == "true"
|
|
293
|
+
continue
|
|
294
|
+
try:
|
|
295
|
+
raw[key] = int(val)
|
|
296
|
+
except ValueError:
|
|
297
|
+
try:
|
|
298
|
+
raw[key] = float(val)
|
|
299
|
+
except ValueError:
|
|
300
|
+
pass
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type,scope,value,description
|
|
2
|
+
product_match,global,required,Rep must have product expertise matching HCP requirements
|
|
3
|
+
account_grouping,account:A001,same_primary_rep,All HCPs in account A001 share one primary rep
|
|
4
|
+
account_grouping,account:A005,same_primary_rep,All HCPs in account A005 share one primary rep
|
|
5
|
+
account_grouping,account:A009,same_primary_rep,All HCPs in account A009 share one primary rep
|
|
6
|
+
account_grouping,account:A013,same_primary_rep,All HCPs in account A013 share one primary rep
|
|
7
|
+
account_grouping,account:A014,same_primary_rep,All HCPs in account A014 share one primary rep
|
|
8
|
+
max_hcps_per_rep,global,18,No rep gets more than 18 HCPs
|
|
9
|
+
frequency_cap,segment:high,2,Max weeks between visits for high-segment HCPs
|
|
10
|
+
frequency_cap,segment:medium,4,Max weeks between visits for medium-segment HCPs
|
|
11
|
+
frequency_cap,segment:low,8,Max weeks between visits for low-segment HCPs
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
hcp_id,primary_rep,secondary_rep
|
|
2
|
+
H001,R001,
|
|
3
|
+
H002,R001,
|
|
4
|
+
H003,R001,
|
|
5
|
+
H004,R002,
|
|
6
|
+
H005,R001,
|
|
7
|
+
H006,R001,
|
|
8
|
+
H007,R002,
|
|
9
|
+
H008,R001,
|
|
10
|
+
H009,R002,
|
|
11
|
+
H010,R001,
|
|
12
|
+
H011,R001,
|
|
13
|
+
H012,R001,
|
|
14
|
+
H013,R002,
|
|
15
|
+
H014,R001,
|
|
16
|
+
H015,R001,
|
|
17
|
+
H016,R002,
|
|
18
|
+
H017,R001,
|
|
19
|
+
H018,R002,
|
|
20
|
+
H019,R001,
|
|
21
|
+
H020,R002,
|
|
22
|
+
H021,R003,
|
|
23
|
+
H022,R003,
|
|
24
|
+
H023,R003,
|
|
25
|
+
H024,R003,
|
|
26
|
+
H025,R003,
|
|
27
|
+
H026,R003,
|
|
28
|
+
H027,R004,
|
|
29
|
+
H028,R004,
|
|
30
|
+
H029,R003,
|
|
31
|
+
H030,R003,
|
|
32
|
+
H031,R004,
|
|
33
|
+
H032,R003,
|
|
34
|
+
H033,R004,
|
|
35
|
+
H034,R003,
|
|
36
|
+
H035,R004,
|
|
37
|
+
H036,R004,
|
|
38
|
+
H037,R004,
|
|
39
|
+
H038,R003,
|
|
40
|
+
H039,R004,
|
|
41
|
+
H040,R003,
|
|
42
|
+
H041,R005,
|
|
43
|
+
H042,R005,
|
|
44
|
+
H043,R006,
|
|
45
|
+
H044,R005,
|
|
46
|
+
H045,R005,
|
|
47
|
+
H046,R006,
|
|
48
|
+
H047,R005,
|
|
49
|
+
H048,R005,
|
|
50
|
+
H049,R005,
|
|
51
|
+
H050,R006,
|
|
52
|
+
H051,R005,
|
|
53
|
+
H052,R006,
|
|
54
|
+
H053,R005,
|
|
55
|
+
H054,R005,
|
|
56
|
+
H055,R006,
|
|
57
|
+
H056,R005,
|
|
58
|
+
H057,R006,
|
|
59
|
+
H058,R005,
|
|
60
|
+
H059,R006,
|
|
61
|
+
H060,R006,
|
|
62
|
+
H061,R007,
|
|
63
|
+
H062,R008,
|
|
64
|
+
H063,R007,
|
|
65
|
+
H064,R007,
|
|
66
|
+
H065,R007,
|
|
67
|
+
H066,R008,
|
|
68
|
+
H067,R007,
|
|
69
|
+
H068,R008,
|
|
70
|
+
H069,R007,
|
|
71
|
+
H070,R007,
|
|
72
|
+
H071,R007,
|
|
73
|
+
H072,R008,
|
|
74
|
+
H073,R007,
|
|
75
|
+
H074,R007,
|
|
76
|
+
H075,R007,
|
|
77
|
+
H076,R008,
|
|
78
|
+
H077,R007,
|
|
79
|
+
H078,R008,
|
|
80
|
+
H079,R007,
|
|
81
|
+
H080,R008,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
hcp_id,name,specialty,segment,tier,lat,lng,account_id,consent_email,consent_phone,consent_visit,annual_potential,product_requirements
|
|
2
|
+
H001,Dr. Sarah Chen,oncology,high,1,40.7648,-73.9653,A001,true,true,true,95000,product_a
|
|
3
|
+
H002,Dr. Michael Torres,cardiology,high,1,40.7505,-73.9764,A001,true,true,true,88000,product_b
|
|
4
|
+
H003,Dr. Priya Sharma,oncology,high,1,40.7282,-74.0060,A001,true,true,true,82000,product_a
|
|
5
|
+
H004,Dr. David Okafor,neurology,high,1,40.6892,-73.9857,A002,true,true,true,78000,product_a;product_b
|
|
6
|
+
H005,Dr. Rachel Kim,cardiology,medium,2,40.7580,-73.9855,A001,true,true,true,45000,product_b
|
|
7
|
+
H006,Dr. Thomas Wright,oncology,medium,2,40.7425,-73.9540,A002,true,false,true,42000,product_a
|
|
8
|
+
H007,Dr. Amara Osei,immunology,medium,2,40.6945,-73.9565,A002,true,true,true,38000,product_b
|
|
9
|
+
H008,Dr. Jennifer Liu,cardiology,medium,2,40.7614,-73.9776,A003,true,true,true,36000,product_a
|
|
10
|
+
H009,Dr. Robert Patel,oncology,medium,2,40.6782,-73.9442,A002,true,true,true,34000,product_a;product_b
|
|
11
|
+
H010,Dr. Fatima Al-Hassan,neurology,medium,2,40.7831,-73.9712,A003,true,true,true,32000,product_b
|
|
12
|
+
H011,Dr. Andrew Nowak,oncology,low,3,40.7505,-73.9934,,true,true,true,12000,product_a
|
|
13
|
+
H012,Dr. Maria Santos,cardiology,low,3,40.7261,-74.0014,,true,true,true,10000,product_b
|
|
14
|
+
H013,Dr. Kevin Zhao,immunology,low,3,40.7112,-73.9505,,true,false,true,9000,product_a
|
|
15
|
+
H014,Dr. Laura Becker,neurology,low,3,40.7400,-74.0055,,true,true,true,8500,product_b
|
|
16
|
+
H015,Dr. Hassan Yusuf,primary_care,low,3,40.7680,-73.9510,,false,false,true,7000,product_a
|
|
17
|
+
H016,Dr. Emily Strand,oncology,low,3,40.6800,-73.9750,,true,true,true,6500,product_a
|
|
18
|
+
H017,Dr. Daniel Cho,cardiology,low,3,40.7527,-73.9772,,true,true,true,6000,product_b
|
|
19
|
+
H018,Dr. Olivia Martin,primary_care,low,3,40.6950,-73.9300,,true,true,true,5500,product_a;product_b
|
|
20
|
+
H019,Dr. Raj Gupta,oncology,low,3,40.7453,-73.9882,,true,true,true,5000,product_a
|
|
21
|
+
H020,Dr. Susan O'Neill,immunology,low,3,40.7020,-73.9880,,true,true,true,4500,product_b
|
|
22
|
+
H021,Dr. James Nakamura,oncology,high,1,40.7425,-74.0310,A004,true,true,true,92000,product_a
|
|
23
|
+
H022,Dr. Lisa Johansson,immunology,high,1,40.7282,-74.0776,A004,true,true,true,85000,product_b
|
|
24
|
+
H023,Dr. Wei Zhang,neurology,high,1,40.7178,-74.0431,A004,true,true,true,80000,product_a;product_b
|
|
25
|
+
H024,Dr. Patricia Reyes,cardiology,high,1,40.7357,-74.0298,A005,true,true,true,76000,product_a
|
|
26
|
+
H025,Dr. Mark Thompson,oncology,medium,2,40.7210,-74.0480,A004,true,true,true,44000,product_a
|
|
27
|
+
H026,Dr. Anna Kowalski,immunology,medium,2,40.7480,-74.0280,A005,true,false,true,40000,product_b
|
|
28
|
+
H027,Dr. Bryan Jackson,neurology,medium,2,40.7100,-74.0650,A005,true,true,true,37000,product_a
|
|
29
|
+
H028,Dr. Diana Petrova,cardiology,medium,2,40.7320,-74.0720,,true,true,true,35000,product_b
|
|
30
|
+
H029,Dr. George Fernandez,oncology,medium,2,40.7540,-74.0230,,true,true,true,33000,product_a;product_b
|
|
31
|
+
H030,Dr. Helen Tran,immunology,medium,2,40.7060,-74.0120,,true,true,true,31000,product_b
|
|
32
|
+
H031,Dr. Ivan Smirnov,neurology,low,3,40.7380,-74.0510,,true,true,true,11000,product_a
|
|
33
|
+
H032,Dr. Karen Lee,oncology,low,3,40.7150,-74.0340,,true,true,true,9500,product_b
|
|
34
|
+
H033,Dr. Nathan Cole,cardiology,low,3,40.7250,-74.0630,,true,true,true,8800,product_a
|
|
35
|
+
H034,Dr. Priscilla Dubois,immunology,low,3,40.7450,-74.0180,,true,false,true,8000,product_b
|
|
36
|
+
H035,Dr. Oscar Herrera,primary_care,low,3,40.7310,-74.0430,,false,false,true,7200,product_a
|
|
37
|
+
H036,Dr. Yuki Tanaka,oncology,low,3,40.7200,-74.0550,,true,true,true,6800,product_a
|
|
38
|
+
H037,Dr. Frank Bauer,neurology,low,3,40.7060,-74.0700,,true,true,true,5800,product_b
|
|
39
|
+
H038,Dr. Gloria Vega,cardiology,low,3,40.7350,-74.0150,,true,true,true,5200,product_a;product_b
|
|
40
|
+
H039,Dr. Henry Olsen,oncology,low,3,40.7470,-74.0350,,true,true,true,4800,product_a
|
|
41
|
+
H040,Dr. Irene Costa,immunology,low,3,40.7120,-74.0260,,true,true,true,4200,product_b
|
|
42
|
+
H041,Dr. Richard Muller,cardiology,high,1,40.7440,-74.1690,A006,true,true,true,90000,product_a
|
|
43
|
+
H042,Dr. Sophia Andersen,oncology,high,1,40.7357,-74.1724,A006,true,true,true,86000,product_b
|
|
44
|
+
H043,Dr. Charles Obi,neurology,high,1,40.7580,-74.2260,A007,true,true,true,79000,product_a;product_b
|
|
45
|
+
H044,Dr. Nina Volkov,immunology,high,1,40.7282,-74.0776,A006,true,true,true,75000,product_a
|
|
46
|
+
H045,Dr. Peter Hoffman,cardiology,medium,2,40.7200,-74.1730,A006,true,true,true,43000,product_b
|
|
47
|
+
H046,Dr. Aisha Nkomo,oncology,medium,2,40.7610,-74.2100,A007,true,false,true,39000,product_a
|
|
48
|
+
H047,Dr. Victor Ionescu,neurology,medium,2,40.7400,-74.1550,,true,true,true,36500,product_b
|
|
49
|
+
H048,Dr. Stephanie Cruz,cardiology,medium,2,40.7500,-74.1850,A007,true,true,true,34500,product_a
|
|
50
|
+
H049,Dr. Albert Schneider,oncology,medium,2,40.7100,-74.1600,,false,true,true,32500,product_a;product_b
|
|
51
|
+
H050,Dr. Beatrice Adeyemi,immunology,medium,2,40.7650,-74.2350,A008,true,true,true,30500,product_b
|
|
52
|
+
H051,Dr. Carl Lindqvist,cardiology,low,3,40.7300,-74.1900,,true,true,true,11500,product_a
|
|
53
|
+
H052,Dr. Diane Washington,oncology,low,3,40.7450,-74.2000,,true,true,true,10500,product_b
|
|
54
|
+
H053,Dr. Eduardo Silva,neurology,low,3,40.7550,-74.1750,,true,true,true,9200,product_a
|
|
55
|
+
H054,Dr. Fiona McCarthy,immunology,low,3,40.7150,-74.1650,,true,false,true,8200,product_b
|
|
56
|
+
H055,Dr. Greg Antonov,primary_care,low,3,40.7350,-74.2100,,false,false,true,7100,product_a
|
|
57
|
+
H056,Dr. Hannah Eriksson,oncology,low,3,40.7250,-74.1580,,true,true,true,6200,product_a
|
|
58
|
+
H057,Dr. Ian Fletcher,cardiology,low,3,40.7480,-74.1930,,true,true,true,5600,product_b
|
|
59
|
+
H058,Dr. Julia Romano,neurology,low,3,40.7050,-74.1700,,true,true,true,5100,product_a;product_b
|
|
60
|
+
H059,Dr. Kyle Abrams,oncology,low,3,40.7380,-74.2050,,true,true,true,4600,product_a
|
|
61
|
+
H060,Dr. Monica Dupont,immunology,low,3,40.7520,-74.2200,,true,true,true,4100,product_b
|
|
62
|
+
H061,Dr. Antonio Morales,oncology,high,1,40.8580,-73.8690,A009,true,true,true,91000,product_a
|
|
63
|
+
H062,Dr. Natalie Bergstrom,neurology,high,1,40.8128,-73.9291,A009,true,true,true,84000,product_b
|
|
64
|
+
H063,Dr. Philip Owusu,cardiology,high,1,40.8449,-73.8648,A009,true,true,true,77000,product_a;product_b
|
|
65
|
+
H064,Dr. Camille Bonnet,immunology,high,1,40.9176,-73.8984,A010,true,true,true,74000,product_a
|
|
66
|
+
H065,Dr. Samuel Okonkwo,oncology,medium,2,40.8400,-73.8450,A009,true,true,true,42000,product_a
|
|
67
|
+
H066,Dr. Teresa Ramirez,neurology,medium,2,40.8200,-73.9100,,true,false,true,38500,product_b
|
|
68
|
+
H067,Dr. Ulrich Weber,cardiology,medium,2,40.8650,-73.8880,,true,true,true,36000,product_a
|
|
69
|
+
H068,Dr. Vanessa Boyd,oncology,medium,2,40.8000,-73.9350,A010,true,true,true,34000,product_b
|
|
70
|
+
H069,Dr. William Fraser,immunology,medium,2,40.8300,-73.8750,,false,true,true,32000,product_a;product_b
|
|
71
|
+
H070,Dr. Xena Papadopoulos,neurology,medium,2,40.9050,-73.9100,A010,true,true,true,30000,product_b
|
|
72
|
+
H071,Dr. Yosef Abadi,oncology,low,3,40.8550,-73.8600,,true,true,true,11200,product_a
|
|
73
|
+
H072,Dr. Zoe Campbell,cardiology,low,3,40.8100,-73.9200,,true,true,true,10000,product_b
|
|
74
|
+
H073,Dr. Adam Kowalczyk,neurology,low,3,40.8700,-73.8950,,true,true,true,9100,product_a
|
|
75
|
+
H074,Dr. Bella Nguyen,immunology,low,3,40.8350,-73.8500,,true,false,true,7800,product_b
|
|
76
|
+
H075,Dr. Cedric Dumont,primary_care,low,3,40.8950,-73.9000,,false,false,true,6800,product_a
|
|
77
|
+
H076,Dr. Dina Khalil,oncology,low,3,40.8250,-73.9400,,true,true,true,6300,product_a
|
|
78
|
+
H077,Dr. Ethan Ross,cardiology,low,3,40.8480,-73.8780,,true,true,true,5400,product_b
|
|
79
|
+
H078,Dr. Freya Lindgren,neurology,low,3,40.8150,-73.9050,,true,true,true,4900,product_a;product_b
|
|
80
|
+
H079,Dr. Gavin Murphy,oncology,low,3,40.8600,-73.8850,,true,true,true,4400,product_a
|
|
81
|
+
H080,Dr. Hana Takahashi,immunology,low,3,40.8050,-73.9150,,true,true,true,3800,product_b
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
rep_id,name,base_lat,base_lng,product_expertise,max_weekly_hours,max_daily_calls,available_days
|
|
2
|
+
R001,Alice Park,40.7580,-73.9855,product_a;product_b,40,8,mon;tue;wed;thu;fri
|
|
3
|
+
R002,Bob Torres,40.6892,-73.9505,product_a;product_b,40,8,mon;tue;wed;thu;fri
|
|
4
|
+
R003,Carol Wu,40.7282,-74.0431,product_a;product_b,40,8,mon;tue;wed;thu;fri
|
|
5
|
+
R004,Dan Osei,40.7178,-74.0650,product_a,35,6,mon;tue;wed;thu
|
|
6
|
+
R005,Elena Rossi,40.7440,-74.1690,product_a;product_b,40,8,mon;tue;wed;thu;fri
|
|
7
|
+
R006,Frank Bauer,40.7580,-74.2260,product_b,40,8,mon;tue;wed;thu;fri
|
|
8
|
+
R007,Grace Tanaka,40.8580,-73.8690,product_a;product_b,40,8,mon;tue;wed;thu;fri
|
|
9
|
+
R008,Hank Morrison,40.8128,-73.9291,product_a;product_b,40,8,mon;tue;wed;thu;fri
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Geographic utilities: haversine, distance matrix, clustering, routing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
_EARTH_RADIUS_KM = 6371.0
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def haversine(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
|
11
|
+
"""Great-circle distance in km between two points."""
|
|
12
|
+
lat1, lng1, lat2, lng2 = (math.radians(v) for v in (lat1, lng1, lat2, lng2))
|
|
13
|
+
dlat = lat2 - lat1
|
|
14
|
+
dlng = lng2 - lng1
|
|
15
|
+
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlng / 2) ** 2
|
|
16
|
+
return 2 * _EARTH_RADIUS_KM * math.asin(math.sqrt(a))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def travel_minutes(km: float, speed_kmh: float = 40.0) -> float:
|
|
20
|
+
"""Rough urban travel estimate."""
|
|
21
|
+
if km <= 0:
|
|
22
|
+
return 0.0
|
|
23
|
+
return (km / speed_kmh) * 60.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def distance_matrix(
|
|
27
|
+
points: list[tuple[float, float]],
|
|
28
|
+
) -> list[list[float]]:
|
|
29
|
+
"""Pairwise haversine distances (km) between points."""
|
|
30
|
+
n = len(points)
|
|
31
|
+
mat = [[0.0] * n for _ in range(n)]
|
|
32
|
+
for i in range(n):
|
|
33
|
+
for j in range(i + 1, n):
|
|
34
|
+
d = haversine(points[i][0], points[i][1], points[j][0], points[j][1])
|
|
35
|
+
mat[i][j] = d
|
|
36
|
+
mat[j][i] = d
|
|
37
|
+
return mat
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def centroid(points: list[tuple[float, float]]) -> tuple[float, float]:
|
|
41
|
+
"""Arithmetic mean of lat/lng points."""
|
|
42
|
+
if not points:
|
|
43
|
+
return 0.0, 0.0
|
|
44
|
+
lat = sum(p[0] for p in points) / len(points)
|
|
45
|
+
lng = sum(p[1] for p in points) / len(points)
|
|
46
|
+
return lat, lng
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def grid_cluster(
|
|
50
|
+
points: list[tuple[float, float, str]],
|
|
51
|
+
target_per_cluster: int = 6,
|
|
52
|
+
) -> list[list[str]]:
|
|
53
|
+
"""Simple grid-based clustering. Each point is (lat, lng, id).
|
|
54
|
+
|
|
55
|
+
Returns a list of clusters, each a list of point IDs.
|
|
56
|
+
"""
|
|
57
|
+
if not points:
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
lats = [p[0] for p in points]
|
|
61
|
+
lngs = [p[1] for p in points]
|
|
62
|
+
min_lat, max_lat = min(lats), max(lats)
|
|
63
|
+
min_lng, max_lng = min(lngs), max(lngs)
|
|
64
|
+
|
|
65
|
+
n = len(points)
|
|
66
|
+
num_clusters = max(1, round(n / target_per_cluster))
|
|
67
|
+
grid_side = max(1, math.ceil(math.sqrt(num_clusters)))
|
|
68
|
+
|
|
69
|
+
lat_step = (max_lat - min_lat + 1e-9) / grid_side
|
|
70
|
+
lng_step = (max_lng - min_lng + 1e-9) / grid_side
|
|
71
|
+
|
|
72
|
+
cells: dict[tuple[int, int], list[str]] = {}
|
|
73
|
+
for lat, lng, pid in points:
|
|
74
|
+
r = min(int((lat - min_lat) / lat_step), grid_side - 1)
|
|
75
|
+
c = min(int((lng - min_lng) / lng_step), grid_side - 1)
|
|
76
|
+
cells.setdefault((r, c), []).append(pid)
|
|
77
|
+
|
|
78
|
+
clusters = list(cells.values())
|
|
79
|
+
|
|
80
|
+
merged: list[list[str]] = []
|
|
81
|
+
for cluster in clusters:
|
|
82
|
+
if len(cluster) < 2 and merged:
|
|
83
|
+
merged[-1].extend(cluster)
|
|
84
|
+
else:
|
|
85
|
+
merged.append(cluster)
|
|
86
|
+
|
|
87
|
+
return merged if merged else [list(cells.values())[0]] if cells else []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def nearest_neighbor_route(
|
|
91
|
+
points: list[tuple[float, float]],
|
|
92
|
+
start: tuple[float, float] | None = None,
|
|
93
|
+
) -> tuple[list[int], float]:
|
|
94
|
+
"""Nearest-neighbor TSP heuristic. Returns (visit_order, total_km)."""
|
|
95
|
+
if not points:
|
|
96
|
+
return [], 0.0
|
|
97
|
+
if len(points) == 1:
|
|
98
|
+
d = haversine(start[0], start[1], points[0][0], points[0][1]) if start else 0.0
|
|
99
|
+
return [0], d
|
|
100
|
+
|
|
101
|
+
n = len(points)
|
|
102
|
+
visited = [False] * n
|
|
103
|
+
order: list[int] = []
|
|
104
|
+
total_km = 0.0
|
|
105
|
+
|
|
106
|
+
if start:
|
|
107
|
+
dists = [haversine(start[0], start[1], p[0], p[1]) for p in points]
|
|
108
|
+
current_idx = min(range(n), key=lambda i: dists[i])
|
|
109
|
+
total_km += dists[current_idx]
|
|
110
|
+
else:
|
|
111
|
+
current_idx = 0
|
|
112
|
+
|
|
113
|
+
visited[current_idx] = True
|
|
114
|
+
order.append(current_idx)
|
|
115
|
+
|
|
116
|
+
for _ in range(n - 1):
|
|
117
|
+
best_dist = float("inf")
|
|
118
|
+
best_idx = -1
|
|
119
|
+
for j in range(n):
|
|
120
|
+
if visited[j]:
|
|
121
|
+
continue
|
|
122
|
+
d = haversine(
|
|
123
|
+
points[current_idx][0],
|
|
124
|
+
points[current_idx][1],
|
|
125
|
+
points[j][0],
|
|
126
|
+
points[j][1],
|
|
127
|
+
)
|
|
128
|
+
if d < best_dist:
|
|
129
|
+
best_dist = d
|
|
130
|
+
best_idx = j
|
|
131
|
+
visited[best_idx] = True
|
|
132
|
+
order.append(best_idx)
|
|
133
|
+
total_km += best_dist
|
|
134
|
+
current_idx = best_idx
|
|
135
|
+
|
|
136
|
+
return order, total_km
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def two_opt_improve(
|
|
140
|
+
points: list[tuple[float, float]],
|
|
141
|
+
route: list[int],
|
|
142
|
+
max_iterations: int = 100,
|
|
143
|
+
) -> tuple[list[int], float]:
|
|
144
|
+
"""Improve a route using 2-opt swaps."""
|
|
145
|
+
n = len(route)
|
|
146
|
+
if n < 4:
|
|
147
|
+
total = sum(
|
|
148
|
+
haversine(points[route[i]][0], points[route[i]][1], points[route[i + 1]][0], points[route[i + 1]][1])
|
|
149
|
+
for i in range(n - 1)
|
|
150
|
+
)
|
|
151
|
+
return route, total
|
|
152
|
+
|
|
153
|
+
def route_distance(r: list[int]) -> float:
|
|
154
|
+
return sum(
|
|
155
|
+
haversine(points[r[i]][0], points[r[i]][1], points[r[i + 1]][0], points[r[i + 1]][1])
|
|
156
|
+
for i in range(len(r) - 1)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
best = list(route)
|
|
160
|
+
best_dist = route_distance(best)
|
|
161
|
+
|
|
162
|
+
for _ in range(max_iterations):
|
|
163
|
+
improved = False
|
|
164
|
+
for i in range(1, n - 1):
|
|
165
|
+
for j in range(i + 1, n):
|
|
166
|
+
new_route = best[:i] + best[i : j + 1][::-1] + best[j + 1 :]
|
|
167
|
+
new_dist = route_distance(new_route)
|
|
168
|
+
if new_dist < best_dist - 0.01:
|
|
169
|
+
best = new_route
|
|
170
|
+
best_dist = new_dist
|
|
171
|
+
improved = True
|
|
172
|
+
if not improved:
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
return best, best_dist
|