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,201 @@
|
|
|
1
|
+
"""Pydantic domain models for territory alignment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# Input models
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HCP(BaseModel):
|
|
15
|
+
"""An HCP loaded from hcps.csv. Extra columns are preserved."""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(extra="allow")
|
|
18
|
+
|
|
19
|
+
hcp_id: str
|
|
20
|
+
name: str
|
|
21
|
+
specialty: str = ""
|
|
22
|
+
segment: str = Field(default="medium", description="high | medium | low")
|
|
23
|
+
tier: int = Field(default=2, ge=1, le=3)
|
|
24
|
+
lat: float | None = None
|
|
25
|
+
lng: float | None = None
|
|
26
|
+
account_id: str = ""
|
|
27
|
+
consent_email: bool = True
|
|
28
|
+
consent_phone: bool = True
|
|
29
|
+
consent_visit: bool = True
|
|
30
|
+
annual_potential: float = 0.0
|
|
31
|
+
product_requirements: list[str] = Field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Rep(BaseModel):
|
|
35
|
+
rep_id: str
|
|
36
|
+
name: str
|
|
37
|
+
base_lat: float = 0.0
|
|
38
|
+
base_lng: float = 0.0
|
|
39
|
+
product_expertise: list[str] = Field(default_factory=list)
|
|
40
|
+
max_weekly_hours: float = 40.0
|
|
41
|
+
max_daily_calls: int = 8
|
|
42
|
+
available_days: list[str] = Field(default_factory=lambda: ["mon", "tue", "wed", "thu", "fri"])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CurrentAssignment(BaseModel):
|
|
46
|
+
hcp_id: str
|
|
47
|
+
primary_rep: str
|
|
48
|
+
secondary_rep: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Constraint(BaseModel):
|
|
52
|
+
type: str
|
|
53
|
+
scope: str
|
|
54
|
+
value: Any
|
|
55
|
+
description: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Objective configuration
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ObjectiveWeights(BaseModel):
|
|
64
|
+
workload_balance: float = Field(default=0.30, ge=0.0, le=1.0)
|
|
65
|
+
travel_efficiency: float = Field(default=0.25, ge=0.0, le=1.0)
|
|
66
|
+
disruption: float = Field(default=0.25, ge=0.0, le=1.0)
|
|
67
|
+
coverage: float = Field(default=0.20, ge=0.0, le=1.0)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Scenario levers (ta_align input)
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Override(BaseModel):
|
|
76
|
+
hcp_id: str = Field(description="HCP to pin to a specific rep")
|
|
77
|
+
rep_id: str = Field(description="Rep to assign")
|
|
78
|
+
reason: str = ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class NewHire(BaseModel):
|
|
82
|
+
rep_id: str
|
|
83
|
+
name: str
|
|
84
|
+
base_lat: float
|
|
85
|
+
base_lng: float
|
|
86
|
+
product_expertise: list[str] = Field(default_factory=list)
|
|
87
|
+
max_weekly_hours: float = 40.0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Output models
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AssignmentResult(BaseModel):
|
|
96
|
+
"""A single HCP assignment in the output plan."""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(extra="allow")
|
|
99
|
+
|
|
100
|
+
hcp_id: str
|
|
101
|
+
hcp_name: str
|
|
102
|
+
primary_rep: str
|
|
103
|
+
previous_rep: str
|
|
104
|
+
is_changed: bool
|
|
105
|
+
change_reason: str = ""
|
|
106
|
+
estimated_travel_min: float = 0.0
|
|
107
|
+
estimated_annual_visits: int = 0
|
|
108
|
+
segment: str = ""
|
|
109
|
+
tier: int = 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TerritorySummary(BaseModel):
|
|
113
|
+
rep_id: str
|
|
114
|
+
rep_name: str
|
|
115
|
+
hcp_count: int
|
|
116
|
+
total_potential: float
|
|
117
|
+
workload_hours_weekly: float
|
|
118
|
+
travel_hours_weekly: float
|
|
119
|
+
segment_high: int = 0
|
|
120
|
+
segment_medium: int = 0
|
|
121
|
+
segment_low: int = 0
|
|
122
|
+
relationships_kept: int = 0
|
|
123
|
+
relationships_new: int = 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class RawMetrics(BaseModel):
|
|
127
|
+
workload_gini: float
|
|
128
|
+
avg_travel_min: float
|
|
129
|
+
max_travel_min: float
|
|
130
|
+
pct_reassigned: float
|
|
131
|
+
pct_priority_covered: float
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ObjectiveScores(BaseModel):
|
|
135
|
+
workload_balance: float
|
|
136
|
+
travel_efficiency: float
|
|
137
|
+
disruption: float
|
|
138
|
+
coverage: float
|
|
139
|
+
composite: float
|
|
140
|
+
raw: RawMetrics
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class UnassignedHCP(BaseModel):
|
|
144
|
+
hcp_id: str
|
|
145
|
+
reason: str
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ScenarioResult(BaseModel):
|
|
149
|
+
scenario_name: str
|
|
150
|
+
assignments: list[AssignmentResult]
|
|
151
|
+
territory_summary: list[TerritorySummary]
|
|
152
|
+
objectives: ObjectiveScores
|
|
153
|
+
unassigned: list[UnassignedHCP]
|
|
154
|
+
weights_used: ObjectiveWeights
|
|
155
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# Operational mode output
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class VisitCluster(BaseModel):
|
|
164
|
+
cluster_id: str
|
|
165
|
+
hcp_ids: list[str]
|
|
166
|
+
centroid_lat: float | None = None
|
|
167
|
+
centroid_lng: float | None = None
|
|
168
|
+
estimated_route_km: float = 0.0
|
|
169
|
+
estimated_travel_min: float = 0.0
|
|
170
|
+
hcp_count: int = 0
|
|
171
|
+
suggested_day: str = ""
|
|
172
|
+
travel_warning: str = ""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class VisitStop(BaseModel):
|
|
176
|
+
hcp_id: str
|
|
177
|
+
hcp_name: str
|
|
178
|
+
visit_order: int
|
|
179
|
+
cluster_id: str
|
|
180
|
+
lat: float | None = None
|
|
181
|
+
lng: float | None = None
|
|
182
|
+
travel_km_from_previous: float = 0.0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class RemoteAlternative(BaseModel):
|
|
186
|
+
hcp_id: str
|
|
187
|
+
hcp_name: str
|
|
188
|
+
distance_km: float
|
|
189
|
+
reason: str
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class VisitPlan(BaseModel):
|
|
193
|
+
rep_id: str
|
|
194
|
+
rep_name: str
|
|
195
|
+
period: str
|
|
196
|
+
clusters: list[VisitCluster]
|
|
197
|
+
visit_sequence: list[VisitStop]
|
|
198
|
+
remote_alternatives: list[RemoteAlternative]
|
|
199
|
+
total_route_km: float
|
|
200
|
+
total_hcps: int
|
|
201
|
+
remote_count: int
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Multi-objective scoring and normalisation for territory alignment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .geo import haversine, travel_minutes
|
|
6
|
+
from .models import (
|
|
7
|
+
HCP,
|
|
8
|
+
AssignmentResult,
|
|
9
|
+
ObjectiveScores,
|
|
10
|
+
ObjectiveWeights,
|
|
11
|
+
RawMetrics,
|
|
12
|
+
Rep,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def score_alignment(
|
|
17
|
+
assignments: list[AssignmentResult],
|
|
18
|
+
hcps: list[HCP],
|
|
19
|
+
reps: list[Rep],
|
|
20
|
+
weights: ObjectiveWeights,
|
|
21
|
+
baseline: RawMetrics | None = None,
|
|
22
|
+
) -> ObjectiveScores:
|
|
23
|
+
"""Score an alignment across four normalised objectives."""
|
|
24
|
+
hcp_map = {h.hcp_id: h for h in hcps}
|
|
25
|
+
rep_map = {r.rep_id: r for r in reps}
|
|
26
|
+
|
|
27
|
+
raw = _compute_raw(assignments, hcp_map, rep_map)
|
|
28
|
+
|
|
29
|
+
if baseline:
|
|
30
|
+
wb = _normalise(raw.workload_gini, baseline.workload_gini)
|
|
31
|
+
te = _normalise(raw.avg_travel_min, baseline.avg_travel_min)
|
|
32
|
+
di = _normalise(raw.pct_reassigned, baseline.pct_reassigned)
|
|
33
|
+
co = _normalise(100.0 - raw.pct_priority_covered, 100.0 - baseline.pct_priority_covered)
|
|
34
|
+
else:
|
|
35
|
+
wb = min(raw.workload_gini, 1.0)
|
|
36
|
+
te = min(raw.avg_travel_min / 60.0, 1.0) if raw.avg_travel_min > 0 else 0.0
|
|
37
|
+
di = min(raw.pct_reassigned / 100.0, 1.0)
|
|
38
|
+
co = min((100.0 - raw.pct_priority_covered) / 100.0, 1.0)
|
|
39
|
+
|
|
40
|
+
composite = (
|
|
41
|
+
weights.workload_balance * wb + weights.travel_efficiency * te + weights.disruption * di + weights.coverage * co
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return ObjectiveScores(
|
|
45
|
+
workload_balance=round(wb, 4),
|
|
46
|
+
travel_efficiency=round(te, 4),
|
|
47
|
+
disruption=round(di, 4),
|
|
48
|
+
coverage=round(co, 4),
|
|
49
|
+
composite=round(composite, 4),
|
|
50
|
+
raw=raw,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _compute_raw(
|
|
55
|
+
assignments: list[AssignmentResult],
|
|
56
|
+
hcp_map: dict[str, HCP],
|
|
57
|
+
rep_map: dict[str, Rep],
|
|
58
|
+
) -> RawMetrics:
|
|
59
|
+
rep_workloads: dict[str, float] = {r: 0.0 for r in rep_map}
|
|
60
|
+
rep_travel: dict[str, list[float]] = {r: [] for r in rep_map}
|
|
61
|
+
reassigned = 0
|
|
62
|
+
priority_total = 0
|
|
63
|
+
priority_covered = 0
|
|
64
|
+
|
|
65
|
+
for a in assignments:
|
|
66
|
+
hcp = hcp_map.get(a.hcp_id)
|
|
67
|
+
rep = rep_map.get(a.primary_rep)
|
|
68
|
+
if not hcp or not rep:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
hours_per_visit = 1.0 if hcp.segment == "high" else 0.75 if hcp.segment == "medium" else 0.5
|
|
72
|
+
visits_per_year = 24 if hcp.segment == "high" else 12 if hcp.segment == "medium" else 6
|
|
73
|
+
weekly_hours = (hours_per_visit * visits_per_year) / 48.0
|
|
74
|
+
rep_workloads[a.primary_rep] = rep_workloads.get(a.primary_rep, 0.0) + weekly_hours
|
|
75
|
+
|
|
76
|
+
if hcp.lat is not None and hcp.lng is not None:
|
|
77
|
+
dist = haversine(rep.base_lat, rep.base_lng, hcp.lat, hcp.lng)
|
|
78
|
+
rep_travel.setdefault(a.primary_rep, []).append(travel_minutes(dist))
|
|
79
|
+
|
|
80
|
+
if a.is_changed:
|
|
81
|
+
reassigned += 1
|
|
82
|
+
|
|
83
|
+
if hcp.segment == "high":
|
|
84
|
+
priority_total += 1
|
|
85
|
+
priority_covered += 1
|
|
86
|
+
|
|
87
|
+
all_hcps_high = sum(1 for h in hcp_map.values() if h.segment == "high")
|
|
88
|
+
if all_hcps_high > priority_total:
|
|
89
|
+
priority_total = all_hcps_high
|
|
90
|
+
|
|
91
|
+
workloads = [v for v in rep_workloads.values() if v > 0]
|
|
92
|
+
gini = _gini(workloads) if workloads else 0.0
|
|
93
|
+
|
|
94
|
+
all_travel = [t for ts in rep_travel.values() for t in ts]
|
|
95
|
+
avg_travel = sum(all_travel) / max(len(all_travel), 1)
|
|
96
|
+
max_travel = max(all_travel) if all_travel else 0.0
|
|
97
|
+
|
|
98
|
+
pct_reassigned = (reassigned / max(len(assignments), 1)) * 100.0
|
|
99
|
+
pct_covered = (priority_covered / max(priority_total, 1)) * 100.0
|
|
100
|
+
|
|
101
|
+
return RawMetrics(
|
|
102
|
+
workload_gini=round(gini, 4),
|
|
103
|
+
avg_travel_min=round(avg_travel, 1),
|
|
104
|
+
max_travel_min=round(max_travel, 1),
|
|
105
|
+
pct_reassigned=round(pct_reassigned, 1),
|
|
106
|
+
pct_priority_covered=round(pct_covered, 1),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _gini(values: list[float]) -> float:
|
|
111
|
+
"""Gini coefficient (0 = perfect equality, 1 = max inequality)."""
|
|
112
|
+
if not values or all(v == 0 for v in values):
|
|
113
|
+
return 0.0
|
|
114
|
+
n = len(values)
|
|
115
|
+
sorted_v = sorted(values)
|
|
116
|
+
total = sum(sorted_v)
|
|
117
|
+
weighted_sum = sum((i + 1) * v for i, v in enumerate(sorted_v))
|
|
118
|
+
return round((2.0 * weighted_sum) / (n * total) - (n + 1.0) / n, 4)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _normalise(value: float, baseline: float) -> float:
|
|
122
|
+
"""Normalise a metric relative to baseline. Returns 0-1 range."""
|
|
123
|
+
if baseline <= 0:
|
|
124
|
+
return 0.0 if value <= 0 else 1.0
|
|
125
|
+
return min(max(value / baseline, 0.0), 2.0) / 2.0
|