| """Central tuning knobs for the routing optimizer. | |
| All times are integer minutes since midnight; all distances are miles. | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # Travel model (placeholder until you plug in a real drive-time source) | |
| # --------------------------------------------------------------------------- | |
| # Straight-line (haversine) miles are multiplied by a road-circuity factor to | |
| # approximate actual driving distance, then converted to minutes at an | |
| # average speed. Swap distance.py's matrix builder for OSRM / a Distance | |
| # Matrix API when you're ready for real drive times. | |
| CIRCUITY_FACTOR = 1.3 | |
| AVG_SPEED_MPH = 35.0 | |
| # --------------------------------------------------------------------------- | |
| # Google Maps Routes API (used with --distance-source google) | |
| # --------------------------------------------------------------------------- | |
| # TRAFFIC_UNAWARE: typical drive times, 625 elements/request, cheapest. | |
| # TRAFFIC_AWARE: live+predictive traffic, but requires a departureTime per | |
| # request and allows only 100 elements/request — see google_distance.py | |
| # before switching. | |
| GOOGLE_ROUTING_PREFERENCE = "TRAFFIC_UNAWARE" | |
| # --------------------------------------------------------------------------- | |
| # Claim priorities and drop penalties | |
| # --------------------------------------------------------------------------- | |
| # Every claim node is wrapped in a "disjunction": the solver may leave it | |
| # unassigned (dropped -> rescheduled to a later day) by paying the penalty. | |
| # Penalties are in the same units as the objective's arc cost (travel | |
| # minutes), so a penalty of 600 means "spend up to ~10 extra driving hours | |
| # across the fleet before you're allowed to drop this claim". | |
| # | |
| # Priority 1 = MUST be inspected today. Its penalty is set so high that the | |
| # solver will only ever drop it when it is physically impossible to serve | |
| # (no qualified adjuster, or no feasible slot in anyone's shift). If that | |
| # happens the report flags it loudly as a violation so a human can intervene. | |
| PRIORITY_MUST_TODAY = 1 | |
| PRIORITY_HIGH = 2 | |
| PRIORITY_NORMAL = 3 | |
| DROP_PENALTY = { | |
| PRIORITY_MUST_TODAY: 1_000_000, | |
| PRIORITY_HIGH: 3_000, | |
| PRIORITY_NORMAL: 600, | |
| } | |
| PRIORITY_LABEL = { | |
| PRIORITY_MUST_TODAY: "MUST-TODAY", | |
| PRIORITY_HIGH: "high", | |
| PRIORITY_NORMAL: "normal", | |
| } | |
| # SLA-age escalation: a claim's drop penalty grows the longer it has been | |
| # waiting (optional claims.csv column `age_days`, default 0 - so all | |
| # published benchmark numbers are unchanged unless ages are provided). | |
| # MUST-TODAY claims are already at the ceiling and do not escalate. | |
| SLA_ESCALATION_PER_DAY = 0.25 | |
| def effective_penalty(priority: int, age_days: int = 0) -> int: | |
| base = DROP_PENALTY[priority] | |
| if priority == PRIORITY_MUST_TODAY: | |
| return base | |
| return int(base * (1 + SLA_ESCALATION_PER_DAY * max(0, age_days))) | |
| def is_eligible(adjuster, claim, home_to_claim_miles: float) -> bool: | |
| """The single assignment-eligibility rule, shared by every backend: | |
| the adjuster must hold the claim's peril skill AND, if they have a | |
| service territory (max_radius_miles), the claim must lie within it. | |
| Distances are the road-approximate matrix miles, so the same rule | |
| works with haversine and Google-based matrices.""" | |
| if claim.peril not in adjuster.skills: | |
| return False | |
| radius = getattr(adjuster, "max_radius_miles", None) | |
| return radius is None or home_to_claim_miles <= radius | |
| # --------------------------------------------------------------------------- | |
| # Optional schedule features (OR-Tools backend; off by default so that | |
| # benchmarks and documented results remain reproducible) | |
| # --------------------------------------------------------------------------- | |
| # 30-minute lunch starting between 11:30 and 13:30. | |
| LUNCH_BREAK = {"duration": 30, "earliest_start": 690, "latest_start": 810} | |
| # Standard appointment slots the call center offers policyholders (and | |
| # the Call Planner re-offers when someone can't make their original | |
| # window). Three 3-hour chunks by default; edit to match your company's | |
| # slot menu (e.g. two half-day windows). | |
| CALL_SLOTS = [(8 * 60, 11 * 60), (11 * 60, 14 * 60), (14 * 60, 17 * 60)] | |
| # Span cost per minute of route duration when workload balancing is on; | |
| # discourages one adjuster carrying a much longer day than the others. | |
| BALANCE_COEFFICIENT = 2 | |
| # --------------------------------------------------------------------------- | |
| # Rolling booking horizon (horizon.py): capacity planning knobs for | |
| # assigning backlog claims to days before each day is routed exactly. | |
| # --------------------------------------------------------------------------- | |
| # Planning estimate of drive time consumed per visit (calibrate from | |
| # your fleet's measured mean leg; the per-day routing repair pass | |
| # corrects estimation errors honestly). | |
| HORIZON_TRAVEL_ALLOWANCE_MIN = 40 | |
| # Fraction of shift minutes considered bookable when day-assigning | |
| # (the slack absorbs matrix estimation error and the lunch break). | |
| HORIZON_UTILIZATION = 0.9 | |
| # --------------------------------------------------------------------------- | |
| # Solver | |
| # --------------------------------------------------------------------------- | |
| SOLVER_TIME_LIMIT_SECONDS = 15 | |
| # Max waiting time allowed at a stop (arriving before the policyholder's | |
| # window opens), in minutes. | |
| MAX_WAIT_MINUTES = 240 | |
| # Upper bound for the time dimension (minutes since midnight). | |
| TIME_HORIZON_MINUTES = 24 * 60 | |
| # --------------------------------------------------------------------------- | |
| # Synthetic data defaults | |
| # --------------------------------------------------------------------------- | |
| PERILS = ["fire", "flood", "wind", "hail"] | |
| # Claims are scattered around the Houston metro area. | |
| REGION_CENTER = (29.76, -95.37) | |
| REGION_SPREAD_DEG = 0.35 | |
| DATA_DIR = "data" | |