Spaces:
Running
Running
Deploy FieldOps Service Parts & Field Operations Optimizer
Browse files- README.md +14 -10
- assets/demo/benchmarks.json +57 -0
- assets/demo/summary.json +15 -0
- assets/samples/sample_data_center.json +321 -0
- assets/samples/sample_elevators.json +416 -0
- assets/samples/sample_industrial_equipment.json +395 -0
- assets/samples/sample_medical_equipment.json +293 -0
- assets/samples/sample_telecom.json +562 -0
- gradio/README.md +34 -0
- gradio/app.py +257 -0
- gradio/assets/config.json +42 -0
- gradio/assets/demo/benchmarks.json +57 -0
- gradio/assets/demo/summary.json +15 -0
- gradio/requirements.txt +4 -0
- gradio/src/fieldops/__init__.py +5 -0
- gradio/src/fieldops/alns.py +83 -0
- gradio/src/fieldops/constants.py +118 -0
- gradio/src/fieldops/engine.py +125 -0
- gradio/src/fieldops/generator.py +230 -0
- gradio/src/fieldops/inventory_optimization.py +117 -0
- gradio/src/fieldops/metrics.py +76 -0
- gradio/src/fieldops/ml_predictions.py +89 -0
- gradio/src/fieldops/models.py +243 -0
- gradio/src/fieldops/pipeline.py +94 -0
- gradio/src/fieldops/routing.py +167 -0
- gradio/src/fieldops/scheduling.py +105 -0
- gradio/src/fieldops/simulation.py +82 -0
- gradio/src/fieldops/stochastic.py +75 -0
- gradio/src/fieldops/visualization.py +192 -0
- index.html +183 -19
README.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Service Parts Field Operations Optimizer
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: static
|
| 7 |
-
pinned: false
|
| 8 |
-
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: FieldOps — Service Parts & Field Operations Optimizer
|
| 3 |
+
emoji: 🛠️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: static
|
| 7 |
+
pinned: false
|
| 8 |
+
license: apache-2.0
|
| 9 |
+
short_description: Field service parts, routing & inventory optimizer
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# FieldOps Static Explorer
|
| 13 |
+
|
| 14 |
+
Pre-computed optimization results from the FieldOps predict-then-optimize engine.
|
assets/demo/benchmarks.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"industry": "industrial_equipment",
|
| 4 |
+
"stress": "baseline",
|
| 5 |
+
"algorithm": "two_stage_stochastic",
|
| 6 |
+
"sla_compliance_pct": 100.0,
|
| 7 |
+
"mttr_hours": 4.31,
|
| 8 |
+
"ftf_rate_pct": 100.0,
|
| 9 |
+
"logistics_cost": 597.53,
|
| 10 |
+
"downtime_cost": 17622.45,
|
| 11 |
+
"solve_time_sec": 0.011503599991556257
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"industry": "medical_equipment",
|
| 15 |
+
"stress": "emergency_surge",
|
| 16 |
+
"algorithm": "two_stage_stochastic",
|
| 17 |
+
"sla_compliance_pct": 0.0,
|
| 18 |
+
"mttr_hours": 4.94,
|
| 19 |
+
"ftf_rate_pct": 66.7,
|
| 20 |
+
"logistics_cost": 284.58,
|
| 21 |
+
"downtime_cost": 44212.55,
|
| 22 |
+
"solve_time_sec": 0.006099400023231283
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"industry": "telecom",
|
| 26 |
+
"stress": "parts_shortage",
|
| 27 |
+
"algorithm": "two_stage_stochastic",
|
| 28 |
+
"sla_compliance_pct": 90.0,
|
| 29 |
+
"mttr_hours": 4.2,
|
| 30 |
+
"ftf_rate_pct": 80.0,
|
| 31 |
+
"logistics_cost": 1170.53,
|
| 32 |
+
"downtime_cost": 58191.35,
|
| 33 |
+
"solve_time_sec": 0.006857800006400794
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
"industry": "data_center",
|
| 37 |
+
"stress": "technician_shortage",
|
| 38 |
+
"algorithm": "two_stage_stochastic",
|
| 39 |
+
"sla_compliance_pct": 0.0,
|
| 40 |
+
"mttr_hours": 4.61,
|
| 41 |
+
"ftf_rate_pct": 83.3,
|
| 42 |
+
"logistics_cost": 217.76,
|
| 43 |
+
"downtime_cost": 38117.22,
|
| 44 |
+
"solve_time_sec": 0.0037087000091560185
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"industry": "elevators",
|
| 48 |
+
"stress": "peak_season",
|
| 49 |
+
"algorithm": "two_stage_stochastic",
|
| 50 |
+
"sla_compliance_pct": 11.1,
|
| 51 |
+
"mttr_hours": 4.71,
|
| 52 |
+
"ftf_rate_pct": 77.8,
|
| 53 |
+
"logistics_cost": 566.36,
|
| 54 |
+
"downtime_cost": 25018.85,
|
| 55 |
+
"solve_time_sec": 0.00974899998982437
|
| 56 |
+
}
|
| 57 |
+
]
|
assets/demo/summary.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"project": "FieldOps",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"scenarios": 5,
|
| 5 |
+
"algorithms": 7,
|
| 6 |
+
"primary_metrics": [
|
| 7 |
+
"sla_compliance_pct",
|
| 8 |
+
"mean_time_to_repair_hours",
|
| 9 |
+
"first_time_fix_rate_pct",
|
| 10 |
+
"emergency_stockout_count",
|
| 11 |
+
"technician_utilization_pct",
|
| 12 |
+
"total_logistics_cost",
|
| 13 |
+
"total_downtime_cost"
|
| 14 |
+
]
|
| 15 |
+
}
|
assets/samples/sample_data_center.json
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"industry": "data_center",
|
| 3 |
+
"stress": "technician_shortage",
|
| 4 |
+
"scenario_label": "Data Center \u2014 Technician Shortage",
|
| 5 |
+
"summary": {
|
| 6 |
+
"sites": 6,
|
| 7 |
+
"technicians": 5,
|
| 8 |
+
"warehouses": 2,
|
| 9 |
+
"requests": 6
|
| 10 |
+
},
|
| 11 |
+
"optimization": {
|
| 12 |
+
"strategy": "two_stage_stochastic",
|
| 13 |
+
"algorithm": "two_stage_stochastic_programming",
|
| 14 |
+
"stage1_plan": {
|
| 15 |
+
"technician_positions": {
|
| 16 |
+
"T-01": [
|
| 17 |
+
50.97447733372627,
|
| 18 |
+
11.76305897995824
|
| 19 |
+
],
|
| 20 |
+
"T-02": [
|
| 21 |
+
50.97447733372627,
|
| 22 |
+
11.76305897995824
|
| 23 |
+
],
|
| 24 |
+
"T-03": [
|
| 25 |
+
50.97447733372627,
|
| 26 |
+
11.76305897995824
|
| 27 |
+
],
|
| 28 |
+
"T-04": [
|
| 29 |
+
50.97447733372627,
|
| 30 |
+
11.76305897995824
|
| 31 |
+
],
|
| 32 |
+
"T-05": [
|
| 33 |
+
50.97447733372627,
|
| 34 |
+
11.76305897995824
|
| 35 |
+
]
|
| 36 |
+
},
|
| 37 |
+
"inventory_targets": [
|
| 38 |
+
{
|
| 39 |
+
"warehouse_id": "WH-01",
|
| 40 |
+
"part_id": "P-005",
|
| 41 |
+
"quantity": 5,
|
| 42 |
+
"reorder_point": 2,
|
| 43 |
+
"safety_stock": 2
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"warehouse_id": "WH-01",
|
| 47 |
+
"part_id": "P-001",
|
| 48 |
+
"quantity": 2,
|
| 49 |
+
"reorder_point": 1,
|
| 50 |
+
"safety_stock": 1
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"warehouse_id": "WH-02",
|
| 54 |
+
"part_id": "P-005",
|
| 55 |
+
"quantity": 5,
|
| 56 |
+
"reorder_point": 2,
|
| 57 |
+
"safety_stock": 2
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"warehouse_id": "WH-02",
|
| 61 |
+
"part_id": "P-001",
|
| 62 |
+
"quantity": 2,
|
| 63 |
+
"reorder_point": 1,
|
| 64 |
+
"safety_stock": 1
|
| 65 |
+
}
|
| 66 |
+
],
|
| 67 |
+
"supply_contracts": [
|
| 68 |
+
{
|
| 69 |
+
"warehouse": "WH-01",
|
| 70 |
+
"part": "P-001",
|
| 71 |
+
"min_order_qty": 5,
|
| 72 |
+
"lead_time_days": 5,
|
| 73 |
+
"expedite_available": true
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"warehouse": "WH-01",
|
| 77 |
+
"part": "P-002",
|
| 78 |
+
"min_order_qty": 5,
|
| 79 |
+
"lead_time_days": 5,
|
| 80 |
+
"expedite_available": true
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"warehouse": "WH-01",
|
| 84 |
+
"part": "P-003",
|
| 85 |
+
"min_order_qty": 5,
|
| 86 |
+
"lead_time_days": 5,
|
| 87 |
+
"expedite_available": true
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"warehouse": "WH-02",
|
| 91 |
+
"part": "P-001",
|
| 92 |
+
"min_order_qty": 5,
|
| 93 |
+
"lead_time_days": 5,
|
| 94 |
+
"expedite_available": true
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"warehouse": "WH-02",
|
| 98 |
+
"part": "P-002",
|
| 99 |
+
"min_order_qty": 5,
|
| 100 |
+
"lead_time_days": 5,
|
| 101 |
+
"expedite_available": true
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"warehouse": "WH-02",
|
| 105 |
+
"part": "P-003",
|
| 106 |
+
"min_order_qty": 5,
|
| 107 |
+
"lead_time_days": 5,
|
| 108 |
+
"expedite_available": true
|
| 109 |
+
}
|
| 110 |
+
],
|
| 111 |
+
"repair_shop_capacity": 2,
|
| 112 |
+
"total_holding_cost": 1765.0866473281349,
|
| 113 |
+
"notes": [
|
| 114 |
+
"Stock vs expedite ratio: 50% (higher = more pre-positioned inventory).",
|
| 115 |
+
"Multi-echelon targets across 2 warehouses.",
|
| 116 |
+
"Estimated holding cost: $1,765 over 7 days."
|
| 117 |
+
]
|
| 118 |
+
},
|
| 119 |
+
"assignments": [
|
| 120 |
+
{
|
| 121 |
+
"request_id": "SR-0004",
|
| 122 |
+
"technician_id": "T-01",
|
| 123 |
+
"technician_name": "Tech A",
|
| 124 |
+
"warehouse_id": "WH-01",
|
| 125 |
+
"part_id": "P-005",
|
| 126 |
+
"part_source": "deferred",
|
| 127 |
+
"route_order": 1,
|
| 128 |
+
"scheduled_start_hours": 0.0,
|
| 129 |
+
"estimated_arrival_hours": 0.9399564376072906,
|
| 130 |
+
"estimated_completion_hours": 4.18995643760729,
|
| 131 |
+
"use_substitute": false,
|
| 132 |
+
"defer_to_later": true,
|
| 133 |
+
"rationale": "Scheduled \u2014 skill match 93%, part via deferred (deferred to next window)"
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"request_id": "SR-0005",
|
| 137 |
+
"technician_id": "T-01",
|
| 138 |
+
"technician_name": "Tech A",
|
| 139 |
+
"warehouse_id": "WH-02",
|
| 140 |
+
"part_id": "P-001",
|
| 141 |
+
"part_source": "deferred",
|
| 142 |
+
"route_order": 2,
|
| 143 |
+
"scheduled_start_hours": 4.015667316280312,
|
| 144 |
+
"estimated_arrival_hours": 11.388180930767685,
|
| 145 |
+
"estimated_completion_hours": 15.988180930767685,
|
| 146 |
+
"use_substitute": false,
|
| 147 |
+
"defer_to_later": true,
|
| 148 |
+
"rationale": "Scheduled \u2014 skill match 93%, part via deferred (deferred to next window)"
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"request_id": "SR-0003",
|
| 152 |
+
"technician_id": "T-01",
|
| 153 |
+
"technician_name": "Tech A",
|
| 154 |
+
"warehouse_id": "WH-01",
|
| 155 |
+
"part_id": "P-005",
|
| 156 |
+
"part_source": "warehouse_stock",
|
| 157 |
+
"route_order": 3,
|
| 158 |
+
"scheduled_start_hours": 15.287192016851948,
|
| 159 |
+
"estimated_arrival_hours": 22.408510752185045,
|
| 160 |
+
"estimated_completion_hours": 25.288510752185044,
|
| 161 |
+
"use_substitute": false,
|
| 162 |
+
"defer_to_later": false,
|
| 163 |
+
"rationale": "Emergency \u2014 skill match 102%, part via warehouse_stock"
|
| 164 |
+
},
|
| 165 |
+
{
|
| 166 |
+
"request_id": "SR-0001",
|
| 167 |
+
"technician_id": "T-01",
|
| 168 |
+
"technician_name": "Tech A",
|
| 169 |
+
"warehouse_id": "WH-01",
|
| 170 |
+
"part_id": "P-001",
|
| 171 |
+
"part_source": "warehouse_stock",
|
| 172 |
+
"route_order": 4,
|
| 173 |
+
"scheduled_start_hours": 18.16719201685195,
|
| 174 |
+
"estimated_arrival_hours": 25.288510752185044,
|
| 175 |
+
"estimated_completion_hours": 28.828510752185043,
|
| 176 |
+
"use_substitute": false,
|
| 177 |
+
"defer_to_later": false,
|
| 178 |
+
"rationale": "Scheduled \u2014 skill match 93%, part via warehouse_stock"
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
"request_id": "SR-0006",
|
| 182 |
+
"technician_id": "T-02",
|
| 183 |
+
"technician_name": "Tech B",
|
| 184 |
+
"warehouse_id": "WH-01",
|
| 185 |
+
"part_id": "P-005",
|
| 186 |
+
"part_source": "warehouse_stock",
|
| 187 |
+
"route_order": 1,
|
| 188 |
+
"scheduled_start_hours": 0.0,
|
| 189 |
+
"estimated_arrival_hours": 4.603596172719693,
|
| 190 |
+
"estimated_completion_hours": 7.813596172719693,
|
| 191 |
+
"use_substitute": false,
|
| 192 |
+
"defer_to_later": false,
|
| 193 |
+
"rationale": "Scheduled \u2014 skill match 99%, part via warehouse_stock"
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
"request_id": "SR-0002",
|
| 197 |
+
"technician_id": "T-02",
|
| 198 |
+
"technician_name": "Tech B",
|
| 199 |
+
"warehouse_id": "WH-01",
|
| 200 |
+
"part_id": "P-005",
|
| 201 |
+
"part_source": "warehouse_stock",
|
| 202 |
+
"route_order": 2,
|
| 203 |
+
"scheduled_start_hours": 3.2100630923173323,
|
| 204 |
+
"estimated_arrival_hours": 10.642771892204166,
|
| 205 |
+
"estimated_completion_hours": 12.392771892204166,
|
| 206 |
+
"use_substitute": false,
|
| 207 |
+
"defer_to_later": false,
|
| 208 |
+
"rationale": "Scheduled \u2014 skill match 99%, part via warehouse_stock"
|
| 209 |
+
}
|
| 210 |
+
],
|
| 211 |
+
"ml_predictions": [
|
| 212 |
+
{
|
| 213 |
+
"request_id": "SR-0001",
|
| 214 |
+
"site_id": "S-004",
|
| 215 |
+
"failure_probability": 0.2315,
|
| 216 |
+
"predicted_part_type": "motor_drive",
|
| 217 |
+
"predicted_duration_hours": 3.54,
|
| 218 |
+
"first_time_fix_probability": 0.7326,
|
| 219 |
+
"revisit_probability": 0.2273,
|
| 220 |
+
"confidence": 0.8963,
|
| 221 |
+
"risk_tier": "low"
|
| 222 |
+
},
|
| 223 |
+
{
|
| 224 |
+
"request_id": "SR-0002",
|
| 225 |
+
"site_id": "S-001",
|
| 226 |
+
"failure_probability": 0.0879,
|
| 227 |
+
"predicted_part_type": "communication_card",
|
| 228 |
+
"predicted_duration_hours": 1.75,
|
| 229 |
+
"first_time_fix_probability": 0.8723,
|
| 230 |
+
"revisit_probability": 0.1086,
|
| 231 |
+
"confidence": 0.8676,
|
| 232 |
+
"risk_tier": "low"
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"request_id": "SR-0003",
|
| 236 |
+
"site_id": "S-004",
|
| 237 |
+
"failure_probability": 0.4256,
|
| 238 |
+
"predicted_part_type": "motor_drive",
|
| 239 |
+
"predicted_duration_hours": 2.88,
|
| 240 |
+
"first_time_fix_probability": 0.7851,
|
| 241 |
+
"revisit_probability": 0.1827,
|
| 242 |
+
"confidence": 0.9351,
|
| 243 |
+
"risk_tier": "medium"
|
| 244 |
+
},
|
| 245 |
+
{
|
| 246 |
+
"request_id": "SR-0004",
|
| 247 |
+
"site_id": "S-001",
|
| 248 |
+
"failure_probability": 0.098,
|
| 249 |
+
"predicted_part_type": "motor_drive",
|
| 250 |
+
"predicted_duration_hours": 3.25,
|
| 251 |
+
"first_time_fix_probability": 0.812,
|
| 252 |
+
"revisit_probability": 0.1598,
|
| 253 |
+
"confidence": 0.8696,
|
| 254 |
+
"risk_tier": "low"
|
| 255 |
+
},
|
| 256 |
+
{
|
| 257 |
+
"request_id": "SR-0005",
|
| 258 |
+
"site_id": "S-002",
|
| 259 |
+
"failure_probability": 0.1279,
|
| 260 |
+
"predicted_part_type": "motor_drive",
|
| 261 |
+
"predicted_duration_hours": 4.6,
|
| 262 |
+
"first_time_fix_probability": 0.691,
|
| 263 |
+
"revisit_probability": 0.2626,
|
| 264 |
+
"confidence": 0.8756,
|
| 265 |
+
"risk_tier": "low"
|
| 266 |
+
},
|
| 267 |
+
{
|
| 268 |
+
"request_id": "SR-0006",
|
| 269 |
+
"site_id": "S-003",
|
| 270 |
+
"failure_probability": 0.1347,
|
| 271 |
+
"predicted_part_type": "motor_drive",
|
| 272 |
+
"predicted_duration_hours": 3.21,
|
| 273 |
+
"first_time_fix_probability": 0.809,
|
| 274 |
+
"revisit_probability": 0.1624,
|
| 275 |
+
"confidence": 0.8769,
|
| 276 |
+
"risk_tier": "low"
|
| 277 |
+
}
|
| 278 |
+
],
|
| 279 |
+
"metrics": {
|
| 280 |
+
"sla_compliance_pct": 0.0,
|
| 281 |
+
"mean_time_to_repair_hours": 4.61,
|
| 282 |
+
"first_time_fix_rate_pct": 83.3,
|
| 283 |
+
"emergency_stockout_count": 0,
|
| 284 |
+
"technician_utilization_pct": 33.3,
|
| 285 |
+
"total_logistics_cost": 217.76,
|
| 286 |
+
"total_downtime_cost": 38117.22,
|
| 287 |
+
"total_holding_cost": 1765.09,
|
| 288 |
+
"expedite_shipments": 0
|
| 289 |
+
},
|
| 290 |
+
"simulation": {
|
| 291 |
+
"replications": 30,
|
| 292 |
+
"sla_compliance_mean": 0.0,
|
| 293 |
+
"mttr_mean": 4.68,
|
| 294 |
+
"stockout_rate": 0.0,
|
| 295 |
+
"avg_logistics_cost": 222.06,
|
| 296 |
+
"avg_downtime_cost": 45215.93,
|
| 297 |
+
"confidence_interval_95": {
|
| 298 |
+
"sla_compliance": [
|
| 299 |
+
0.0,
|
| 300 |
+
0.0
|
| 301 |
+
],
|
| 302 |
+
"mttr": [
|
| 303 |
+
4.54,
|
| 304 |
+
4.82
|
| 305 |
+
],
|
| 306 |
+
"logistics_cost": [
|
| 307 |
+
218.66,
|
| 308 |
+
225.45
|
| 309 |
+
]
|
| 310 |
+
}
|
| 311 |
+
},
|
| 312 |
+
"solve_time_sec": 0.0037087000091560185,
|
| 313 |
+
"status": "optimal",
|
| 314 |
+
"notes": [
|
| 315 |
+
"Two-stage stochastic: 20 SAA scenarios evaluated.",
|
| 316 |
+
"Stage 1: multi-echelon inventory + technician positioning.",
|
| 317 |
+
"Stage 2: mission assignment minimizing expected logistics + downtime cost.",
|
| 318 |
+
"Monte Carlo (30 runs): SLA 0.0%, MTTR 4.7h."
|
| 319 |
+
]
|
| 320 |
+
}
|
| 321 |
+
}
|
assets/samples/sample_elevators.json
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"industry": "elevators",
|
| 3 |
+
"stress": "peak_season",
|
| 4 |
+
"scenario_label": "Elevators & Lifts \u2014 Peak Season",
|
| 5 |
+
"summary": {
|
| 6 |
+
"sites": 18,
|
| 7 |
+
"technicians": 10,
|
| 8 |
+
"warehouses": 2,
|
| 9 |
+
"requests": 9
|
| 10 |
+
},
|
| 11 |
+
"optimization": {
|
| 12 |
+
"strategy": "two_stage_stochastic",
|
| 13 |
+
"algorithm": "two_stage_stochastic_programming",
|
| 14 |
+
"stage1_plan": {
|
| 15 |
+
"technician_positions": {
|
| 16 |
+
"T-01": [
|
| 17 |
+
50.73799305374447,
|
| 18 |
+
11.603656687362223
|
| 19 |
+
],
|
| 20 |
+
"T-02": [
|
| 21 |
+
50.73799305374447,
|
| 22 |
+
11.603656687362223
|
| 23 |
+
],
|
| 24 |
+
"T-03": [
|
| 25 |
+
50.73799305374447,
|
| 26 |
+
11.603656687362223
|
| 27 |
+
],
|
| 28 |
+
"T-04": [
|
| 29 |
+
50.73799305374447,
|
| 30 |
+
11.603656687362223
|
| 31 |
+
],
|
| 32 |
+
"T-05": [
|
| 33 |
+
50.73799305374447,
|
| 34 |
+
11.603656687362223
|
| 35 |
+
],
|
| 36 |
+
"T-06": [
|
| 37 |
+
50.73799305374447,
|
| 38 |
+
11.603656687362223
|
| 39 |
+
],
|
| 40 |
+
"T-07": [
|
| 41 |
+
50.73799305374447,
|
| 42 |
+
11.603656687362223
|
| 43 |
+
],
|
| 44 |
+
"T-08": [
|
| 45 |
+
50.73799305374447,
|
| 46 |
+
11.603656687362223
|
| 47 |
+
],
|
| 48 |
+
"T-09": [
|
| 49 |
+
50.73799305374447,
|
| 50 |
+
11.603656687362223
|
| 51 |
+
],
|
| 52 |
+
"T-10": [
|
| 53 |
+
50.73799305374447,
|
| 54 |
+
11.603656687362223
|
| 55 |
+
]
|
| 56 |
+
},
|
| 57 |
+
"inventory_targets": [
|
| 58 |
+
{
|
| 59 |
+
"warehouse_id": "WH-01",
|
| 60 |
+
"part_id": "P-002",
|
| 61 |
+
"quantity": 3,
|
| 62 |
+
"reorder_point": 1,
|
| 63 |
+
"safety_stock": 1
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"warehouse_id": "WH-01",
|
| 67 |
+
"part_id": "P-001",
|
| 68 |
+
"quantity": 3,
|
| 69 |
+
"reorder_point": 1,
|
| 70 |
+
"safety_stock": 1
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"warehouse_id": "WH-01",
|
| 74 |
+
"part_id": "P-003",
|
| 75 |
+
"quantity": 2,
|
| 76 |
+
"reorder_point": 1,
|
| 77 |
+
"safety_stock": 1
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"warehouse_id": "WH-01",
|
| 81 |
+
"part_id": "P-004",
|
| 82 |
+
"quantity": 2,
|
| 83 |
+
"reorder_point": 1,
|
| 84 |
+
"safety_stock": 1
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"warehouse_id": "WH-01",
|
| 88 |
+
"part_id": "P-006",
|
| 89 |
+
"quantity": 2,
|
| 90 |
+
"reorder_point": 1,
|
| 91 |
+
"safety_stock": 1
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
"warehouse_id": "WH-02",
|
| 95 |
+
"part_id": "P-002",
|
| 96 |
+
"quantity": 3,
|
| 97 |
+
"reorder_point": 1,
|
| 98 |
+
"safety_stock": 1
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"warehouse_id": "WH-02",
|
| 102 |
+
"part_id": "P-001",
|
| 103 |
+
"quantity": 3,
|
| 104 |
+
"reorder_point": 1,
|
| 105 |
+
"safety_stock": 1
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"warehouse_id": "WH-02",
|
| 109 |
+
"part_id": "P-003",
|
| 110 |
+
"quantity": 2,
|
| 111 |
+
"reorder_point": 1,
|
| 112 |
+
"safety_stock": 1
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"warehouse_id": "WH-02",
|
| 116 |
+
"part_id": "P-004",
|
| 117 |
+
"quantity": 2,
|
| 118 |
+
"reorder_point": 1,
|
| 119 |
+
"safety_stock": 1
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"warehouse_id": "WH-02",
|
| 123 |
+
"part_id": "P-006",
|
| 124 |
+
"quantity": 2,
|
| 125 |
+
"reorder_point": 1,
|
| 126 |
+
"safety_stock": 1
|
| 127 |
+
}
|
| 128 |
+
],
|
| 129 |
+
"supply_contracts": [
|
| 130 |
+
{
|
| 131 |
+
"warehouse": "WH-01",
|
| 132 |
+
"part": "P-001",
|
| 133 |
+
"min_order_qty": 5,
|
| 134 |
+
"lead_time_days": 5,
|
| 135 |
+
"expedite_available": true
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"warehouse": "WH-01",
|
| 139 |
+
"part": "P-002",
|
| 140 |
+
"min_order_qty": 5,
|
| 141 |
+
"lead_time_days": 5,
|
| 142 |
+
"expedite_available": true
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
"warehouse": "WH-01",
|
| 146 |
+
"part": "P-003",
|
| 147 |
+
"min_order_qty": 5,
|
| 148 |
+
"lead_time_days": 5,
|
| 149 |
+
"expedite_available": true
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"warehouse": "WH-02",
|
| 153 |
+
"part": "P-001",
|
| 154 |
+
"min_order_qty": 5,
|
| 155 |
+
"lead_time_days": 5,
|
| 156 |
+
"expedite_available": true
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
"warehouse": "WH-02",
|
| 160 |
+
"part": "P-002",
|
| 161 |
+
"min_order_qty": 5,
|
| 162 |
+
"lead_time_days": 5,
|
| 163 |
+
"expedite_available": true
|
| 164 |
+
},
|
| 165 |
+
{
|
| 166 |
+
"warehouse": "WH-02",
|
| 167 |
+
"part": "P-003",
|
| 168 |
+
"min_order_qty": 5,
|
| 169 |
+
"lead_time_days": 5,
|
| 170 |
+
"expedite_available": true
|
| 171 |
+
}
|
| 172 |
+
],
|
| 173 |
+
"repair_shop_capacity": 3,
|
| 174 |
+
"total_holding_cost": 2129.891162348705,
|
| 175 |
+
"notes": [
|
| 176 |
+
"Stock vs expedite ratio: 50% (higher = more pre-positioned inventory).",
|
| 177 |
+
"Multi-echelon targets across 2 warehouses.",
|
| 178 |
+
"Estimated holding cost: $2,130 over 7 days."
|
| 179 |
+
]
|
| 180 |
+
},
|
| 181 |
+
"assignments": [
|
| 182 |
+
{
|
| 183 |
+
"request_id": "SR-0002",
|
| 184 |
+
"technician_id": "T-02",
|
| 185 |
+
"technician_name": "Tech B",
|
| 186 |
+
"warehouse_id": "WH-01",
|
| 187 |
+
"part_id": "P-002",
|
| 188 |
+
"part_source": "warehouse_stock",
|
| 189 |
+
"route_order": 1,
|
| 190 |
+
"scheduled_start_hours": 0.0,
|
| 191 |
+
"estimated_arrival_hours": 4.471727329434108,
|
| 192 |
+
"estimated_completion_hours": 9.051727329434108,
|
| 193 |
+
"use_substitute": false,
|
| 194 |
+
"defer_to_later": false,
|
| 195 |
+
"rationale": "Scheduled \u2014 skill match 99%, part via warehouse_stock"
|
| 196 |
+
},
|
| 197 |
+
{
|
| 198 |
+
"request_id": "SR-0004",
|
| 199 |
+
"technician_id": "T-02",
|
| 200 |
+
"technician_name": "Tech B",
|
| 201 |
+
"warehouse_id": "WH-01",
|
| 202 |
+
"part_id": "P-001",
|
| 203 |
+
"part_source": "deferred",
|
| 204 |
+
"route_order": 2,
|
| 205 |
+
"scheduled_start_hours": 8.218847521029023,
|
| 206 |
+
"estimated_arrival_hours": 16.112410451298178,
|
| 207 |
+
"estimated_completion_hours": 20.562410451298177,
|
| 208 |
+
"use_substitute": false,
|
| 209 |
+
"defer_to_later": true,
|
| 210 |
+
"rationale": "Scheduled \u2014 skill match 99%, part via deferred (deferred to next window)"
|
| 211 |
+
},
|
| 212 |
+
{
|
| 213 |
+
"request_id": "SR-0009",
|
| 214 |
+
"technician_id": "T-02",
|
| 215 |
+
"technician_name": "Tech B",
|
| 216 |
+
"warehouse_id": "WH-02",
|
| 217 |
+
"part_id": "P-006",
|
| 218 |
+
"part_source": "warehouse_stock",
|
| 219 |
+
"route_order": 3,
|
| 220 |
+
"scheduled_start_hours": 17.328286148354728,
|
| 221 |
+
"estimated_arrival_hours": 23.120365200771747,
|
| 222 |
+
"estimated_completion_hours": 24.870365200771747,
|
| 223 |
+
"use_substitute": false,
|
| 224 |
+
"defer_to_later": false,
|
| 225 |
+
"rationale": "Scheduled \u2014 skill match 99%, part via warehouse_stock"
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"request_id": "SR-0008",
|
| 229 |
+
"technician_id": "T-06",
|
| 230 |
+
"technician_name": "Tech F",
|
| 231 |
+
"warehouse_id": "WH-01",
|
| 232 |
+
"part_id": "P-004",
|
| 233 |
+
"part_source": "deferred",
|
| 234 |
+
"route_order": 1,
|
| 235 |
+
"scheduled_start_hours": 0.0,
|
| 236 |
+
"estimated_arrival_hours": 0.5885734974225917,
|
| 237 |
+
"estimated_completion_hours": 4.178573497422591,
|
| 238 |
+
"use_substitute": false,
|
| 239 |
+
"defer_to_later": true,
|
| 240 |
+
"rationale": "Scheduled \u2014 skill match 95%, part via deferred (deferred to next window)"
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"request_id": "SR-0003",
|
| 244 |
+
"technician_id": "T-06",
|
| 245 |
+
"technician_name": "Tech F",
|
| 246 |
+
"warehouse_id": "WH-02",
|
| 247 |
+
"part_id": "P-001",
|
| 248 |
+
"part_source": "deferred",
|
| 249 |
+
"route_order": 2,
|
| 250 |
+
"scheduled_start_hours": 3.609688002162164,
|
| 251 |
+
"estimated_arrival_hours": 13.281694759502987,
|
| 252 |
+
"estimated_completion_hours": 16.961694759502986,
|
| 253 |
+
"use_substitute": false,
|
| 254 |
+
"defer_to_later": true,
|
| 255 |
+
"rationale": "Scheduled \u2014 skill match 95%, part via deferred (deferred to next window)"
|
| 256 |
+
},
|
| 257 |
+
{
|
| 258 |
+
"request_id": "SR-0001",
|
| 259 |
+
"technician_id": "T-09",
|
| 260 |
+
"technician_name": "Tech I",
|
| 261 |
+
"warehouse_id": "WH-01",
|
| 262 |
+
"part_id": "P-002",
|
| 263 |
+
"part_source": "warehouse_stock",
|
| 264 |
+
"route_order": 1,
|
| 265 |
+
"scheduled_start_hours": 0.0,
|
| 266 |
+
"estimated_arrival_hours": 0.9863209197037524,
|
| 267 |
+
"estimated_completion_hours": 4.576320919703752,
|
| 268 |
+
"use_substitute": false,
|
| 269 |
+
"defer_to_later": false,
|
| 270 |
+
"rationale": "Scheduled \u2014 skill match 94%, part via warehouse_stock"
|
| 271 |
+
}
|
| 272 |
+
],
|
| 273 |
+
"ml_predictions": [
|
| 274 |
+
{
|
| 275 |
+
"request_id": "SR-0001",
|
| 276 |
+
"site_id": "S-009",
|
| 277 |
+
"failure_probability": 0.1978,
|
| 278 |
+
"predicted_part_type": "hydraulic_pump",
|
| 279 |
+
"predicted_duration_hours": 3.59,
|
| 280 |
+
"first_time_fix_probability": 0.7352,
|
| 281 |
+
"revisit_probability": 0.2251,
|
| 282 |
+
"confidence": 0.8896,
|
| 283 |
+
"risk_tier": "low"
|
| 284 |
+
},
|
| 285 |
+
{
|
| 286 |
+
"request_id": "SR-0002",
|
| 287 |
+
"site_id": "S-018",
|
| 288 |
+
"failure_probability": 0.1394,
|
| 289 |
+
"predicted_part_type": "sensor_module",
|
| 290 |
+
"predicted_duration_hours": 4.58,
|
| 291 |
+
"first_time_fix_probability": 0.6902,
|
| 292 |
+
"revisit_probability": 0.2633,
|
| 293 |
+
"confidence": 0.8779,
|
| 294 |
+
"risk_tier": "low"
|
| 295 |
+
},
|
| 296 |
+
{
|
| 297 |
+
"request_id": "SR-0003",
|
| 298 |
+
"site_id": "S-007",
|
| 299 |
+
"failure_probability": 0.1235,
|
| 300 |
+
"predicted_part_type": "hydraulic_pump",
|
| 301 |
+
"predicted_duration_hours": 3.68,
|
| 302 |
+
"first_time_fix_probability": 0.7407,
|
| 303 |
+
"revisit_probability": 0.2204,
|
| 304 |
+
"confidence": 0.8747,
|
| 305 |
+
"risk_tier": "low"
|
| 306 |
+
},
|
| 307 |
+
{
|
| 308 |
+
"request_id": "SR-0004",
|
| 309 |
+
"site_id": "S-009",
|
| 310 |
+
"failure_probability": 0.221,
|
| 311 |
+
"predicted_part_type": "sensor_module",
|
| 312 |
+
"predicted_duration_hours": 4.45,
|
| 313 |
+
"first_time_fix_probability": 0.6845,
|
| 314 |
+
"revisit_probability": 0.2681,
|
| 315 |
+
"confidence": 0.8942,
|
| 316 |
+
"risk_tier": "low"
|
| 317 |
+
},
|
| 318 |
+
{
|
| 319 |
+
"request_id": "SR-0005",
|
| 320 |
+
"site_id": "S-010",
|
| 321 |
+
"failure_probability": 0.4651,
|
| 322 |
+
"predicted_part_type": "hydraulic_pump",
|
| 323 |
+
"predicted_duration_hours": 3.24,
|
| 324 |
+
"first_time_fix_probability": 0.7151,
|
| 325 |
+
"revisit_probability": 0.2421,
|
| 326 |
+
"confidence": 0.943,
|
| 327 |
+
"risk_tier": "medium"
|
| 328 |
+
},
|
| 329 |
+
{
|
| 330 |
+
"request_id": "SR-0006",
|
| 331 |
+
"site_id": "S-002",
|
| 332 |
+
"failure_probability": 0.287,
|
| 333 |
+
"predicted_part_type": "bearing_kit",
|
| 334 |
+
"predicted_duration_hours": 3.04,
|
| 335 |
+
"first_time_fix_probability": 0.7965,
|
| 336 |
+
"revisit_probability": 0.173,
|
| 337 |
+
"confidence": 0.9074,
|
| 338 |
+
"risk_tier": "low"
|
| 339 |
+
},
|
| 340 |
+
{
|
| 341 |
+
"request_id": "SR-0007",
|
| 342 |
+
"site_id": "S-017",
|
| 343 |
+
"failure_probability": 0.6365,
|
| 344 |
+
"predicted_part_type": "hydraulic_pump",
|
| 345 |
+
"predicted_duration_hours": 3.03,
|
| 346 |
+
"first_time_fix_probability": 0.7023,
|
| 347 |
+
"revisit_probability": 0.2531,
|
| 348 |
+
"confidence": 0.9227,
|
| 349 |
+
"risk_tier": "high"
|
| 350 |
+
},
|
| 351 |
+
{
|
| 352 |
+
"request_id": "SR-0008",
|
| 353 |
+
"site_id": "S-008",
|
| 354 |
+
"failure_probability": 0.1978,
|
| 355 |
+
"predicted_part_type": "communication_card",
|
| 356 |
+
"predicted_duration_hours": 3.59,
|
| 357 |
+
"first_time_fix_probability": 0.7352,
|
| 358 |
+
"revisit_probability": 0.2251,
|
| 359 |
+
"confidence": 0.8896,
|
| 360 |
+
"risk_tier": "low"
|
| 361 |
+
},
|
| 362 |
+
{
|
| 363 |
+
"request_id": "SR-0009",
|
| 364 |
+
"site_id": "S-014",
|
| 365 |
+
"failure_probability": 0.0903,
|
| 366 |
+
"predicted_part_type": "communication_card",
|
| 367 |
+
"predicted_duration_hours": 1.75,
|
| 368 |
+
"first_time_fix_probability": 0.8721,
|
| 369 |
+
"revisit_probability": 0.1088,
|
| 370 |
+
"confidence": 0.8681,
|
| 371 |
+
"risk_tier": "low"
|
| 372 |
+
}
|
| 373 |
+
],
|
| 374 |
+
"metrics": {
|
| 375 |
+
"sla_compliance_pct": 11.1,
|
| 376 |
+
"mean_time_to_repair_hours": 4.71,
|
| 377 |
+
"first_time_fix_rate_pct": 77.8,
|
| 378 |
+
"emergency_stockout_count": 0,
|
| 379 |
+
"technician_utilization_pct": 22.5,
|
| 380 |
+
"total_logistics_cost": 566.36,
|
| 381 |
+
"total_downtime_cost": 25018.85,
|
| 382 |
+
"total_holding_cost": 2129.89,
|
| 383 |
+
"expedite_shipments": 0
|
| 384 |
+
},
|
| 385 |
+
"simulation": {
|
| 386 |
+
"replications": 30,
|
| 387 |
+
"sla_compliance_mean": 17.8,
|
| 388 |
+
"mttr_mean": 4.91,
|
| 389 |
+
"stockout_rate": 0.0,
|
| 390 |
+
"avg_logistics_cost": 462.09,
|
| 391 |
+
"avg_downtime_cost": 8404.96,
|
| 392 |
+
"confidence_interval_95": {
|
| 393 |
+
"sla_compliance": [
|
| 394 |
+
16.3,
|
| 395 |
+
19.31
|
| 396 |
+
],
|
| 397 |
+
"mttr": [
|
| 398 |
+
4.78,
|
| 399 |
+
5.04
|
| 400 |
+
],
|
| 401 |
+
"logistics_cost": [
|
| 402 |
+
455.11,
|
| 403 |
+
469.07
|
| 404 |
+
]
|
| 405 |
+
}
|
| 406 |
+
},
|
| 407 |
+
"solve_time_sec": 0.00974899998982437,
|
| 408 |
+
"status": "optimal",
|
| 409 |
+
"notes": [
|
| 410 |
+
"Two-stage stochastic: 20 SAA scenarios evaluated.",
|
| 411 |
+
"Stage 1: multi-echelon inventory + technician positioning.",
|
| 412 |
+
"Stage 2: mission assignment minimizing expected logistics + downtime cost.",
|
| 413 |
+
"Monte Carlo (30 runs): SLA 17.8%, MTTR 4.9h."
|
| 414 |
+
]
|
| 415 |
+
}
|
| 416 |
+
}
|
assets/samples/sample_industrial_equipment.json
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"industry": "industrial_equipment",
|
| 3 |
+
"stress": "baseline",
|
| 4 |
+
"scenario_label": "Industrial Equipment \u2014 Baseline Demand",
|
| 5 |
+
"summary": {
|
| 6 |
+
"sites": 12,
|
| 7 |
+
"technicians": 8,
|
| 8 |
+
"warehouses": 3,
|
| 9 |
+
"requests": 6
|
| 10 |
+
},
|
| 11 |
+
"optimization": {
|
| 12 |
+
"strategy": "two_stage_stochastic",
|
| 13 |
+
"algorithm": "two_stage_stochastic_programming",
|
| 14 |
+
"stage1_plan": {
|
| 15 |
+
"technician_positions": {
|
| 16 |
+
"T-01": [
|
| 17 |
+
50.851132588450646,
|
| 18 |
+
11.498955066615594
|
| 19 |
+
],
|
| 20 |
+
"T-02": [
|
| 21 |
+
50.851132588450646,
|
| 22 |
+
11.498955066615594
|
| 23 |
+
],
|
| 24 |
+
"T-03": [
|
| 25 |
+
50.851132588450646,
|
| 26 |
+
11.498955066615594
|
| 27 |
+
],
|
| 28 |
+
"T-04": [
|
| 29 |
+
50.851132588450646,
|
| 30 |
+
11.498955066615594
|
| 31 |
+
],
|
| 32 |
+
"T-05": [
|
| 33 |
+
50.851132588450646,
|
| 34 |
+
11.498955066615594
|
| 35 |
+
],
|
| 36 |
+
"T-06": [
|
| 37 |
+
50.851132588450646,
|
| 38 |
+
11.498955066615594
|
| 39 |
+
],
|
| 40 |
+
"T-07": [
|
| 41 |
+
50.851132588450646,
|
| 42 |
+
11.498955066615594
|
| 43 |
+
],
|
| 44 |
+
"T-08": [
|
| 45 |
+
50.851132588450646,
|
| 46 |
+
11.498955066615594
|
| 47 |
+
]
|
| 48 |
+
},
|
| 49 |
+
"inventory_targets": [
|
| 50 |
+
{
|
| 51 |
+
"warehouse_id": "WH-01",
|
| 52 |
+
"part_id": "P-005",
|
| 53 |
+
"quantity": 2,
|
| 54 |
+
"reorder_point": 1,
|
| 55 |
+
"safety_stock": 1
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"warehouse_id": "WH-01",
|
| 59 |
+
"part_id": "P-004",
|
| 60 |
+
"quantity": 2,
|
| 61 |
+
"reorder_point": 1,
|
| 62 |
+
"safety_stock": 1
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"warehouse_id": "WH-01",
|
| 66 |
+
"part_id": "P-003",
|
| 67 |
+
"quantity": 2,
|
| 68 |
+
"reorder_point": 1,
|
| 69 |
+
"safety_stock": 1
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"warehouse_id": "WH-01",
|
| 73 |
+
"part_id": "P-002",
|
| 74 |
+
"quantity": 2,
|
| 75 |
+
"reorder_point": 1,
|
| 76 |
+
"safety_stock": 1
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"warehouse_id": "WH-02",
|
| 80 |
+
"part_id": "P-005",
|
| 81 |
+
"quantity": 2,
|
| 82 |
+
"reorder_point": 1,
|
| 83 |
+
"safety_stock": 1
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"warehouse_id": "WH-02",
|
| 87 |
+
"part_id": "P-004",
|
| 88 |
+
"quantity": 2,
|
| 89 |
+
"reorder_point": 1,
|
| 90 |
+
"safety_stock": 1
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"warehouse_id": "WH-02",
|
| 94 |
+
"part_id": "P-003",
|
| 95 |
+
"quantity": 2,
|
| 96 |
+
"reorder_point": 1,
|
| 97 |
+
"safety_stock": 1
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"warehouse_id": "WH-02",
|
| 101 |
+
"part_id": "P-002",
|
| 102 |
+
"quantity": 2,
|
| 103 |
+
"reorder_point": 1,
|
| 104 |
+
"safety_stock": 1
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
"warehouse_id": "WH-03",
|
| 108 |
+
"part_id": "P-005",
|
| 109 |
+
"quantity": 2,
|
| 110 |
+
"reorder_point": 1,
|
| 111 |
+
"safety_stock": 1
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"warehouse_id": "WH-03",
|
| 115 |
+
"part_id": "P-004",
|
| 116 |
+
"quantity": 2,
|
| 117 |
+
"reorder_point": 1,
|
| 118 |
+
"safety_stock": 1
|
| 119 |
+
},
|
| 120 |
+
{
|
| 121 |
+
"warehouse_id": "WH-03",
|
| 122 |
+
"part_id": "P-003",
|
| 123 |
+
"quantity": 2,
|
| 124 |
+
"reorder_point": 1,
|
| 125 |
+
"safety_stock": 1
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"warehouse_id": "WH-03",
|
| 129 |
+
"part_id": "P-002",
|
| 130 |
+
"quantity": 2,
|
| 131 |
+
"reorder_point": 1,
|
| 132 |
+
"safety_stock": 1
|
| 133 |
+
}
|
| 134 |
+
],
|
| 135 |
+
"supply_contracts": [
|
| 136 |
+
{
|
| 137 |
+
"warehouse": "WH-01",
|
| 138 |
+
"part": "P-001",
|
| 139 |
+
"min_order_qty": 5,
|
| 140 |
+
"lead_time_days": 5,
|
| 141 |
+
"expedite_available": true
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"warehouse": "WH-01",
|
| 145 |
+
"part": "P-002",
|
| 146 |
+
"min_order_qty": 5,
|
| 147 |
+
"lead_time_days": 5,
|
| 148 |
+
"expedite_available": true
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"warehouse": "WH-01",
|
| 152 |
+
"part": "P-003",
|
| 153 |
+
"min_order_qty": 5,
|
| 154 |
+
"lead_time_days": 5,
|
| 155 |
+
"expedite_available": true
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
"warehouse": "WH-02",
|
| 159 |
+
"part": "P-001",
|
| 160 |
+
"min_order_qty": 5,
|
| 161 |
+
"lead_time_days": 5,
|
| 162 |
+
"expedite_available": true
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
"warehouse": "WH-02",
|
| 166 |
+
"part": "P-002",
|
| 167 |
+
"min_order_qty": 5,
|
| 168 |
+
"lead_time_days": 5,
|
| 169 |
+
"expedite_available": true
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
"warehouse": "WH-02",
|
| 173 |
+
"part": "P-003",
|
| 174 |
+
"min_order_qty": 5,
|
| 175 |
+
"lead_time_days": 5,
|
| 176 |
+
"expedite_available": true
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
"warehouse": "WH-03",
|
| 180 |
+
"part": "P-001",
|
| 181 |
+
"min_order_qty": 5,
|
| 182 |
+
"lead_time_days": 5,
|
| 183 |
+
"expedite_available": true
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"warehouse": "WH-03",
|
| 187 |
+
"part": "P-002",
|
| 188 |
+
"min_order_qty": 5,
|
| 189 |
+
"lead_time_days": 5,
|
| 190 |
+
"expedite_available": true
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
"warehouse": "WH-03",
|
| 194 |
+
"part": "P-003",
|
| 195 |
+
"min_order_qty": 5,
|
| 196 |
+
"lead_time_days": 5,
|
| 197 |
+
"expedite_available": true
|
| 198 |
+
}
|
| 199 |
+
],
|
| 200 |
+
"repair_shop_capacity": 2,
|
| 201 |
+
"total_holding_cost": 1644.4361424549948,
|
| 202 |
+
"notes": [
|
| 203 |
+
"Stock vs expedite ratio: 50% (higher = more pre-positioned inventory).",
|
| 204 |
+
"Multi-echelon targets across 3 warehouses.",
|
| 205 |
+
"Estimated holding cost: $1,644 over 7 days."
|
| 206 |
+
]
|
| 207 |
+
},
|
| 208 |
+
"assignments": [
|
| 209 |
+
{
|
| 210 |
+
"request_id": "SR-0006",
|
| 211 |
+
"technician_id": "T-02",
|
| 212 |
+
"technician_name": "Tech B",
|
| 213 |
+
"warehouse_id": "WH-02",
|
| 214 |
+
"part_id": "P-002",
|
| 215 |
+
"part_source": "warehouse_stock",
|
| 216 |
+
"route_order": 1,
|
| 217 |
+
"scheduled_start_hours": 0.0,
|
| 218 |
+
"estimated_arrival_hours": 0.6553569009603153,
|
| 219 |
+
"estimated_completion_hours": 4.245356900960315,
|
| 220 |
+
"use_substitute": false,
|
| 221 |
+
"defer_to_later": false,
|
| 222 |
+
"rationale": "Scheduled \u2014 skill match 89%, part via warehouse_stock"
|
| 223 |
+
},
|
| 224 |
+
{
|
| 225 |
+
"request_id": "SR-0005",
|
| 226 |
+
"technician_id": "T-04",
|
| 227 |
+
"technician_name": "Tech D",
|
| 228 |
+
"warehouse_id": "WH-01",
|
| 229 |
+
"part_id": "P-003",
|
| 230 |
+
"part_source": "warehouse_stock",
|
| 231 |
+
"route_order": 1,
|
| 232 |
+
"scheduled_start_hours": 0.0,
|
| 233 |
+
"estimated_arrival_hours": 0.7832393948734134,
|
| 234 |
+
"estimated_completion_hours": 2.3932393948734134,
|
| 235 |
+
"use_substitute": false,
|
| 236 |
+
"defer_to_later": false,
|
| 237 |
+
"rationale": "Scheduled \u2014 skill match 95%, part via warehouse_stock"
|
| 238 |
+
},
|
| 239 |
+
{
|
| 240 |
+
"request_id": "SR-0002",
|
| 241 |
+
"technician_id": "T-04",
|
| 242 |
+
"technician_name": "Tech D",
|
| 243 |
+
"warehouse_id": "WH-01",
|
| 244 |
+
"part_id": "P-004",
|
| 245 |
+
"part_source": "deferred",
|
| 246 |
+
"route_order": 2,
|
| 247 |
+
"scheduled_start_hours": 1.674930030939441,
|
| 248 |
+
"estimated_arrival_hours": 6.747959255100246,
|
| 249 |
+
"estimated_completion_hours": 9.837959255100245,
|
| 250 |
+
"use_substitute": false,
|
| 251 |
+
"defer_to_later": true,
|
| 252 |
+
"rationale": "Scheduled \u2014 skill match 95%, part via deferred (deferred to next window)"
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"request_id": "SR-0003",
|
| 256 |
+
"technician_id": "T-04",
|
| 257 |
+
"technician_name": "Tech D",
|
| 258 |
+
"warehouse_id": "WH-03",
|
| 259 |
+
"part_id": "P-001",
|
| 260 |
+
"part_source": "deferred",
|
| 261 |
+
"route_order": 3,
|
| 262 |
+
"scheduled_start_hours": 5.567661069863554,
|
| 263 |
+
"estimated_arrival_hours": 13.851400960258042,
|
| 264 |
+
"estimated_completion_hours": 17.441400960258044,
|
| 265 |
+
"use_substitute": false,
|
| 266 |
+
"defer_to_later": true,
|
| 267 |
+
"rationale": "Scheduled \u2014 skill match 95%, part via deferred (deferred to next window)"
|
| 268 |
+
},
|
| 269 |
+
{
|
| 270 |
+
"request_id": "SR-0001",
|
| 271 |
+
"technician_id": "T-04",
|
| 272 |
+
"technician_name": "Tech D",
|
| 273 |
+
"warehouse_id": "WH-02",
|
| 274 |
+
"part_id": "P-005",
|
| 275 |
+
"part_source": "warehouse_stock",
|
| 276 |
+
"route_order": 4,
|
| 277 |
+
"scheduled_start_hours": 15.758638309028454,
|
| 278 |
+
"estimated_arrival_hours": 24.623161217266052,
|
| 279 |
+
"estimated_completion_hours": 27.783161217266052,
|
| 280 |
+
"use_substitute": false,
|
| 281 |
+
"defer_to_later": false,
|
| 282 |
+
"rationale": "Scheduled \u2014 skill match 95%, part via warehouse_stock"
|
| 283 |
+
}
|
| 284 |
+
],
|
| 285 |
+
"ml_predictions": [
|
| 286 |
+
{
|
| 287 |
+
"request_id": "SR-0001",
|
| 288 |
+
"site_id": "S-002",
|
| 289 |
+
"failure_probability": 0.1751,
|
| 290 |
+
"predicted_part_type": "motor_drive",
|
| 291 |
+
"predicted_duration_hours": 3.16,
|
| 292 |
+
"first_time_fix_probability": 0.8056,
|
| 293 |
+
"revisit_probability": 0.1652,
|
| 294 |
+
"confidence": 0.885,
|
| 295 |
+
"risk_tier": "low"
|
| 296 |
+
},
|
| 297 |
+
{
|
| 298 |
+
"request_id": "SR-0002",
|
| 299 |
+
"site_id": "S-010",
|
| 300 |
+
"failure_probability": 0.2405,
|
| 301 |
+
"predicted_part_type": "control_board",
|
| 302 |
+
"predicted_duration_hours": 3.09,
|
| 303 |
+
"first_time_fix_probability": 0.8003,
|
| 304 |
+
"revisit_probability": 0.1698,
|
| 305 |
+
"confidence": 0.8981,
|
| 306 |
+
"risk_tier": "low"
|
| 307 |
+
},
|
| 308 |
+
{
|
| 309 |
+
"request_id": "SR-0003",
|
| 310 |
+
"site_id": "S-011",
|
| 311 |
+
"failure_probability": 0.1978,
|
| 312 |
+
"predicted_part_type": "hydraulic_pump",
|
| 313 |
+
"predicted_duration_hours": 3.59,
|
| 314 |
+
"first_time_fix_probability": 0.7352,
|
| 315 |
+
"revisit_probability": 0.2251,
|
| 316 |
+
"confidence": 0.8896,
|
| 317 |
+
"risk_tier": "low"
|
| 318 |
+
},
|
| 319 |
+
{
|
| 320 |
+
"request_id": "SR-0004",
|
| 321 |
+
"site_id": "S-011",
|
| 322 |
+
"failure_probability": 0.3775,
|
| 323 |
+
"predicted_part_type": "hydraulic_pump",
|
| 324 |
+
"predicted_duration_hours": 3.36,
|
| 325 |
+
"first_time_fix_probability": 0.7217,
|
| 326 |
+
"revisit_probability": 0.2366,
|
| 327 |
+
"confidence": 0.9255,
|
| 328 |
+
"risk_tier": "medium"
|
| 329 |
+
},
|
| 330 |
+
{
|
| 331 |
+
"request_id": "SR-0005",
|
| 332 |
+
"site_id": "S-004",
|
| 333 |
+
"failure_probability": 0.3208,
|
| 334 |
+
"predicted_part_type": "hydraulic_pump",
|
| 335 |
+
"predicted_duration_hours": 1.61,
|
| 336 |
+
"first_time_fix_probability": 0.8518,
|
| 337 |
+
"revisit_probability": 0.126,
|
| 338 |
+
"confidence": 0.9142,
|
| 339 |
+
"risk_tier": "low"
|
| 340 |
+
},
|
| 341 |
+
{
|
| 342 |
+
"request_id": "SR-0006",
|
| 343 |
+
"site_id": "S-002",
|
| 344 |
+
"failure_probability": 0.1978,
|
| 345 |
+
"predicted_part_type": "hydraulic_pump",
|
| 346 |
+
"predicted_duration_hours": 3.59,
|
| 347 |
+
"first_time_fix_probability": 0.7352,
|
| 348 |
+
"revisit_probability": 0.2251,
|
| 349 |
+
"confidence": 0.8896,
|
| 350 |
+
"risk_tier": "low"
|
| 351 |
+
}
|
| 352 |
+
],
|
| 353 |
+
"metrics": {
|
| 354 |
+
"sla_compliance_pct": 100.0,
|
| 355 |
+
"mean_time_to_repair_hours": 4.31,
|
| 356 |
+
"first_time_fix_rate_pct": 100.0,
|
| 357 |
+
"emergency_stockout_count": 0,
|
| 358 |
+
"technician_utilization_pct": 15.8,
|
| 359 |
+
"total_logistics_cost": 597.53,
|
| 360 |
+
"total_downtime_cost": 17622.45,
|
| 361 |
+
"total_holding_cost": 1644.44,
|
| 362 |
+
"expedite_shipments": 0
|
| 363 |
+
},
|
| 364 |
+
"simulation": {
|
| 365 |
+
"replications": 30,
|
| 366 |
+
"sla_compliance_mean": 100.0,
|
| 367 |
+
"mttr_mean": 4.33,
|
| 368 |
+
"stockout_rate": 0.0,
|
| 369 |
+
"avg_logistics_cost": 543.76,
|
| 370 |
+
"avg_downtime_cost": 14486.99,
|
| 371 |
+
"confidence_interval_95": {
|
| 372 |
+
"sla_compliance": [
|
| 373 |
+
100.0,
|
| 374 |
+
100.0
|
| 375 |
+
],
|
| 376 |
+
"mttr": [
|
| 377 |
+
4.2,
|
| 378 |
+
4.46
|
| 379 |
+
],
|
| 380 |
+
"logistics_cost": [
|
| 381 |
+
535.44,
|
| 382 |
+
552.07
|
| 383 |
+
]
|
| 384 |
+
}
|
| 385 |
+
},
|
| 386 |
+
"solve_time_sec": 0.011503599991556257,
|
| 387 |
+
"status": "optimal",
|
| 388 |
+
"notes": [
|
| 389 |
+
"Two-stage stochastic: 20 SAA scenarios evaluated.",
|
| 390 |
+
"Stage 1: multi-echelon inventory + technician positioning.",
|
| 391 |
+
"Stage 2: mission assignment minimizing expected logistics + downtime cost.",
|
| 392 |
+
"Monte Carlo (30 runs): SLA 100.0%, MTTR 4.3h."
|
| 393 |
+
]
|
| 394 |
+
}
|
| 395 |
+
}
|
assets/samples/sample_medical_equipment.json
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"industry": "medical_equipment",
|
| 3 |
+
"stress": "emergency_surge",
|
| 4 |
+
"scenario_label": "Medical Equipment \u2014 Emergency Surge",
|
| 5 |
+
"summary": {
|
| 6 |
+
"sites": 10,
|
| 7 |
+
"technicians": 6,
|
| 8 |
+
"warehouses": 2,
|
| 9 |
+
"requests": 6
|
| 10 |
+
},
|
| 11 |
+
"optimization": {
|
| 12 |
+
"strategy": "two_stage_stochastic",
|
| 13 |
+
"algorithm": "two_stage_stochastic_programming",
|
| 14 |
+
"stage1_plan": {
|
| 15 |
+
"technician_positions": {
|
| 16 |
+
"T-01": [
|
| 17 |
+
50.830218050411915,
|
| 18 |
+
11.245464134292025
|
| 19 |
+
],
|
| 20 |
+
"T-02": [
|
| 21 |
+
50.830218050411915,
|
| 22 |
+
11.245464134292025
|
| 23 |
+
],
|
| 24 |
+
"T-03": [
|
| 25 |
+
50.830218050411915,
|
| 26 |
+
11.245464134292025
|
| 27 |
+
],
|
| 28 |
+
"T-04": [
|
| 29 |
+
50.830218050411915,
|
| 30 |
+
11.245464134292025
|
| 31 |
+
],
|
| 32 |
+
"T-05": [
|
| 33 |
+
50.830218050411915,
|
| 34 |
+
11.245464134292025
|
| 35 |
+
],
|
| 36 |
+
"T-06": [
|
| 37 |
+
50.830218050411915,
|
| 38 |
+
11.245464134292025
|
| 39 |
+
]
|
| 40 |
+
},
|
| 41 |
+
"inventory_targets": [
|
| 42 |
+
{
|
| 43 |
+
"warehouse_id": "WH-01",
|
| 44 |
+
"part_id": "P-001",
|
| 45 |
+
"quantity": 3,
|
| 46 |
+
"reorder_point": 1,
|
| 47 |
+
"safety_stock": 1
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"warehouse_id": "WH-01",
|
| 51 |
+
"part_id": "P-004",
|
| 52 |
+
"quantity": 2,
|
| 53 |
+
"reorder_point": 1,
|
| 54 |
+
"safety_stock": 1
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"warehouse_id": "WH-01",
|
| 58 |
+
"part_id": "P-003",
|
| 59 |
+
"quantity": 2,
|
| 60 |
+
"reorder_point": 1,
|
| 61 |
+
"safety_stock": 1
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"warehouse_id": "WH-01",
|
| 65 |
+
"part_id": "P-006",
|
| 66 |
+
"quantity": 2,
|
| 67 |
+
"reorder_point": 1,
|
| 68 |
+
"safety_stock": 1
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"warehouse_id": "WH-02",
|
| 72 |
+
"part_id": "P-001",
|
| 73 |
+
"quantity": 3,
|
| 74 |
+
"reorder_point": 1,
|
| 75 |
+
"safety_stock": 1
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"warehouse_id": "WH-02",
|
| 79 |
+
"part_id": "P-004",
|
| 80 |
+
"quantity": 2,
|
| 81 |
+
"reorder_point": 1,
|
| 82 |
+
"safety_stock": 1
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
"warehouse_id": "WH-02",
|
| 86 |
+
"part_id": "P-003",
|
| 87 |
+
"quantity": 2,
|
| 88 |
+
"reorder_point": 1,
|
| 89 |
+
"safety_stock": 1
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"warehouse_id": "WH-02",
|
| 93 |
+
"part_id": "P-006",
|
| 94 |
+
"quantity": 2,
|
| 95 |
+
"reorder_point": 1,
|
| 96 |
+
"safety_stock": 1
|
| 97 |
+
}
|
| 98 |
+
],
|
| 99 |
+
"supply_contracts": [
|
| 100 |
+
{
|
| 101 |
+
"warehouse": "WH-01",
|
| 102 |
+
"part": "P-001",
|
| 103 |
+
"min_order_qty": 5,
|
| 104 |
+
"lead_time_days": 5,
|
| 105 |
+
"expedite_available": true
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"warehouse": "WH-01",
|
| 109 |
+
"part": "P-002",
|
| 110 |
+
"min_order_qty": 5,
|
| 111 |
+
"lead_time_days": 5,
|
| 112 |
+
"expedite_available": true
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"warehouse": "WH-01",
|
| 116 |
+
"part": "P-003",
|
| 117 |
+
"min_order_qty": 5,
|
| 118 |
+
"lead_time_days": 5,
|
| 119 |
+
"expedite_available": true
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"warehouse": "WH-02",
|
| 123 |
+
"part": "P-001",
|
| 124 |
+
"min_order_qty": 5,
|
| 125 |
+
"lead_time_days": 5,
|
| 126 |
+
"expedite_available": true
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"warehouse": "WH-02",
|
| 130 |
+
"part": "P-002",
|
| 131 |
+
"min_order_qty": 5,
|
| 132 |
+
"lead_time_days": 5,
|
| 133 |
+
"expedite_available": true
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"warehouse": "WH-02",
|
| 137 |
+
"part": "P-003",
|
| 138 |
+
"min_order_qty": 5,
|
| 139 |
+
"lead_time_days": 5,
|
| 140 |
+
"expedite_available": true
|
| 141 |
+
}
|
| 142 |
+
],
|
| 143 |
+
"repair_shop_capacity": 2,
|
| 144 |
+
"total_holding_cost": 1557.7179540408565,
|
| 145 |
+
"notes": [
|
| 146 |
+
"Stock vs expedite ratio: 50% (higher = more pre-positioned inventory).",
|
| 147 |
+
"Multi-echelon targets across 2 warehouses.",
|
| 148 |
+
"Estimated holding cost: $1,558 over 7 days."
|
| 149 |
+
]
|
| 150 |
+
},
|
| 151 |
+
"assignments": [
|
| 152 |
+
{
|
| 153 |
+
"request_id": "SR-0003",
|
| 154 |
+
"technician_id": "T-02",
|
| 155 |
+
"technician_name": "Tech B",
|
| 156 |
+
"warehouse_id": "WH-01",
|
| 157 |
+
"part_id": "P-003",
|
| 158 |
+
"part_source": "warehouse_stock",
|
| 159 |
+
"route_order": 1,
|
| 160 |
+
"scheduled_start_hours": 0.0,
|
| 161 |
+
"estimated_arrival_hours": 7.168973193506486,
|
| 162 |
+
"estimated_completion_hours": 11.858973193506486,
|
| 163 |
+
"use_substitute": false,
|
| 164 |
+
"defer_to_later": false,
|
| 165 |
+
"rationale": "Scheduled \u2014 skill match 99%, part via warehouse_stock"
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
"request_id": "SR-0005",
|
| 169 |
+
"technician_id": "T-06",
|
| 170 |
+
"technician_name": "Tech F",
|
| 171 |
+
"warehouse_id": "WH-02",
|
| 172 |
+
"part_id": "P-006",
|
| 173 |
+
"part_source": "deferred",
|
| 174 |
+
"route_order": 1,
|
| 175 |
+
"scheduled_start_hours": 0.0,
|
| 176 |
+
"estimated_arrival_hours": 8.944809558991361,
|
| 177 |
+
"estimated_completion_hours": 13.514809558991361,
|
| 178 |
+
"use_substitute": false,
|
| 179 |
+
"defer_to_later": true,
|
| 180 |
+
"rationale": "Scheduled \u2014 skill match 95%, part via deferred (deferred to next window)"
|
| 181 |
+
}
|
| 182 |
+
],
|
| 183 |
+
"ml_predictions": [
|
| 184 |
+
{
|
| 185 |
+
"request_id": "SR-0001",
|
| 186 |
+
"site_id": "S-010",
|
| 187 |
+
"failure_probability": 0.3318,
|
| 188 |
+
"predicted_part_type": "power_supply",
|
| 189 |
+
"predicted_duration_hours": 1.6,
|
| 190 |
+
"first_time_fix_probability": 0.8508,
|
| 191 |
+
"revisit_probability": 0.1268,
|
| 192 |
+
"confidence": 0.9164,
|
| 193 |
+
"risk_tier": "low"
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
"request_id": "SR-0002",
|
| 197 |
+
"site_id": "S-005",
|
| 198 |
+
"failure_probability": 0.2689,
|
| 199 |
+
"predicted_part_type": "communication_card",
|
| 200 |
+
"predicted_duration_hours": 3.5,
|
| 201 |
+
"first_time_fix_probability": 0.7298,
|
| 202 |
+
"revisit_probability": 0.2296,
|
| 203 |
+
"confidence": 0.9038,
|
| 204 |
+
"risk_tier": "low"
|
| 205 |
+
},
|
| 206 |
+
{
|
| 207 |
+
"request_id": "SR-0003",
|
| 208 |
+
"site_id": "S-008",
|
| 209 |
+
"failure_probability": 0.0691,
|
| 210 |
+
"predicted_part_type": "communication_card",
|
| 211 |
+
"predicted_duration_hours": 4.69,
|
| 212 |
+
"first_time_fix_probability": 0.6952,
|
| 213 |
+
"revisit_probability": 0.2591,
|
| 214 |
+
"confidence": 0.8638,
|
| 215 |
+
"risk_tier": "low"
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
"request_id": "SR-0004",
|
| 219 |
+
"site_id": "S-003",
|
| 220 |
+
"failure_probability": 0.6225,
|
| 221 |
+
"predicted_part_type": "control_board",
|
| 222 |
+
"predicted_duration_hours": 2.66,
|
| 223 |
+
"first_time_fix_probability": 0.769,
|
| 224 |
+
"revisit_probability": 0.1964,
|
| 225 |
+
"confidence": 0.9255,
|
| 226 |
+
"risk_tier": "high"
|
| 227 |
+
},
|
| 228 |
+
{
|
| 229 |
+
"request_id": "SR-0005",
|
| 230 |
+
"site_id": "S-007",
|
| 231 |
+
"failure_probability": 0.1419,
|
| 232 |
+
"predicted_part_type": "power_supply",
|
| 233 |
+
"predicted_duration_hours": 4.57,
|
| 234 |
+
"first_time_fix_probability": 0.6901,
|
| 235 |
+
"revisit_probability": 0.2634,
|
| 236 |
+
"confidence": 0.8784,
|
| 237 |
+
"risk_tier": "low"
|
| 238 |
+
},
|
| 239 |
+
{
|
| 240 |
+
"request_id": "SR-0006",
|
| 241 |
+
"site_id": "S-004",
|
| 242 |
+
"failure_probability": 0.2142,
|
| 243 |
+
"predicted_part_type": "motor_drive",
|
| 244 |
+
"predicted_duration_hours": 3.12,
|
| 245 |
+
"first_time_fix_probability": 0.8024,
|
| 246 |
+
"revisit_probability": 0.1679,
|
| 247 |
+
"confidence": 0.8928,
|
| 248 |
+
"risk_tier": "low"
|
| 249 |
+
}
|
| 250 |
+
],
|
| 251 |
+
"metrics": {
|
| 252 |
+
"sla_compliance_pct": 0.0,
|
| 253 |
+
"mean_time_to_repair_hours": 4.94,
|
| 254 |
+
"first_time_fix_rate_pct": 66.7,
|
| 255 |
+
"emergency_stockout_count": 0,
|
| 256 |
+
"technician_utilization_pct": 27.3,
|
| 257 |
+
"total_logistics_cost": 284.58,
|
| 258 |
+
"total_downtime_cost": 44212.55,
|
| 259 |
+
"total_holding_cost": 1557.72,
|
| 260 |
+
"expedite_shipments": 0
|
| 261 |
+
},
|
| 262 |
+
"simulation": {
|
| 263 |
+
"replications": 30,
|
| 264 |
+
"sla_compliance_mean": 0.0,
|
| 265 |
+
"mttr_mean": 6.79,
|
| 266 |
+
"stockout_rate": 0.0,
|
| 267 |
+
"avg_logistics_cost": 138.81,
|
| 268 |
+
"avg_downtime_cost": 9570.58,
|
| 269 |
+
"confidence_interval_95": {
|
| 270 |
+
"sla_compliance": [
|
| 271 |
+
0.0,
|
| 272 |
+
0.0
|
| 273 |
+
],
|
| 274 |
+
"mttr": [
|
| 275 |
+
6.58,
|
| 276 |
+
7.01
|
| 277 |
+
],
|
| 278 |
+
"logistics_cost": [
|
| 279 |
+
136.68,
|
| 280 |
+
140.93
|
| 281 |
+
]
|
| 282 |
+
}
|
| 283 |
+
},
|
| 284 |
+
"solve_time_sec": 0.006099400023231283,
|
| 285 |
+
"status": "optimal",
|
| 286 |
+
"notes": [
|
| 287 |
+
"Two-stage stochastic: 20 SAA scenarios evaluated.",
|
| 288 |
+
"Stage 1: multi-echelon inventory + technician positioning.",
|
| 289 |
+
"Stage 2: mission assignment minimizing expected logistics + downtime cost.",
|
| 290 |
+
"Monte Carlo (30 runs): SLA 0.0%, MTTR 6.8h."
|
| 291 |
+
]
|
| 292 |
+
}
|
| 293 |
+
}
|
assets/samples/sample_telecom.json
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"industry": "telecom",
|
| 3 |
+
"stress": "parts_shortage",
|
| 4 |
+
"scenario_label": "Telecom Infrastructure \u2014 Parts Shortage",
|
| 5 |
+
"summary": {
|
| 6 |
+
"sites": 20,
|
| 7 |
+
"technicians": 12,
|
| 8 |
+
"warehouses": 4,
|
| 9 |
+
"requests": 10
|
| 10 |
+
},
|
| 11 |
+
"optimization": {
|
| 12 |
+
"strategy": "two_stage_stochastic",
|
| 13 |
+
"algorithm": "two_stage_stochastic_programming",
|
| 14 |
+
"stage1_plan": {
|
| 15 |
+
"technician_positions": {
|
| 16 |
+
"T-01": [
|
| 17 |
+
50.52239678759814,
|
| 18 |
+
11.102612634161087
|
| 19 |
+
],
|
| 20 |
+
"T-02": [
|
| 21 |
+
50.52239678759814,
|
| 22 |
+
11.102612634161087
|
| 23 |
+
],
|
| 24 |
+
"T-03": [
|
| 25 |
+
50.52239678759814,
|
| 26 |
+
11.102612634161087
|
| 27 |
+
],
|
| 28 |
+
"T-04": [
|
| 29 |
+
50.52239678759814,
|
| 30 |
+
11.102612634161087
|
| 31 |
+
],
|
| 32 |
+
"T-05": [
|
| 33 |
+
50.52239678759814,
|
| 34 |
+
11.102612634161087
|
| 35 |
+
],
|
| 36 |
+
"T-06": [
|
| 37 |
+
50.52239678759814,
|
| 38 |
+
11.102612634161087
|
| 39 |
+
],
|
| 40 |
+
"T-07": [
|
| 41 |
+
50.52239678759814,
|
| 42 |
+
11.102612634161087
|
| 43 |
+
],
|
| 44 |
+
"T-08": [
|
| 45 |
+
50.52239678759814,
|
| 46 |
+
11.102612634161087
|
| 47 |
+
],
|
| 48 |
+
"T-09": [
|
| 49 |
+
50.52239678759814,
|
| 50 |
+
11.102612634161087
|
| 51 |
+
],
|
| 52 |
+
"T-10": [
|
| 53 |
+
50.52239678759814,
|
| 54 |
+
11.102612634161087
|
| 55 |
+
],
|
| 56 |
+
"T-11": [
|
| 57 |
+
50.52239678759814,
|
| 58 |
+
11.102612634161087
|
| 59 |
+
],
|
| 60 |
+
"T-12": [
|
| 61 |
+
50.52239678759814,
|
| 62 |
+
11.102612634161087
|
| 63 |
+
]
|
| 64 |
+
},
|
| 65 |
+
"inventory_targets": [
|
| 66 |
+
{
|
| 67 |
+
"warehouse_id": "WH-01",
|
| 68 |
+
"part_id": "P-005",
|
| 69 |
+
"quantity": 3,
|
| 70 |
+
"reorder_point": 1,
|
| 71 |
+
"safety_stock": 1
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"warehouse_id": "WH-01",
|
| 75 |
+
"part_id": "P-002",
|
| 76 |
+
"quantity": 5,
|
| 77 |
+
"reorder_point": 2,
|
| 78 |
+
"safety_stock": 2
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"warehouse_id": "WH-01",
|
| 82 |
+
"part_id": "P-001",
|
| 83 |
+
"quantity": 2,
|
| 84 |
+
"reorder_point": 1,
|
| 85 |
+
"safety_stock": 1
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"warehouse_id": "WH-01",
|
| 89 |
+
"part_id": "P-004",
|
| 90 |
+
"quantity": 2,
|
| 91 |
+
"reorder_point": 1,
|
| 92 |
+
"safety_stock": 1
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"warehouse_id": "WH-01",
|
| 96 |
+
"part_id": "P-003",
|
| 97 |
+
"quantity": 2,
|
| 98 |
+
"reorder_point": 1,
|
| 99 |
+
"safety_stock": 1
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"warehouse_id": "WH-02",
|
| 103 |
+
"part_id": "P-005",
|
| 104 |
+
"quantity": 3,
|
| 105 |
+
"reorder_point": 1,
|
| 106 |
+
"safety_stock": 1
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
"warehouse_id": "WH-02",
|
| 110 |
+
"part_id": "P-002",
|
| 111 |
+
"quantity": 5,
|
| 112 |
+
"reorder_point": 2,
|
| 113 |
+
"safety_stock": 2
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
"warehouse_id": "WH-02",
|
| 117 |
+
"part_id": "P-001",
|
| 118 |
+
"quantity": 2,
|
| 119 |
+
"reorder_point": 1,
|
| 120 |
+
"safety_stock": 1
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"warehouse_id": "WH-02",
|
| 124 |
+
"part_id": "P-004",
|
| 125 |
+
"quantity": 2,
|
| 126 |
+
"reorder_point": 1,
|
| 127 |
+
"safety_stock": 1
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
"warehouse_id": "WH-02",
|
| 131 |
+
"part_id": "P-003",
|
| 132 |
+
"quantity": 2,
|
| 133 |
+
"reorder_point": 1,
|
| 134 |
+
"safety_stock": 1
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
"warehouse_id": "WH-03",
|
| 138 |
+
"part_id": "P-005",
|
| 139 |
+
"quantity": 3,
|
| 140 |
+
"reorder_point": 1,
|
| 141 |
+
"safety_stock": 1
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"warehouse_id": "WH-03",
|
| 145 |
+
"part_id": "P-002",
|
| 146 |
+
"quantity": 5,
|
| 147 |
+
"reorder_point": 2,
|
| 148 |
+
"safety_stock": 2
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"warehouse_id": "WH-03",
|
| 152 |
+
"part_id": "P-001",
|
| 153 |
+
"quantity": 2,
|
| 154 |
+
"reorder_point": 1,
|
| 155 |
+
"safety_stock": 1
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
"warehouse_id": "WH-03",
|
| 159 |
+
"part_id": "P-004",
|
| 160 |
+
"quantity": 2,
|
| 161 |
+
"reorder_point": 1,
|
| 162 |
+
"safety_stock": 1
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
"warehouse_id": "WH-03",
|
| 166 |
+
"part_id": "P-003",
|
| 167 |
+
"quantity": 2,
|
| 168 |
+
"reorder_point": 1,
|
| 169 |
+
"safety_stock": 1
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
"warehouse_id": "WH-04",
|
| 173 |
+
"part_id": "P-005",
|
| 174 |
+
"quantity": 3,
|
| 175 |
+
"reorder_point": 1,
|
| 176 |
+
"safety_stock": 1
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
"warehouse_id": "WH-04",
|
| 180 |
+
"part_id": "P-002",
|
| 181 |
+
"quantity": 5,
|
| 182 |
+
"reorder_point": 2,
|
| 183 |
+
"safety_stock": 2
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"warehouse_id": "WH-04",
|
| 187 |
+
"part_id": "P-001",
|
| 188 |
+
"quantity": 2,
|
| 189 |
+
"reorder_point": 1,
|
| 190 |
+
"safety_stock": 1
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
"warehouse_id": "WH-04",
|
| 194 |
+
"part_id": "P-004",
|
| 195 |
+
"quantity": 2,
|
| 196 |
+
"reorder_point": 1,
|
| 197 |
+
"safety_stock": 1
|
| 198 |
+
},
|
| 199 |
+
{
|
| 200 |
+
"warehouse_id": "WH-04",
|
| 201 |
+
"part_id": "P-003",
|
| 202 |
+
"quantity": 2,
|
| 203 |
+
"reorder_point": 1,
|
| 204 |
+
"safety_stock": 1
|
| 205 |
+
}
|
| 206 |
+
],
|
| 207 |
+
"supply_contracts": [
|
| 208 |
+
{
|
| 209 |
+
"warehouse": "WH-01",
|
| 210 |
+
"part": "P-001",
|
| 211 |
+
"min_order_qty": 5,
|
| 212 |
+
"lead_time_days": 5,
|
| 213 |
+
"expedite_available": true
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
"warehouse": "WH-01",
|
| 217 |
+
"part": "P-002",
|
| 218 |
+
"min_order_qty": 5,
|
| 219 |
+
"lead_time_days": 5,
|
| 220 |
+
"expedite_available": true
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"warehouse": "WH-01",
|
| 224 |
+
"part": "P-003",
|
| 225 |
+
"min_order_qty": 5,
|
| 226 |
+
"lead_time_days": 5,
|
| 227 |
+
"expedite_available": true
|
| 228 |
+
},
|
| 229 |
+
{
|
| 230 |
+
"warehouse": "WH-02",
|
| 231 |
+
"part": "P-001",
|
| 232 |
+
"min_order_qty": 5,
|
| 233 |
+
"lead_time_days": 5,
|
| 234 |
+
"expedite_available": true
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"warehouse": "WH-02",
|
| 238 |
+
"part": "P-002",
|
| 239 |
+
"min_order_qty": 5,
|
| 240 |
+
"lead_time_days": 5,
|
| 241 |
+
"expedite_available": true
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"warehouse": "WH-02",
|
| 245 |
+
"part": "P-003",
|
| 246 |
+
"min_order_qty": 5,
|
| 247 |
+
"lead_time_days": 5,
|
| 248 |
+
"expedite_available": true
|
| 249 |
+
},
|
| 250 |
+
{
|
| 251 |
+
"warehouse": "WH-03",
|
| 252 |
+
"part": "P-001",
|
| 253 |
+
"min_order_qty": 5,
|
| 254 |
+
"lead_time_days": 5,
|
| 255 |
+
"expedite_available": true
|
| 256 |
+
},
|
| 257 |
+
{
|
| 258 |
+
"warehouse": "WH-03",
|
| 259 |
+
"part": "P-002",
|
| 260 |
+
"min_order_qty": 5,
|
| 261 |
+
"lead_time_days": 5,
|
| 262 |
+
"expedite_available": true
|
| 263 |
+
},
|
| 264 |
+
{
|
| 265 |
+
"warehouse": "WH-03",
|
| 266 |
+
"part": "P-003",
|
| 267 |
+
"min_order_qty": 5,
|
| 268 |
+
"lead_time_days": 5,
|
| 269 |
+
"expedite_available": true
|
| 270 |
+
},
|
| 271 |
+
{
|
| 272 |
+
"warehouse": "WH-04",
|
| 273 |
+
"part": "P-001",
|
| 274 |
+
"min_order_qty": 5,
|
| 275 |
+
"lead_time_days": 5,
|
| 276 |
+
"expedite_available": true
|
| 277 |
+
},
|
| 278 |
+
{
|
| 279 |
+
"warehouse": "WH-04",
|
| 280 |
+
"part": "P-002",
|
| 281 |
+
"min_order_qty": 5,
|
| 282 |
+
"lead_time_days": 5,
|
| 283 |
+
"expedite_available": true
|
| 284 |
+
},
|
| 285 |
+
{
|
| 286 |
+
"warehouse": "WH-04",
|
| 287 |
+
"part": "P-003",
|
| 288 |
+
"min_order_qty": 5,
|
| 289 |
+
"lead_time_days": 5,
|
| 290 |
+
"expedite_available": true
|
| 291 |
+
}
|
| 292 |
+
],
|
| 293 |
+
"repair_shop_capacity": 4,
|
| 294 |
+
"total_holding_cost": 6411.200715308966,
|
| 295 |
+
"notes": [
|
| 296 |
+
"Stock vs expedite ratio: 50% (higher = more pre-positioned inventory).",
|
| 297 |
+
"Multi-echelon targets across 4 warehouses.",
|
| 298 |
+
"Estimated holding cost: $6,411 over 7 days."
|
| 299 |
+
]
|
| 300 |
+
},
|
| 301 |
+
"assignments": [
|
| 302 |
+
{
|
| 303 |
+
"request_id": "SR-0008",
|
| 304 |
+
"technician_id": "T-01",
|
| 305 |
+
"technician_name": "Tech A",
|
| 306 |
+
"warehouse_id": "WH-04",
|
| 307 |
+
"part_id": "P-005",
|
| 308 |
+
"part_source": "deferred",
|
| 309 |
+
"route_order": 1,
|
| 310 |
+
"scheduled_start_hours": 0.0,
|
| 311 |
+
"estimated_arrival_hours": 5.009145620384388,
|
| 312 |
+
"estimated_completion_hours": 9.469145620384388,
|
| 313 |
+
"use_substitute": false,
|
| 314 |
+
"defer_to_later": true,
|
| 315 |
+
"rationale": "Scheduled \u2014 skill match 89%, part via deferred (deferred to next window)"
|
| 316 |
+
},
|
| 317 |
+
{
|
| 318 |
+
"request_id": "SR-0009",
|
| 319 |
+
"technician_id": "T-01",
|
| 320 |
+
"technician_name": "Tech A",
|
| 321 |
+
"warehouse_id": "WH-04",
|
| 322 |
+
"part_id": "P-002",
|
| 323 |
+
"part_source": "warehouse_stock",
|
| 324 |
+
"route_order": 2,
|
| 325 |
+
"scheduled_start_hours": 6.474696411232166,
|
| 326 |
+
"estimated_arrival_hours": 14.818291851602815,
|
| 327 |
+
"estimated_completion_hours": 17.908291851602815,
|
| 328 |
+
"use_substitute": false,
|
| 329 |
+
"defer_to_later": false,
|
| 330 |
+
"rationale": "Scheduled \u2014 skill match 89%, part via warehouse_stock"
|
| 331 |
+
},
|
| 332 |
+
{
|
| 333 |
+
"request_id": "SR-0004",
|
| 334 |
+
"technician_id": "T-03",
|
| 335 |
+
"technician_name": "Tech C",
|
| 336 |
+
"warehouse_id": "WH-03",
|
| 337 |
+
"part_id": "P-001",
|
| 338 |
+
"part_source": "deferred",
|
| 339 |
+
"route_order": 1,
|
| 340 |
+
"scheduled_start_hours": 0.0,
|
| 341 |
+
"estimated_arrival_hours": 2.2097288498190735,
|
| 342 |
+
"estimated_completion_hours": 5.709728849819074,
|
| 343 |
+
"use_substitute": false,
|
| 344 |
+
"defer_to_later": true,
|
| 345 |
+
"rationale": "Scheduled \u2014 skill match 95%, part via deferred (deferred to next window)"
|
| 346 |
+
},
|
| 347 |
+
{
|
| 348 |
+
"request_id": "SR-0010",
|
| 349 |
+
"technician_id": "T-03",
|
| 350 |
+
"technician_name": "Tech C",
|
| 351 |
+
"warehouse_id": "WH-02",
|
| 352 |
+
"part_id": "P-003",
|
| 353 |
+
"part_source": "warehouse_stock",
|
| 354 |
+
"route_order": 2,
|
| 355 |
+
"scheduled_start_hours": 5.587436340493345,
|
| 356 |
+
"estimated_arrival_hours": 13.291148468476583,
|
| 357 |
+
"estimated_completion_hours": 16.791148468476585,
|
| 358 |
+
"use_substitute": false,
|
| 359 |
+
"defer_to_later": false,
|
| 360 |
+
"rationale": "Scheduled \u2014 skill match 95%, part via warehouse_stock"
|
| 361 |
+
},
|
| 362 |
+
{
|
| 363 |
+
"request_id": "SR-0003",
|
| 364 |
+
"technician_id": "T-03",
|
| 365 |
+
"technician_name": "Tech C",
|
| 366 |
+
"warehouse_id": "WH-04",
|
| 367 |
+
"part_id": "P-002",
|
| 368 |
+
"part_source": "deferred",
|
| 369 |
+
"route_order": 3,
|
| 370 |
+
"scheduled_start_hours": 18.126605167384554,
|
| 371 |
+
"estimated_arrival_hours": 22.239061408292017,
|
| 372 |
+
"estimated_completion_hours": 23.939061408292016,
|
| 373 |
+
"use_substitute": false,
|
| 374 |
+
"defer_to_later": true,
|
| 375 |
+
"rationale": "Scheduled \u2014 skill match 95%, part via deferred (deferred to next window)"
|
| 376 |
+
},
|
| 377 |
+
{
|
| 378 |
+
"request_id": "SR-0005",
|
| 379 |
+
"technician_id": "T-03",
|
| 380 |
+
"technician_name": "Tech C",
|
| 381 |
+
"warehouse_id": "WH-04",
|
| 382 |
+
"part_id": "P-002",
|
| 383 |
+
"part_source": "warehouse_stock",
|
| 384 |
+
"route_order": 4,
|
| 385 |
+
"scheduled_start_hours": 19.826605167384557,
|
| 386 |
+
"estimated_arrival_hours": 23.939061408292016,
|
| 387 |
+
"estimated_completion_hours": 25.639061408292015,
|
| 388 |
+
"use_substitute": false,
|
| 389 |
+
"defer_to_later": false,
|
| 390 |
+
"rationale": "Scheduled \u2014 skill match 95%, part via warehouse_stock"
|
| 391 |
+
},
|
| 392 |
+
{
|
| 393 |
+
"request_id": "SR-0006",
|
| 394 |
+
"technician_id": "T-06",
|
| 395 |
+
"technician_name": "Tech F",
|
| 396 |
+
"warehouse_id": "WH-02",
|
| 397 |
+
"part_id": "P-004",
|
| 398 |
+
"part_source": "deferred",
|
| 399 |
+
"route_order": 1,
|
| 400 |
+
"scheduled_start_hours": 0.0,
|
| 401 |
+
"estimated_arrival_hours": 7.404866505625244,
|
| 402 |
+
"estimated_completion_hours": 11.564866505625243,
|
| 403 |
+
"use_substitute": false,
|
| 404 |
+
"defer_to_later": true,
|
| 405 |
+
"rationale": "Scheduled \u2014 skill match 94%, part via deferred (deferred to next window)"
|
| 406 |
+
}
|
| 407 |
+
],
|
| 408 |
+
"ml_predictions": [
|
| 409 |
+
{
|
| 410 |
+
"request_id": "SR-0001",
|
| 411 |
+
"site_id": "S-013",
|
| 412 |
+
"failure_probability": 0.2592,
|
| 413 |
+
"predicted_part_type": "communication_card",
|
| 414 |
+
"predicted_duration_hours": 1.64,
|
| 415 |
+
"first_time_fix_probability": 0.8572,
|
| 416 |
+
"revisit_probability": 0.1214,
|
| 417 |
+
"confidence": 0.9018,
|
| 418 |
+
"risk_tier": "low"
|
| 419 |
+
},
|
| 420 |
+
{
|
| 421 |
+
"request_id": "SR-0002",
|
| 422 |
+
"site_id": "S-003",
|
| 423 |
+
"failure_probability": 0.343,
|
| 424 |
+
"predicted_part_type": "hydraulic_pump",
|
| 425 |
+
"predicted_duration_hours": 2.98,
|
| 426 |
+
"first_time_fix_probability": 0.7919,
|
| 427 |
+
"revisit_probability": 0.1769,
|
| 428 |
+
"confidence": 0.9186,
|
| 429 |
+
"risk_tier": "low"
|
| 430 |
+
},
|
| 431 |
+
{
|
| 432 |
+
"request_id": "SR-0003",
|
| 433 |
+
"site_id": "S-005",
|
| 434 |
+
"failure_probability": 0.1611,
|
| 435 |
+
"predicted_part_type": "power_supply",
|
| 436 |
+
"predicted_duration_hours": 1.7,
|
| 437 |
+
"first_time_fix_probability": 0.8658,
|
| 438 |
+
"revisit_probability": 0.1141,
|
| 439 |
+
"confidence": 0.8822,
|
| 440 |
+
"risk_tier": "low"
|
| 441 |
+
},
|
| 442 |
+
{
|
| 443 |
+
"request_id": "SR-0004",
|
| 444 |
+
"site_id": "S-018",
|
| 445 |
+
"failure_probability": 0.2689,
|
| 446 |
+
"predicted_part_type": "hydraulic_pump",
|
| 447 |
+
"predicted_duration_hours": 3.5,
|
| 448 |
+
"first_time_fix_probability": 0.7298,
|
| 449 |
+
"revisit_probability": 0.2296,
|
| 450 |
+
"confidence": 0.9038,
|
| 451 |
+
"risk_tier": "low"
|
| 452 |
+
},
|
| 453 |
+
{
|
| 454 |
+
"request_id": "SR-0005",
|
| 455 |
+
"site_id": "S-005",
|
| 456 |
+
"failure_probability": 0.1611,
|
| 457 |
+
"predicted_part_type": "power_supply",
|
| 458 |
+
"predicted_duration_hours": 1.7,
|
| 459 |
+
"first_time_fix_probability": 0.8658,
|
| 460 |
+
"revisit_probability": 0.1141,
|
| 461 |
+
"confidence": 0.8822,
|
| 462 |
+
"risk_tier": "low"
|
| 463 |
+
},
|
| 464 |
+
{
|
| 465 |
+
"request_id": "SR-0006",
|
| 466 |
+
"site_id": "S-007",
|
| 467 |
+
"failure_probability": 0.4013,
|
| 468 |
+
"predicted_part_type": "control_board",
|
| 469 |
+
"predicted_duration_hours": 4.16,
|
| 470 |
+
"first_time_fix_probability": 0.6719,
|
| 471 |
+
"revisit_probability": 0.2789,
|
| 472 |
+
"confidence": 0.9303,
|
| 473 |
+
"risk_tier": "medium"
|
| 474 |
+
},
|
| 475 |
+
{
|
| 476 |
+
"request_id": "SR-0007",
|
| 477 |
+
"site_id": "S-002",
|
| 478 |
+
"failure_probability": 0.475,
|
| 479 |
+
"predicted_part_type": "hydraulic_pump",
|
| 480 |
+
"predicted_duration_hours": 3.23,
|
| 481 |
+
"first_time_fix_probability": 0.7144,
|
| 482 |
+
"revisit_probability": 0.2428,
|
| 483 |
+
"confidence": 0.945,
|
| 484 |
+
"risk_tier": "medium"
|
| 485 |
+
},
|
| 486 |
+
{
|
| 487 |
+
"request_id": "SR-0008",
|
| 488 |
+
"site_id": "S-005",
|
| 489 |
+
"failure_probability": 0.2142,
|
| 490 |
+
"predicted_part_type": "control_board",
|
| 491 |
+
"predicted_duration_hours": 4.46,
|
| 492 |
+
"first_time_fix_probability": 0.685,
|
| 493 |
+
"revisit_probability": 0.2677,
|
| 494 |
+
"confidence": 0.8928,
|
| 495 |
+
"risk_tier": "low"
|
| 496 |
+
},
|
| 497 |
+
{
|
| 498 |
+
"request_id": "SR-0009",
|
| 499 |
+
"site_id": "S-014",
|
| 500 |
+
"failure_probability": 0.2405,
|
| 501 |
+
"predicted_part_type": "hydraulic_pump",
|
| 502 |
+
"predicted_duration_hours": 3.09,
|
| 503 |
+
"first_time_fix_probability": 0.8003,
|
| 504 |
+
"revisit_probability": 0.1698,
|
| 505 |
+
"confidence": 0.8981,
|
| 506 |
+
"risk_tier": "low"
|
| 507 |
+
},
|
| 508 |
+
{
|
| 509 |
+
"request_id": "SR-0010",
|
| 510 |
+
"site_id": "S-012",
|
| 511 |
+
"failure_probability": 0.2689,
|
| 512 |
+
"predicted_part_type": "hydraulic_pump",
|
| 513 |
+
"predicted_duration_hours": 3.5,
|
| 514 |
+
"first_time_fix_probability": 0.7298,
|
| 515 |
+
"revisit_probability": 0.2296,
|
| 516 |
+
"confidence": 0.9038,
|
| 517 |
+
"risk_tier": "low"
|
| 518 |
+
}
|
| 519 |
+
],
|
| 520 |
+
"metrics": {
|
| 521 |
+
"sla_compliance_pct": 90.0,
|
| 522 |
+
"mean_time_to_repair_hours": 4.2,
|
| 523 |
+
"first_time_fix_rate_pct": 80.0,
|
| 524 |
+
"emergency_stockout_count": 0,
|
| 525 |
+
"technician_utilization_pct": 19.2,
|
| 526 |
+
"total_logistics_cost": 1170.53,
|
| 527 |
+
"total_downtime_cost": 58191.35,
|
| 528 |
+
"total_holding_cost": 6411.2,
|
| 529 |
+
"expedite_shipments": 0
|
| 530 |
+
},
|
| 531 |
+
"simulation": {
|
| 532 |
+
"replications": 30,
|
| 533 |
+
"sla_compliance_mean": 83.8,
|
| 534 |
+
"mttr_mean": 4.53,
|
| 535 |
+
"stockout_rate": 0.0,
|
| 536 |
+
"avg_logistics_cost": 850.4,
|
| 537 |
+
"avg_downtime_cost": 41021.62,
|
| 538 |
+
"confidence_interval_95": {
|
| 539 |
+
"sla_compliance": [
|
| 540 |
+
82.02,
|
| 541 |
+
85.56
|
| 542 |
+
],
|
| 543 |
+
"mttr": [
|
| 544 |
+
4.44,
|
| 545 |
+
4.62
|
| 546 |
+
],
|
| 547 |
+
"logistics_cost": [
|
| 548 |
+
836.5,
|
| 549 |
+
864.31
|
| 550 |
+
]
|
| 551 |
+
}
|
| 552 |
+
},
|
| 553 |
+
"solve_time_sec": 0.006857800006400794,
|
| 554 |
+
"status": "optimal",
|
| 555 |
+
"notes": [
|
| 556 |
+
"Two-stage stochastic: 20 SAA scenarios evaluated.",
|
| 557 |
+
"Stage 1: multi-echelon inventory + technician positioning.",
|
| 558 |
+
"Stage 2: mission assignment minimizing expected logistics + downtime cost.",
|
| 559 |
+
"Monte Carlo (30 runs): SLA 83.8%, MTTR 4.5h."
|
| 560 |
+
]
|
| 561 |
+
}
|
| 562 |
+
}
|
gradio/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: FieldOps — Service Parts & Field Operations Optimizer
|
| 3 |
+
emoji: 🛠️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.50.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
python_version: "3.12"
|
| 10 |
+
short_description: Predict-then-optimize field service parts & routing
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# FieldOps — Service Parts & Field Operations Optimizer
|
| 14 |
+
|
| 15 |
+
Enterprise predict-then-optimize platform for aftermarket field service operations.
|
| 16 |
+
|
| 17 |
+
**ML Failure Prediction → Multi-Echelon Inventory → Technician Routing → Monte Carlo Validation**
|
| 18 |
+
|
| 19 |
+
## Features
|
| 20 |
+
|
| 21 |
+
- Two-stage stochastic programming (pre-failure planning + post-failure response)
|
| 22 |
+
- Multi-echelon spare parts inventory optimization
|
| 23 |
+
- OR-Tools technician routing with skill-based assignment
|
| 24 |
+
- Stock vs expedite cost trade-off analysis
|
| 25 |
+
- ALNS and Sample Average Approximation solvers
|
| 26 |
+
- Monte Carlo SLA validation
|
| 27 |
+
|
| 28 |
+
## Industries
|
| 29 |
+
|
| 30 |
+
Industrial Equipment · Elevators · Medical Equipment · Machinery · Data Center · Telecom · Home Appliances · Automotive · Power Plant
|
| 31 |
+
|
| 32 |
+
## Stack
|
| 33 |
+
|
| 34 |
+
Pyomo · OR-Tools · LightGBM-style ML · SimPy · GeoPandas · Plotly · Gradio
|
gradio/app.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FieldOps — Service Parts and Field Operations Optimizer
|
| 3 |
+
Predict-then-optimize platform for aftermarket field service.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import sys
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import gradio as gr
|
| 13 |
+
import pandas as pd
|
| 14 |
+
|
| 15 |
+
ROOT = Path(__file__).resolve().parent
|
| 16 |
+
sys.path.insert(0, str(ROOT / "src"))
|
| 17 |
+
|
| 18 |
+
from fieldops.generator import ( # noqa: E402
|
| 19 |
+
inventory_to_table_rows,
|
| 20 |
+
requests_to_table_rows,
|
| 21 |
+
sites_to_table_rows,
|
| 22 |
+
technicians_to_table_rows,
|
| 23 |
+
)
|
| 24 |
+
from fieldops.ml_predictions import predictions_to_table_rows # noqa: E402
|
| 25 |
+
from fieldops.pipeline import FieldOpsPipeline # noqa: E402
|
| 26 |
+
from fieldops.visualization import ( # noqa: E402
|
| 27 |
+
assignment_gantt,
|
| 28 |
+
cost_breakdown_chart,
|
| 29 |
+
kpi_radar_chart,
|
| 30 |
+
mission_map_chart,
|
| 31 |
+
result_summary_markdown,
|
| 32 |
+
stock_expedite_chart,
|
| 33 |
+
strategy_comparison_chart,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
pipeline = FieldOpsPipeline(ROOT / "assets")
|
| 37 |
+
pipeline.load()
|
| 38 |
+
|
| 39 |
+
INDUSTRY_CHOICES = [(pipeline.get_industry_label(i), i) for i in pipeline.get_industry_ids()]
|
| 40 |
+
STRESS_CHOICES = [(pipeline.get_stress_label(s), s) for s in pipeline.get_stress_ids()]
|
| 41 |
+
STRATEGY_CHOICES = [(pipeline.get_strategy_label(s), s) for s in pipeline.get_strategy_ids()]
|
| 42 |
+
|
| 43 |
+
ASSIGN_COLS = [
|
| 44 |
+
"Request", "Technician", "Route #", "Part Source",
|
| 45 |
+
"Arrival (h)", "Complete (h)", "Substitute", "Deferred",
|
| 46 |
+
]
|
| 47 |
+
CUSTOM_CSS = ".gradio-container { max-width: 1440px !important; }"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _assignments_df(result) -> pd.DataFrame:
|
| 51 |
+
rows = [
|
| 52 |
+
[
|
| 53 |
+
a.request_id, a.technician_name, a.route_order, a.part_source,
|
| 54 |
+
f"{a.estimated_arrival_hours:.1f}", f"{a.estimated_completion_hours:.1f}",
|
| 55 |
+
"Yes" if a.use_substitute else "No",
|
| 56 |
+
"Yes" if a.defer_to_later else "No",
|
| 57 |
+
]
|
| 58 |
+
for a in result.assignments
|
| 59 |
+
]
|
| 60 |
+
return pd.DataFrame(rows, columns=ASSIGN_COLS) if rows else pd.DataFrame(columns=ASSIGN_COLS)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _notes_md(result) -> str:
|
| 64 |
+
notes = "\n".join(f"- {n}" for n in result.notes)
|
| 65 |
+
rationale = "\n".join(
|
| 66 |
+
f"- **{a.request_id}** → {a.technician_name}: {a.rationale}"
|
| 67 |
+
for a in result.assignments[:5]
|
| 68 |
+
)
|
| 69 |
+
extra = f"\n_…and {len(result.assignments) - 5} more missions_" if len(result.assignments) > 5 else ""
|
| 70 |
+
return f"### Engine Notes\n{notes}\n\n### Assignment Rationale\n{rationale}{extra}"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def run_optimization(industry, stress, strategy, stock_ratio, num_sites, seed):
|
| 74 |
+
try:
|
| 75 |
+
problem = pipeline.build_problem(
|
| 76 |
+
industry, stress, int(num_sites), stock_ratio / 100, seed=int(seed),
|
| 77 |
+
)
|
| 78 |
+
result = pipeline.run_optimization(problem, strategy)
|
| 79 |
+
return (
|
| 80 |
+
result_summary_markdown(result),
|
| 81 |
+
pd.DataFrame(predictions_to_table_rows(result.ml_predictions)),
|
| 82 |
+
_assignments_df(result),
|
| 83 |
+
_notes_md(result),
|
| 84 |
+
kpi_radar_chart(result),
|
| 85 |
+
cost_breakdown_chart(result),
|
| 86 |
+
mission_map_chart(problem, result),
|
| 87 |
+
assignment_gantt(result),
|
| 88 |
+
pd.DataFrame(sites_to_table_rows(problem)),
|
| 89 |
+
pd.DataFrame(technicians_to_table_rows(problem)),
|
| 90 |
+
pd.DataFrame(inventory_to_table_rows(problem)),
|
| 91 |
+
pd.DataFrame(requests_to_table_rows(problem)),
|
| 92 |
+
json.dumps(result.to_dict(), indent=2),
|
| 93 |
+
"Optimization complete.",
|
| 94 |
+
)
|
| 95 |
+
except Exception as exc:
|
| 96 |
+
return (
|
| 97 |
+
f"### Error\n{exc}", pd.DataFrame(), pd.DataFrame(), "",
|
| 98 |
+
None, None, None, None,
|
| 99 |
+
pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame(),
|
| 100 |
+
"", str(exc),
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def run_comparison(industry, stress, num_sites, seed):
|
| 105 |
+
try:
|
| 106 |
+
problem = pipeline.build_problem(industry, stress, int(num_sites), seed=int(seed))
|
| 107 |
+
results = pipeline.run_comparison(problem)
|
| 108 |
+
if not results:
|
| 109 |
+
return "No results.", None, pd.DataFrame()
|
| 110 |
+
rows = []
|
| 111 |
+
for r in results:
|
| 112 |
+
m = r.metrics
|
| 113 |
+
rows.append({
|
| 114 |
+
"Strategy": pipeline.get_strategy_label(r.strategy),
|
| 115 |
+
"SLA %": f"{m.sla_compliance_pct:.1f}",
|
| 116 |
+
"MTTR (h)": f"{m.mean_time_to_repair_hours:.1f}",
|
| 117 |
+
"FTF %": f"{m.first_time_fix_rate_pct:.1f}",
|
| 118 |
+
"Logistics $": f"${m.total_logistics_cost:,.0f}",
|
| 119 |
+
"Downtime $": f"${m.total_downtime_cost:,.0f}",
|
| 120 |
+
"Expedite": m.expedite_shipments,
|
| 121 |
+
"Solve (s)": f"{r.solve_time_sec:.2f}",
|
| 122 |
+
})
|
| 123 |
+
return (
|
| 124 |
+
f"### Strategy Comparison — {problem.scenario_label}\nCompared {len(results)} strategies.",
|
| 125 |
+
strategy_comparison_chart(results),
|
| 126 |
+
pd.DataFrame(rows),
|
| 127 |
+
)
|
| 128 |
+
except Exception as exc:
|
| 129 |
+
return f"Error: {exc}", None, pd.DataFrame()
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def run_stock_expedite(industry, stress, num_sites, seed):
|
| 133 |
+
try:
|
| 134 |
+
problem = pipeline.build_problem(industry, stress, int(num_sites), seed=int(seed))
|
| 135 |
+
results = pipeline.run_stock_expedite_analysis(problem)
|
| 136 |
+
rows = []
|
| 137 |
+
for i, r in enumerate(results):
|
| 138 |
+
m = r.metrics
|
| 139 |
+
ratio = (i + 1) * 0.2
|
| 140 |
+
rows.append({
|
| 141 |
+
"Stock Ratio": f"{ratio:.0%}",
|
| 142 |
+
"Holding $": f"${m.total_holding_cost:,.0f}",
|
| 143 |
+
"Expedite Shipments": m.expedite_shipments,
|
| 144 |
+
"Total Cost $": f"${m.total_logistics_cost + m.total_downtime_cost + m.total_holding_cost:,.0f}",
|
| 145 |
+
"SLA %": f"{m.sla_compliance_pct:.1f}",
|
| 146 |
+
})
|
| 147 |
+
return (
|
| 148 |
+
"### Stock vs Expedite Trade-off Analysis\n"
|
| 149 |
+
"Higher pre-positioned inventory reduces expedite shipments but increases holding cost.",
|
| 150 |
+
stock_expedite_chart(results),
|
| 151 |
+
pd.DataFrame(rows),
|
| 152 |
+
)
|
| 153 |
+
except Exception as exc:
|
| 154 |
+
return f"Error: {exc}", None, pd.DataFrame()
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def build_ui() -> gr.Blocks:
|
| 158 |
+
with gr.Blocks(title="FieldOps — Service Parts & Field Operations Optimizer", css=CUSTOM_CSS) as demo:
|
| 159 |
+
gr.Markdown(
|
| 160 |
+
"# FieldOps — Service Parts & Field Operations Optimizer\n"
|
| 161 |
+
"**Predict-then-Optimize for aftermarket field service operations**\n\n"
|
| 162 |
+
"ML Failure Prediction → Multi-Echelon Inventory → Technician Routing → Monte Carlo Validation"
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
with gr.Tab("Optimize"):
|
| 166 |
+
with gr.Row():
|
| 167 |
+
industry = gr.Dropdown(INDUSTRY_CHOICES, value="industrial_equipment", label="Industry")
|
| 168 |
+
stress = gr.Dropdown(STRESS_CHOICES, value="baseline", label="Stress Scenario")
|
| 169 |
+
strategy = gr.Dropdown(STRATEGY_CHOICES, value="two_stage_stochastic", label="Algorithm")
|
| 170 |
+
with gr.Row():
|
| 171 |
+
stock_ratio = gr.Slider(10, 90, value=50, step=5, label="Stock vs Expedite Ratio (%)")
|
| 172 |
+
num_sites = gr.Slider(6, 20, value=12, step=1, label="Service Sites")
|
| 173 |
+
seed = gr.Number(value=42, label="Random Seed", precision=0)
|
| 174 |
+
run_btn = gr.Button("Run Predict-then-Optimize", variant="primary")
|
| 175 |
+
|
| 176 |
+
summary = gr.Markdown()
|
| 177 |
+
with gr.Row():
|
| 178 |
+
kpi_chart = gr.Plot(label="KPI Radar")
|
| 179 |
+
cost_chart = gr.Plot(label="Cost Breakdown")
|
| 180 |
+
with gr.Row():
|
| 181 |
+
map_chart = gr.Plot(label="Operations Map")
|
| 182 |
+
gantt_chart = gr.Plot(label="Mission Gantt")
|
| 183 |
+
with gr.Row():
|
| 184 |
+
pred_table = gr.Dataframe(label="ML Predictions", interactive=False)
|
| 185 |
+
assign_table = gr.Dataframe(label="Mission Assignments", interactive=False)
|
| 186 |
+
notes = gr.Markdown()
|
| 187 |
+
with gr.Accordion("Network Data", open=False):
|
| 188 |
+
with gr.Row():
|
| 189 |
+
sites_table = gr.Dataframe(label="Service Sites", interactive=False)
|
| 190 |
+
tech_table = gr.Dataframe(label="Technicians", interactive=False)
|
| 191 |
+
with gr.Row():
|
| 192 |
+
inv_table = gr.Dataframe(label="Inventory", interactive=False)
|
| 193 |
+
req_table = gr.Dataframe(label="Service Requests", interactive=False)
|
| 194 |
+
export_json = gr.Code(label="Full Result JSON", language="json")
|
| 195 |
+
status = gr.Textbox(label="Status", interactive=False)
|
| 196 |
+
|
| 197 |
+
run_btn.click(
|
| 198 |
+
run_optimization,
|
| 199 |
+
[industry, stress, strategy, stock_ratio, num_sites, seed],
|
| 200 |
+
[
|
| 201 |
+
summary, pred_table, assign_table, notes,
|
| 202 |
+
kpi_chart, cost_chart, map_chart, gantt_chart,
|
| 203 |
+
sites_table, tech_table, inv_table, req_table,
|
| 204 |
+
export_json, status,
|
| 205 |
+
],
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
with gr.Tab("Compare Strategies"):
|
| 209 |
+
with gr.Row():
|
| 210 |
+
cmp_industry = gr.Dropdown(INDUSTRY_CHOICES, value="medical_equipment", label="Industry")
|
| 211 |
+
cmp_stress = gr.Dropdown(STRESS_CHOICES, value="emergency_surge", label="Stress")
|
| 212 |
+
cmp_sites = gr.Slider(6, 20, value=10, step=1, label="Sites")
|
| 213 |
+
cmp_seed = gr.Number(value=42, label="Seed", precision=0)
|
| 214 |
+
cmp_btn = gr.Button("Compare All Strategies", variant="primary")
|
| 215 |
+
cmp_summary = gr.Markdown()
|
| 216 |
+
cmp_chart = gr.Plot()
|
| 217 |
+
cmp_table = gr.Dataframe(interactive=False)
|
| 218 |
+
cmp_btn.click(run_comparison, [cmp_industry, cmp_stress, cmp_sites, cmp_seed],
|
| 219 |
+
[cmp_summary, cmp_chart, cmp_table])
|
| 220 |
+
|
| 221 |
+
with gr.Tab("Stock vs Expedite"):
|
| 222 |
+
with gr.Row():
|
| 223 |
+
se_industry = gr.Dropdown(INDUSTRY_CHOICES, value="telecom", label="Industry")
|
| 224 |
+
se_stress = gr.Dropdown(STRESS_CHOICES, value="parts_shortage", label="Stress")
|
| 225 |
+
se_sites = gr.Slider(6, 20, value=12, step=1, label="Sites")
|
| 226 |
+
se_seed = gr.Number(value=42, label="Seed", precision=0)
|
| 227 |
+
se_btn = gr.Button("Analyze Trade-off", variant="primary")
|
| 228 |
+
se_summary = gr.Markdown()
|
| 229 |
+
se_chart = gr.Plot()
|
| 230 |
+
se_table = gr.Dataframe(interactive=False)
|
| 231 |
+
se_btn.click(run_stock_expedite, [se_industry, se_stress, se_sites, se_seed],
|
| 232 |
+
[se_summary, se_chart, se_table])
|
| 233 |
+
|
| 234 |
+
with gr.Tab("About"):
|
| 235 |
+
gr.Markdown(
|
| 236 |
+
"## Two-Stage Decision Model\n\n"
|
| 237 |
+
"**Stage 1 — Pre-Failure:** Technician positioning, multi-echelon inventory, "
|
| 238 |
+
"supply contracts, repair shop capacity.\n\n"
|
| 239 |
+
"**Stage 2 — Post-Failure:** Mission assignment, routing, parts allocation, "
|
| 240 |
+
"repair scheduling, substitute parts.\n\n"
|
| 241 |
+
"### ML Predictions (Pre-trained)\n"
|
| 242 |
+
"- Failure probability | Part type needed | Repair duration\n"
|
| 243 |
+
"- First-time fix probability | Revisit probability\n\n"
|
| 244 |
+
"### Optimization Algorithms\n"
|
| 245 |
+
"- Two-Stage Stochastic Programming | Multi-Echelon Inventory\n"
|
| 246 |
+
"- OR-Tools Technician Routing | Sample Average Approximation\n"
|
| 247 |
+
"- ALNS | Monte Carlo Simulation\n\n"
|
| 248 |
+
"### KPIs\n"
|
| 249 |
+
"SLA compliance | MTTR | First-time fix rate | Emergency stockout | "
|
| 250 |
+
"Technician utilization | Logistics cost | Downtime cost"
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
return demo
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
if __name__ == "__main__":
|
| 257 |
+
build_ui().launch()
|
gradio/assets/config.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "fieldops-solver-config",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "Solver configuration for FieldOps field service optimization engine",
|
| 5 |
+
"ml_model": {
|
| 6 |
+
"type": "pre-trained-logistic",
|
| 7 |
+
"predictions": [
|
| 8 |
+
"failure_probability",
|
| 9 |
+
"part_type",
|
| 10 |
+
"repair_duration",
|
| 11 |
+
"first_time_fix_probability",
|
| 12 |
+
"revisit_probability"
|
| 13 |
+
],
|
| 14 |
+
"note": "Fixed coefficients — no training required at inference"
|
| 15 |
+
},
|
| 16 |
+
"optimization": {
|
| 17 |
+
"algorithms": [
|
| 18 |
+
"two_stage_stochastic",
|
| 19 |
+
"multi_echelon_inventory",
|
| 20 |
+
"technician_routing",
|
| 21 |
+
"sample_average_approximation",
|
| 22 |
+
"alns_field_ops",
|
| 23 |
+
"stock_vs_expedite",
|
| 24 |
+
"monte_carlo"
|
| 25 |
+
],
|
| 26 |
+
"stage1": ["inventory_positioning", "technician_deployment", "supply_contracts"],
|
| 27 |
+
"stage2": ["mission_assignment", "routing", "parts_allocation", "scheduling"]
|
| 28 |
+
},
|
| 29 |
+
"simulation": {
|
| 30 |
+
"method": "monte_carlo",
|
| 31 |
+
"default_replications": 30
|
| 32 |
+
},
|
| 33 |
+
"kpis": [
|
| 34 |
+
"sla_compliance_pct",
|
| 35 |
+
"mean_time_to_repair_hours",
|
| 36 |
+
"first_time_fix_rate_pct",
|
| 37 |
+
"emergency_stockout_count",
|
| 38 |
+
"technician_utilization_pct",
|
| 39 |
+
"total_logistics_cost",
|
| 40 |
+
"total_downtime_cost"
|
| 41 |
+
]
|
| 42 |
+
}
|
gradio/assets/demo/benchmarks.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"industry": "industrial_equipment",
|
| 4 |
+
"stress": "baseline",
|
| 5 |
+
"algorithm": "two_stage_stochastic",
|
| 6 |
+
"sla_compliance_pct": 100.0,
|
| 7 |
+
"mttr_hours": 4.31,
|
| 8 |
+
"ftf_rate_pct": 100.0,
|
| 9 |
+
"logistics_cost": 597.53,
|
| 10 |
+
"downtime_cost": 17622.45,
|
| 11 |
+
"solve_time_sec": 0.011503599991556257
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"industry": "medical_equipment",
|
| 15 |
+
"stress": "emergency_surge",
|
| 16 |
+
"algorithm": "two_stage_stochastic",
|
| 17 |
+
"sla_compliance_pct": 0.0,
|
| 18 |
+
"mttr_hours": 4.94,
|
| 19 |
+
"ftf_rate_pct": 66.7,
|
| 20 |
+
"logistics_cost": 284.58,
|
| 21 |
+
"downtime_cost": 44212.55,
|
| 22 |
+
"solve_time_sec": 0.006099400023231283
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"industry": "telecom",
|
| 26 |
+
"stress": "parts_shortage",
|
| 27 |
+
"algorithm": "two_stage_stochastic",
|
| 28 |
+
"sla_compliance_pct": 90.0,
|
| 29 |
+
"mttr_hours": 4.2,
|
| 30 |
+
"ftf_rate_pct": 80.0,
|
| 31 |
+
"logistics_cost": 1170.53,
|
| 32 |
+
"downtime_cost": 58191.35,
|
| 33 |
+
"solve_time_sec": 0.006857800006400794
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
"industry": "data_center",
|
| 37 |
+
"stress": "technician_shortage",
|
| 38 |
+
"algorithm": "two_stage_stochastic",
|
| 39 |
+
"sla_compliance_pct": 0.0,
|
| 40 |
+
"mttr_hours": 4.61,
|
| 41 |
+
"ftf_rate_pct": 83.3,
|
| 42 |
+
"logistics_cost": 217.76,
|
| 43 |
+
"downtime_cost": 38117.22,
|
| 44 |
+
"solve_time_sec": 0.0037087000091560185
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"industry": "elevators",
|
| 48 |
+
"stress": "peak_season",
|
| 49 |
+
"algorithm": "two_stage_stochastic",
|
| 50 |
+
"sla_compliance_pct": 11.1,
|
| 51 |
+
"mttr_hours": 4.71,
|
| 52 |
+
"ftf_rate_pct": 77.8,
|
| 53 |
+
"logistics_cost": 566.36,
|
| 54 |
+
"downtime_cost": 25018.85,
|
| 55 |
+
"solve_time_sec": 0.00974899998982437
|
| 56 |
+
}
|
| 57 |
+
]
|
gradio/assets/demo/summary.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"project": "FieldOps",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"scenarios": 5,
|
| 5 |
+
"algorithms": 7,
|
| 6 |
+
"primary_metrics": [
|
| 7 |
+
"sla_compliance_pct",
|
| 8 |
+
"mean_time_to_repair_hours",
|
| 9 |
+
"first_time_fix_rate_pct",
|
| 10 |
+
"emergency_stockout_count",
|
| 11 |
+
"technician_utilization_pct",
|
| 12 |
+
"total_logistics_cost",
|
| 13 |
+
"total_downtime_cost"
|
| 14 |
+
]
|
| 15 |
+
}
|
gradio/requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ortools>=9.8
|
| 2 |
+
numpy>=1.24
|
| 3 |
+
pandas>=2.0
|
| 4 |
+
plotly>=5.18
|
gradio/src/fieldops/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FieldOps — Service Parts and Field Operations Optimizer."""
|
| 2 |
+
|
| 3 |
+
from fieldops.constants import ENGINE_VERSION
|
| 4 |
+
|
| 5 |
+
__version__ = ENGINE_VERSION
|
gradio/src/fieldops/alns.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adaptive Large Neighborhood Search for field operations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import copy
|
| 6 |
+
import random
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
from fieldops.inventory_optimization import optimize_inventory
|
| 10 |
+
from fieldops.ml_predictions import predict_all
|
| 11 |
+
from fieldops.metrics import compute_metrics
|
| 12 |
+
from fieldops.models import OptimizationResult, ProblemInstance, Stage1Plan
|
| 13 |
+
from fieldops.routing import solve_routing
|
| 14 |
+
from fieldops.scheduling import assign_missions
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _destroy(assignments: list[dict], fraction: float, rng: random.Random) -> list[dict]:
|
| 18 |
+
n_remove = max(1, int(len(assignments) * fraction))
|
| 19 |
+
remaining = copy.deepcopy(assignments)
|
| 20 |
+
to_remove = rng.sample(range(len(remaining)), min(n_remove, len(remaining)))
|
| 21 |
+
for idx in sorted(to_remove, reverse=True):
|
| 22 |
+
remaining.pop(idx)
|
| 23 |
+
return remaining
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _repair(problem: ProblemInstance, partial: list[dict], predictions) -> list[dict]:
|
| 27 |
+
assigned_ids = {a["request_id"] for a in partial}
|
| 28 |
+
missing = [r for r in problem.requests if r.request_id not in assigned_ids]
|
| 29 |
+
if not missing:
|
| 30 |
+
return partial
|
| 31 |
+
|
| 32 |
+
sub_problem = copy.deepcopy(problem)
|
| 33 |
+
sub_problem.requests = missing
|
| 34 |
+
new_assignments = assign_missions(sub_problem, predictions)
|
| 35 |
+
return partial + new_assignments
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def solve_alns(
|
| 39 |
+
problem: ProblemInstance,
|
| 40 |
+
max_iterations: int = 30,
|
| 41 |
+
time_limit_sec: float = 8.0,
|
| 42 |
+
seed: int = 42,
|
| 43 |
+
) -> tuple[Stage1Plan, list[dict], list[str]]:
|
| 44 |
+
"""ALNS metaheuristic for field service planning."""
|
| 45 |
+
rng = random.Random(seed)
|
| 46 |
+
t0 = time.perf_counter()
|
| 47 |
+
stage1 = optimize_inventory(problem, problem.stock_vs_expedite_ratio)
|
| 48 |
+
predictions = predict_all(problem)
|
| 49 |
+
|
| 50 |
+
current = assign_missions(problem, predictions)
|
| 51 |
+
current_metrics = compute_metrics(problem, current, stage1, predictions)
|
| 52 |
+
current_cost = current_metrics.total_logistics_cost + current_metrics.total_downtime_cost
|
| 53 |
+
best = copy.deepcopy(current)
|
| 54 |
+
best_cost = current_cost
|
| 55 |
+
|
| 56 |
+
destroy_rates = [0.2, 0.35, 0.5]
|
| 57 |
+
iteration = 0
|
| 58 |
+
|
| 59 |
+
while iteration < max_iterations and (time.perf_counter() - t0) < time_limit_sec:
|
| 60 |
+
rate = destroy_rates[iteration % len(destroy_rates)]
|
| 61 |
+
partial = _destroy(current, rate, rng)
|
| 62 |
+
candidate = _repair(problem, partial, predictions)
|
| 63 |
+
cand_metrics = compute_metrics(problem, candidate, stage1, predictions)
|
| 64 |
+
cand_cost = cand_metrics.total_logistics_cost + cand_metrics.total_downtime_cost
|
| 65 |
+
|
| 66 |
+
delta = cand_cost - current_cost
|
| 67 |
+
temp = max(0.01, 1.0 - iteration / max_iterations)
|
| 68 |
+
accept = delta < 0 or rng.random() < pow(2.718, -delta / (temp * best_cost * 0.1 + 1))
|
| 69 |
+
|
| 70 |
+
if accept:
|
| 71 |
+
current = candidate
|
| 72 |
+
current_cost = cand_cost
|
| 73 |
+
if cand_cost < best_cost:
|
| 74 |
+
best = copy.deepcopy(candidate)
|
| 75 |
+
best_cost = cand_cost
|
| 76 |
+
|
| 77 |
+
iteration += 1
|
| 78 |
+
|
| 79 |
+
notes = [
|
| 80 |
+
f"ALNS: {iteration} iterations, best cost ${best_cost:,.0f}.",
|
| 81 |
+
"Operators: random destroy + greedy repair on mission assignments.",
|
| 82 |
+
]
|
| 83 |
+
return stage1, best, notes
|
gradio/src/fieldops/constants.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Constants for FieldOps engine."""
|
| 2 |
+
|
| 3 |
+
ENGINE_VERSION = "1.0.0"
|
| 4 |
+
|
| 5 |
+
INDUSTRY_SCENARIOS: dict[str, dict] = {
|
| 6 |
+
"industrial_equipment": {
|
| 7 |
+
"label": "Industrial Equipment",
|
| 8 |
+
"default_sites": 12,
|
| 9 |
+
"default_warehouses": 3,
|
| 10 |
+
"default_technicians": 8,
|
| 11 |
+
"failure_rate": 0.08,
|
| 12 |
+
"sla_hours": 8,
|
| 13 |
+
"downtime_cost": 2500,
|
| 14 |
+
},
|
| 15 |
+
"elevators": {
|
| 16 |
+
"label": "Elevators & Lifts",
|
| 17 |
+
"default_sites": 18,
|
| 18 |
+
"default_warehouses": 2,
|
| 19 |
+
"default_technicians": 10,
|
| 20 |
+
"failure_rate": 0.06,
|
| 21 |
+
"sla_hours": 4,
|
| 22 |
+
"downtime_cost": 1800,
|
| 23 |
+
},
|
| 24 |
+
"medical_equipment": {
|
| 25 |
+
"label": "Medical Equipment",
|
| 26 |
+
"default_sites": 10,
|
| 27 |
+
"default_warehouses": 2,
|
| 28 |
+
"default_technicians": 6,
|
| 29 |
+
"failure_rate": 0.05,
|
| 30 |
+
"sla_hours": 2,
|
| 31 |
+
"downtime_cost": 5000,
|
| 32 |
+
},
|
| 33 |
+
"machinery": {
|
| 34 |
+
"label": "Heavy Machinery",
|
| 35 |
+
"default_sites": 8,
|
| 36 |
+
"default_warehouses": 2,
|
| 37 |
+
"default_technicians": 6,
|
| 38 |
+
"failure_rate": 0.10,
|
| 39 |
+
"sla_hours": 12,
|
| 40 |
+
"downtime_cost": 3200,
|
| 41 |
+
},
|
| 42 |
+
"data_center": {
|
| 43 |
+
"label": "Data Center",
|
| 44 |
+
"default_sites": 6,
|
| 45 |
+
"default_warehouses": 2,
|
| 46 |
+
"default_technicians": 5,
|
| 47 |
+
"failure_rate": 0.04,
|
| 48 |
+
"sla_hours": 1,
|
| 49 |
+
"downtime_cost": 8000,
|
| 50 |
+
},
|
| 51 |
+
"telecom": {
|
| 52 |
+
"label": "Telecom Infrastructure",
|
| 53 |
+
"default_sites": 20,
|
| 54 |
+
"default_warehouses": 4,
|
| 55 |
+
"default_technicians": 12,
|
| 56 |
+
"failure_rate": 0.07,
|
| 57 |
+
"sla_hours": 6,
|
| 58 |
+
"downtime_cost": 4000,
|
| 59 |
+
},
|
| 60 |
+
"home_appliances": {
|
| 61 |
+
"label": "Home Appliances",
|
| 62 |
+
"default_sites": 25,
|
| 63 |
+
"default_warehouses": 3,
|
| 64 |
+
"default_technicians": 15,
|
| 65 |
+
"failure_rate": 0.12,
|
| 66 |
+
"sla_hours": 24,
|
| 67 |
+
"downtime_cost": 200,
|
| 68 |
+
},
|
| 69 |
+
"automotive": {
|
| 70 |
+
"label": "Automotive After-Sales",
|
| 71 |
+
"default_sites": 15,
|
| 72 |
+
"default_warehouses": 3,
|
| 73 |
+
"default_technicians": 10,
|
| 74 |
+
"failure_rate": 0.09,
|
| 75 |
+
"sla_hours": 8,
|
| 76 |
+
"downtime_cost": 600,
|
| 77 |
+
},
|
| 78 |
+
"power_plant": {
|
| 79 |
+
"label": "Power Plant",
|
| 80 |
+
"default_sites": 5,
|
| 81 |
+
"default_warehouses": 2,
|
| 82 |
+
"default_technicians": 4,
|
| 83 |
+
"failure_rate": 0.03,
|
| 84 |
+
"sla_hours": 4,
|
| 85 |
+
"downtime_cost": 12000,
|
| 86 |
+
},
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
STRESS_SCENARIOS: dict[str, dict] = {
|
| 90 |
+
"baseline": {"label": "Baseline Demand", "failure_multiplier": 1.0, "stock_multiplier": 1.0},
|
| 91 |
+
"peak_season": {"label": "Peak Season", "failure_multiplier": 1.4, "stock_multiplier": 0.9},
|
| 92 |
+
"parts_shortage": {"label": "Parts Shortage", "failure_multiplier": 1.0, "stock_multiplier": 0.5},
|
| 93 |
+
"technician_shortage": {"label": "Technician Shortage", "failure_multiplier": 1.2, "stock_multiplier": 1.0},
|
| 94 |
+
"emergency_surge": {"label": "Emergency Surge", "failure_multiplier": 2.0, "stock_multiplier": 0.7},
|
| 95 |
+
"regional_outage": {"label": "Regional Outage", "failure_multiplier": 1.8, "stock_multiplier": 0.6},
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
STRATEGIES: list[str] = [
|
| 99 |
+
"two_stage_stochastic",
|
| 100 |
+
"multi_echelon_inventory",
|
| 101 |
+
"technician_routing",
|
| 102 |
+
"sample_average_approximation",
|
| 103 |
+
"alns_field_ops",
|
| 104 |
+
"stock_vs_expedite",
|
| 105 |
+
"monte_carlo",
|
| 106 |
+
]
|
| 107 |
+
|
| 108 |
+
STAGE_LABELS = {
|
| 109 |
+
"stage1": "Stage 1 — Pre-Failure Planning",
|
| 110 |
+
"stage2": "Stage 2 — Post-Failure Response",
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
PART_TYPES = [
|
| 114 |
+
"control_board", "motor_drive", "sensor_module", "hydraulic_pump",
|
| 115 |
+
"bearing_kit", "power_supply", "cooling_fan", "communication_card",
|
| 116 |
+
]
|
| 117 |
+
|
| 118 |
+
SKILL_TYPES = ["electrical", "mechanical", "hydraulic", "electronics", "network"]
|
gradio/src/fieldops/engine.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unified field service optimization engine."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from fieldops.alns import solve_alns
|
| 8 |
+
from fieldops.constants import STRATEGIES
|
| 9 |
+
from fieldops.inventory_optimization import optimize_inventory
|
| 10 |
+
from fieldops.metrics import compute_metrics
|
| 11 |
+
from fieldops.ml_predictions import predict_all
|
| 12 |
+
from fieldops.models import OptimizationResult, ProblemInstance
|
| 13 |
+
from fieldops.routing import solve_routing
|
| 14 |
+
from fieldops.scheduling import assign_missions
|
| 15 |
+
from fieldops.simulation import run_monte_carlo
|
| 16 |
+
from fieldops.stochastic import solve_saa, solve_two_stage_stochastic
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class FieldOpsEngine:
|
| 20 |
+
"""Dispatch field service problems to predict-then-optimize solvers."""
|
| 21 |
+
|
| 22 |
+
def optimize(
|
| 23 |
+
self,
|
| 24 |
+
problem: ProblemInstance,
|
| 25 |
+
strategy: str = "two_stage_stochastic",
|
| 26 |
+
run_simulation: bool = True,
|
| 27 |
+
) -> OptimizationResult:
|
| 28 |
+
strategy = strategy.lower().replace(" ", "_")
|
| 29 |
+
t0 = time.perf_counter()
|
| 30 |
+
predictions = predict_all(problem)
|
| 31 |
+
|
| 32 |
+
if strategy == "two_stage_stochastic":
|
| 33 |
+
stage1, assignments, notes = solve_two_stage_stochastic(problem)
|
| 34 |
+
algorithm = "two_stage_stochastic_programming"
|
| 35 |
+
elif strategy == "multi_echelon_inventory":
|
| 36 |
+
stage1 = optimize_inventory(problem, problem.stock_vs_expedite_ratio)
|
| 37 |
+
assignments = assign_missions(problem, predictions)
|
| 38 |
+
notes = ["Multi-echelon inventory optimization with greedy assignment."]
|
| 39 |
+
algorithm = "multi_echelon_inventory"
|
| 40 |
+
elif strategy == "technician_routing":
|
| 41 |
+
stage1 = optimize_inventory(problem, problem.stock_vs_expedite_ratio)
|
| 42 |
+
assignments = assign_missions(problem, predictions)
|
| 43 |
+
notes = ["OR-Tools VRP routing with skill-based pre-assignment."]
|
| 44 |
+
algorithm = "or_tools_routing"
|
| 45 |
+
elif strategy == "sample_average_approximation":
|
| 46 |
+
stage1, assignments, notes = solve_saa(problem, n_samples=50)
|
| 47 |
+
algorithm = "sample_average_approximation"
|
| 48 |
+
elif strategy == "alns_field_ops":
|
| 49 |
+
stage1, assignments, notes = solve_alns(problem)
|
| 50 |
+
algorithm = "adaptive_large_neighborhood_search"
|
| 51 |
+
elif strategy == "stock_vs_expedite":
|
| 52 |
+
stage1 = optimize_inventory(problem, problem.stock_vs_expedite_ratio)
|
| 53 |
+
assignments = assign_missions(problem, predictions)
|
| 54 |
+
notes = [
|
| 55 |
+
f"Stock vs expedite trade-off at {problem.stock_vs_expedite_ratio:.0%}.",
|
| 56 |
+
"Higher stock ratio → lower expedite cost, higher holding cost.",
|
| 57 |
+
]
|
| 58 |
+
algorithm = "stock_expedite_tradeoff"
|
| 59 |
+
elif strategy == "monte_carlo":
|
| 60 |
+
stage1 = optimize_inventory(problem, 0.5)
|
| 61 |
+
assignments = assign_missions(problem, predictions)
|
| 62 |
+
notes = ["Monte Carlo validation of baseline plan."]
|
| 63 |
+
algorithm = "monte_carlo_simulation"
|
| 64 |
+
else:
|
| 65 |
+
raise ValueError(f"Unknown strategy: {strategy}. Choose from: {STRATEGIES}")
|
| 66 |
+
|
| 67 |
+
mission_assignments = solve_routing(problem, predictions, assignments)
|
| 68 |
+
metrics = compute_metrics(problem, assignments, stage1, predictions)
|
| 69 |
+
|
| 70 |
+
simulation = None
|
| 71 |
+
if run_simulation or strategy == "monte_carlo":
|
| 72 |
+
simulation = run_monte_carlo(
|
| 73 |
+
problem, mission_assignments, stage1, predictions, replications=30,
|
| 74 |
+
)
|
| 75 |
+
notes.append(
|
| 76 |
+
f"Monte Carlo ({simulation.replications} runs): "
|
| 77 |
+
f"SLA {simulation.sla_compliance_mean:.1f}%, "
|
| 78 |
+
f"MTTR {simulation.mttr_mean:.1f}h."
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
return OptimizationResult(
|
| 82 |
+
strategy=strategy,
|
| 83 |
+
algorithm=algorithm,
|
| 84 |
+
stage1_plan=stage1,
|
| 85 |
+
assignments=mission_assignments,
|
| 86 |
+
ml_predictions=predictions,
|
| 87 |
+
metrics=metrics,
|
| 88 |
+
simulation=simulation,
|
| 89 |
+
solve_time_sec=time.perf_counter() - t0,
|
| 90 |
+
status="optimal",
|
| 91 |
+
notes=notes,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
def compare_strategies(
|
| 95 |
+
self,
|
| 96 |
+
problem: ProblemInstance,
|
| 97 |
+
strategy_list: list[str] | None = None,
|
| 98 |
+
) -> list[OptimizationResult]:
|
| 99 |
+
targets = strategy_list or [
|
| 100 |
+
"two_stage_stochastic",
|
| 101 |
+
"multi_echelon_inventory",
|
| 102 |
+
"technician_routing",
|
| 103 |
+
"alns_field_ops",
|
| 104 |
+
]
|
| 105 |
+
results = []
|
| 106 |
+
for strat in targets:
|
| 107 |
+
try:
|
| 108 |
+
results.append(self.optimize(problem, strat, run_simulation=True))
|
| 109 |
+
except Exception:
|
| 110 |
+
continue
|
| 111 |
+
return results
|
| 112 |
+
|
| 113 |
+
def compare_stock_expedite(
|
| 114 |
+
self,
|
| 115 |
+
problem: ProblemInstance,
|
| 116 |
+
ratios: list[float] | None = None,
|
| 117 |
+
) -> list[OptimizationResult]:
|
| 118 |
+
ratios = ratios or [0.2, 0.4, 0.6, 0.8]
|
| 119 |
+
results = []
|
| 120 |
+
for ratio in ratios:
|
| 121 |
+
import copy
|
| 122 |
+
p = copy.deepcopy(problem)
|
| 123 |
+
p.stock_vs_expedite_ratio = ratio
|
| 124 |
+
results.append(self.optimize(p, "stock_vs_expedite", run_simulation=True))
|
| 125 |
+
return results
|
gradio/src/fieldops/generator.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Synthetic field service problem generator."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import random
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from fieldops.constants import INDUSTRY_SCENARIOS, PART_TYPES, SKILL_TYPES, STRESS_SCENARIOS
|
| 10 |
+
from fieldops.models import (
|
| 11 |
+
InventoryLevel,
|
| 12 |
+
ProblemInstance,
|
| 13 |
+
ServiceRequest,
|
| 14 |
+
ServiceSite,
|
| 15 |
+
SparePart,
|
| 16 |
+
Technician,
|
| 17 |
+
Warehouse,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
REGION_CENTERS = {
|
| 21 |
+
"north": (52.5, 13.4),
|
| 22 |
+
"south": (48.1, 11.6),
|
| 23 |
+
"east": (51.0, 14.0),
|
| 24 |
+
"west": (50.9, 6.9),
|
| 25 |
+
"central": (51.2, 10.4),
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _seed(*parts: str) -> int:
|
| 30 |
+
h = hashlib.md5("|".join(parts).encode()).hexdigest()
|
| 31 |
+
return int(h[:8], 16)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _jitter(rng: random.Random, lat: float, lon: float, spread: float = 0.8) -> tuple[float, float]:
|
| 35 |
+
return lat + rng.uniform(-spread, spread), lon + rng.uniform(-spread, spread)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def generate_problem(
|
| 39 |
+
industry_id: str,
|
| 40 |
+
stress_id: str = "baseline",
|
| 41 |
+
num_sites: int | None = None,
|
| 42 |
+
num_technicians: int | None = None,
|
| 43 |
+
num_requests: int | None = None,
|
| 44 |
+
stock_vs_expedite: float = 0.5,
|
| 45 |
+
reliability_target: float = 0.95,
|
| 46 |
+
seed: int = 42,
|
| 47 |
+
) -> ProblemInstance:
|
| 48 |
+
meta = INDUSTRY_SCENARIOS[industry_id]
|
| 49 |
+
stress = STRESS_SCENARIOS.get(stress_id, STRESS_SCENARIOS["baseline"])
|
| 50 |
+
rng = random.Random(seed)
|
| 51 |
+
|
| 52 |
+
n_sites = num_sites or meta["default_sites"]
|
| 53 |
+
n_techs = num_technicians or meta["default_technicians"]
|
| 54 |
+
n_requests = num_requests or max(6, n_sites // 2)
|
| 55 |
+
n_warehouses = meta.get("default_warehouses", 3)
|
| 56 |
+
|
| 57 |
+
regions = list(REGION_CENTERS.keys())
|
| 58 |
+
warehouses: list[Warehouse] = []
|
| 59 |
+
for i in range(n_warehouses):
|
| 60 |
+
region = regions[i % len(regions)]
|
| 61 |
+
lat, lon = REGION_CENTERS[region]
|
| 62 |
+
lat, lon = _jitter(rng, lat, lon, 0.3)
|
| 63 |
+
warehouses.append(Warehouse(
|
| 64 |
+
warehouse_id=f"WH-{i+1:02d}",
|
| 65 |
+
name=f"{region.title()} Depot",
|
| 66 |
+
region=region,
|
| 67 |
+
lat=lat,
|
| 68 |
+
lon=lon,
|
| 69 |
+
capacity_units=rng.randint(80, 200),
|
| 70 |
+
expedite_cost_per_unit=rng.uniform(50, 150),
|
| 71 |
+
))
|
| 72 |
+
|
| 73 |
+
spare_parts: list[SparePart] = []
|
| 74 |
+
for i, pt in enumerate(PART_TYPES[:6]):
|
| 75 |
+
sub_id = PART_TYPES[(i + 1) % 6] if i < 5 else None
|
| 76 |
+
spare_parts.append(SparePart(
|
| 77 |
+
part_id=f"P-{i+1:03d}",
|
| 78 |
+
name=pt.replace("_", " ").title(),
|
| 79 |
+
part_type=pt,
|
| 80 |
+
unit_cost=rng.uniform(80, 2500),
|
| 81 |
+
holding_cost_per_day=rng.uniform(2, 25),
|
| 82 |
+
expedite_cost=rng.uniform(100, 800),
|
| 83 |
+
substitute_part_id=f"P-{(i % 5) + 1:03d}" if sub_id else None,
|
| 84 |
+
))
|
| 85 |
+
|
| 86 |
+
inventory: list[InventoryLevel] = []
|
| 87 |
+
stock_mult = stress["stock_multiplier"]
|
| 88 |
+
for wh in warehouses:
|
| 89 |
+
for part in spare_parts:
|
| 90 |
+
base_qty = rng.randint(2, 12)
|
| 91 |
+
qty = max(1, int(base_qty * stock_mult))
|
| 92 |
+
inventory.append(InventoryLevel(
|
| 93 |
+
warehouse_id=wh.warehouse_id,
|
| 94 |
+
part_id=part.part_id,
|
| 95 |
+
quantity=qty,
|
| 96 |
+
reorder_point=max(1, qty // 3),
|
| 97 |
+
safety_stock=max(1, qty // 4),
|
| 98 |
+
))
|
| 99 |
+
|
| 100 |
+
technicians: list[Technician] = []
|
| 101 |
+
for i in range(n_techs):
|
| 102 |
+
region = regions[i % len(regions)]
|
| 103 |
+
lat, lon = REGION_CENTERS[region]
|
| 104 |
+
lat, lon = _jitter(rng, lat, lon, 0.5)
|
| 105 |
+
skills = rng.sample(SKILL_TYPES, k=rng.randint(2, 3))
|
| 106 |
+
technicians.append(Technician(
|
| 107 |
+
technician_id=f"T-{i+1:02d}",
|
| 108 |
+
name=f"Tech {chr(65 + i)}",
|
| 109 |
+
skills=skills,
|
| 110 |
+
skill_level=rng.uniform(0.6, 1.0),
|
| 111 |
+
hourly_rate=rng.uniform(35, 85),
|
| 112 |
+
home_lat=lat,
|
| 113 |
+
home_lon=lon,
|
| 114 |
+
max_jobs_per_day=rng.randint(3, 6),
|
| 115 |
+
region=region,
|
| 116 |
+
))
|
| 117 |
+
|
| 118 |
+
sites: list[ServiceSite] = []
|
| 119 |
+
criticalities = ["critical", "high", "medium", "low"]
|
| 120 |
+
for i in range(n_sites):
|
| 121 |
+
region = regions[i % len(regions)]
|
| 122 |
+
lat, lon = REGION_CENTERS[region]
|
| 123 |
+
lat, lon = _jitter(rng, lat, lon, 1.2)
|
| 124 |
+
crit = rng.choices(criticalities, weights=[0.15, 0.25, 0.35, 0.25])[0]
|
| 125 |
+
sites.append(ServiceSite(
|
| 126 |
+
site_id=f"S-{i+1:03d}",
|
| 127 |
+
name=f"Site {region.title()}-{i+1}",
|
| 128 |
+
asset_type=rng.choice(["compressor", "hvac", "drive", "controller", "pump"]),
|
| 129 |
+
criticality=crit,
|
| 130 |
+
lat=lat,
|
| 131 |
+
lon=lon,
|
| 132 |
+
sla_hours=meta["sla_hours"] * (0.5 if crit == "critical" else 1.0),
|
| 133 |
+
downtime_cost_per_hour=meta["downtime_cost"] * (2.0 if crit == "critical" else 1.0),
|
| 134 |
+
contract_tier=rng.choice(["gold", "silver", "bronze"]),
|
| 135 |
+
))
|
| 136 |
+
|
| 137 |
+
requests: list[ServiceRequest] = []
|
| 138 |
+
failure_mult = stress["failure_multiplier"]
|
| 139 |
+
for i in range(n_requests):
|
| 140 |
+
site = rng.choice(sites)
|
| 141 |
+
is_emergency = rng.random() < 0.2 * failure_mult
|
| 142 |
+
part_type = rng.choice(PART_TYPES)
|
| 143 |
+
skill = rng.choice(SKILL_TYPES)
|
| 144 |
+
requests.append(ServiceRequest(
|
| 145 |
+
request_id=f"SR-{i+1:04d}",
|
| 146 |
+
site_id=site.site_id,
|
| 147 |
+
site_name=site.name,
|
| 148 |
+
priority="P1" if is_emergency else rng.choice(["P2", "P3", "P4"]),
|
| 149 |
+
failure_type=rng.choice(["mechanical", "electrical", "sensor", "wear"]),
|
| 150 |
+
required_part_type=part_type,
|
| 151 |
+
required_skill=skill,
|
| 152 |
+
estimated_duration_hours=rng.uniform(1.5, 6.0),
|
| 153 |
+
is_emergency=is_emergency,
|
| 154 |
+
sla_deadline_hours=site.sla_hours,
|
| 155 |
+
lat=site.lat,
|
| 156 |
+
lon=site.lon,
|
| 157 |
+
))
|
| 158 |
+
|
| 159 |
+
label = f"{meta['label']} — {stress['label']}"
|
| 160 |
+
return ProblemInstance(
|
| 161 |
+
industry_id=industry_id,
|
| 162 |
+
stress_scenario=stress_id,
|
| 163 |
+
scenario_label=label,
|
| 164 |
+
planning_horizon_days=7,
|
| 165 |
+
warehouses=warehouses,
|
| 166 |
+
spare_parts=spare_parts,
|
| 167 |
+
inventory=inventory,
|
| 168 |
+
technicians=technicians,
|
| 169 |
+
sites=sites,
|
| 170 |
+
requests=requests,
|
| 171 |
+
stock_vs_expedite_ratio=stock_vs_expedite,
|
| 172 |
+
reliability_target=reliability_target,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def sites_to_table_rows(problem: ProblemInstance) -> list[dict[str, Any]]:
|
| 177 |
+
return [
|
| 178 |
+
{
|
| 179 |
+
"Site": s.site_id,
|
| 180 |
+
"Name": s.name,
|
| 181 |
+
"Criticality": s.criticality,
|
| 182 |
+
"SLA (h)": s.sla_hours,
|
| 183 |
+
"Contract": s.contract_tier,
|
| 184 |
+
"Downtime $/h": f"${s.downtime_cost_per_hour:,.0f}",
|
| 185 |
+
}
|
| 186 |
+
for s in problem.sites
|
| 187 |
+
]
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def technicians_to_table_rows(problem: ProblemInstance) -> list[dict[str, Any]]:
|
| 191 |
+
return [
|
| 192 |
+
{
|
| 193 |
+
"Technician": t.technician_id,
|
| 194 |
+
"Name": t.name,
|
| 195 |
+
"Skills": ", ".join(t.skills),
|
| 196 |
+
"Level": f"{t.skill_level:.0%}",
|
| 197 |
+
"Region": t.region,
|
| 198 |
+
"Max Jobs/Day": t.max_jobs_per_day,
|
| 199 |
+
}
|
| 200 |
+
for t in problem.technicians
|
| 201 |
+
]
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def inventory_to_table_rows(problem: ProblemInstance) -> list[dict[str, Any]]:
|
| 205 |
+
part_map = {p.part_id: p.name for p in problem.spare_parts}
|
| 206 |
+
return [
|
| 207 |
+
{
|
| 208 |
+
"Warehouse": inv.warehouse_id,
|
| 209 |
+
"Part": part_map.get(inv.part_id, inv.part_id),
|
| 210 |
+
"Qty": inv.quantity,
|
| 211 |
+
"Reorder Pt": inv.reorder_point,
|
| 212 |
+
"Safety Stock": inv.safety_stock,
|
| 213 |
+
}
|
| 214 |
+
for inv in problem.inventory
|
| 215 |
+
]
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def requests_to_table_rows(problem: ProblemInstance) -> list[dict[str, Any]]:
|
| 219 |
+
return [
|
| 220 |
+
{
|
| 221 |
+
"Request": r.request_id,
|
| 222 |
+
"Site": r.site_name,
|
| 223 |
+
"Priority": r.priority,
|
| 224 |
+
"Emergency": "Yes" if r.is_emergency else "No",
|
| 225 |
+
"Part Type": r.required_part_type,
|
| 226 |
+
"Skill": r.required_skill,
|
| 227 |
+
"SLA (h)": r.sla_deadline_hours,
|
| 228 |
+
}
|
| 229 |
+
for r in problem.requests
|
| 230 |
+
]
|
gradio/src/fieldops/inventory_optimization.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-echelon inventory optimization."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
|
| 7 |
+
from fieldops.models import InventoryLevel, ProblemInstance, SparePart, Stage1Plan, Warehouse
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
| 11 |
+
r = 6371.0
|
| 12 |
+
p1, p2 = math.radians(lat1), math.radians(lat2)
|
| 13 |
+
dp = math.radians(lat2 - lat1)
|
| 14 |
+
dl = math.radians(lon2 - lon1)
|
| 15 |
+
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
| 16 |
+
return 2 * r * math.asin(math.sqrt(a))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def optimize_inventory(
|
| 20 |
+
problem: ProblemInstance,
|
| 21 |
+
stock_vs_expedite: float = 0.5,
|
| 22 |
+
) -> Stage1Plan:
|
| 23 |
+
"""Heuristic multi-echelon inventory targeting with stock vs expedite trade-off."""
|
| 24 |
+
part_map: dict[str, SparePart] = {p.part_id: p for p in problem.spare_parts}
|
| 25 |
+
demand_by_part: dict[str, float] = {}
|
| 26 |
+
for req in problem.requests:
|
| 27 |
+
for part in problem.spare_parts:
|
| 28 |
+
if part.part_type == req.required_part_type:
|
| 29 |
+
demand_by_part[part.part_id] = demand_by_part.get(part.part_id, 0) + 1
|
| 30 |
+
break
|
| 31 |
+
|
| 32 |
+
targets: list[InventoryLevel] = []
|
| 33 |
+
total_holding = 0.0
|
| 34 |
+
stock_bias = stock_vs_expedite
|
| 35 |
+
|
| 36 |
+
for wh in problem.warehouses:
|
| 37 |
+
for part_id, demand in demand_by_part.items():
|
| 38 |
+
part = part_map[part_id]
|
| 39 |
+
base_stock = max(2, int(math.ceil(demand * (0.8 + stock_bias * 0.6))))
|
| 40 |
+
safety = max(1, int(base_stock * (0.3 + stock_bias * 0.2)))
|
| 41 |
+
reorder = max(1, base_stock // 2)
|
| 42 |
+
targets.append(InventoryLevel(
|
| 43 |
+
warehouse_id=wh.warehouse_id,
|
| 44 |
+
part_id=part_id,
|
| 45 |
+
quantity=base_stock,
|
| 46 |
+
reorder_point=reorder,
|
| 47 |
+
safety_stock=safety,
|
| 48 |
+
))
|
| 49 |
+
total_holding += base_stock * part.holding_cost_per_day * problem.planning_horizon_days
|
| 50 |
+
|
| 51 |
+
tech_positions: dict[str, tuple[float, float]] = {}
|
| 52 |
+
region_sites: dict[str, list[tuple[float, float]]] = {}
|
| 53 |
+
for site in problem.sites:
|
| 54 |
+
region_sites.setdefault(site.site_id[:2], []).append((site.lat, site.lon))
|
| 55 |
+
|
| 56 |
+
for tech in problem.technicians:
|
| 57 |
+
site_coords = [(s.lat, s.lon) for s in problem.sites if s.site_id.startswith("S")]
|
| 58 |
+
if site_coords:
|
| 59 |
+
avg_lat = sum(c[0] for c in site_coords) / len(site_coords)
|
| 60 |
+
avg_lon = sum(c[1] for c in site_coords) / len(site_coords)
|
| 61 |
+
tech_positions[tech.technician_id] = (avg_lat, avg_lon)
|
| 62 |
+
else:
|
| 63 |
+
tech_positions[tech.technician_id] = (tech.home_lat, tech.home_lon)
|
| 64 |
+
|
| 65 |
+
contracts = []
|
| 66 |
+
for wh in problem.warehouses:
|
| 67 |
+
for part in problem.spare_parts[:3]:
|
| 68 |
+
contracts.append({
|
| 69 |
+
"warehouse": wh.warehouse_id,
|
| 70 |
+
"part": part.part_id,
|
| 71 |
+
"min_order_qty": 5,
|
| 72 |
+
"lead_time_days": 2 if stock_bias > 0.5 else 5,
|
| 73 |
+
"expedite_available": stock_bias < 0.7,
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
notes = [
|
| 77 |
+
f"Stock vs expedite ratio: {stock_vs_expedite:.0%} (higher = more pre-positioned inventory).",
|
| 78 |
+
f"Multi-echelon targets across {len(problem.warehouses)} warehouses.",
|
| 79 |
+
f"Estimated holding cost: ${total_holding:,.0f} over {problem.planning_horizon_days} days.",
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
return Stage1Plan(
|
| 83 |
+
technician_positions=tech_positions,
|
| 84 |
+
inventory_targets=targets,
|
| 85 |
+
supply_contracts=contracts,
|
| 86 |
+
repair_shop_capacity=max(2, len(problem.technicians) // 3),
|
| 87 |
+
total_holding_cost=total_holding,
|
| 88 |
+
notes=notes,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def find_nearest_warehouse(
|
| 93 |
+
warehouses: list[Warehouse],
|
| 94 |
+
inventory: list[InventoryLevel],
|
| 95 |
+
part_id: str,
|
| 96 |
+
lat: float,
|
| 97 |
+
lon: float,
|
| 98 |
+
) -> tuple[Warehouse, int, float]:
|
| 99 |
+
"""Return nearest warehouse with stock for part_id."""
|
| 100 |
+
inv_map = {(i.warehouse_id, i.part_id): i.quantity for i in inventory}
|
| 101 |
+
wh_map = {w.warehouse_id: w for w in warehouses}
|
| 102 |
+
best_wh, best_qty, best_dist = None, 0, float("inf")
|
| 103 |
+
|
| 104 |
+
for wh in warehouses:
|
| 105 |
+
qty = inv_map.get((wh.warehouse_id, part_id), 0)
|
| 106 |
+
if qty <= 0:
|
| 107 |
+
continue
|
| 108 |
+
dist = _haversine_km(lat, lon, wh.lat, wh.lon)
|
| 109 |
+
if dist < best_dist:
|
| 110 |
+
best_wh, best_qty, best_dist = wh, qty, dist
|
| 111 |
+
|
| 112 |
+
if best_wh is None:
|
| 113 |
+
best_wh = min(warehouses, key=lambda w: _haversine_km(lat, lon, w.lat, w.lon))
|
| 114 |
+
best_qty = 0
|
| 115 |
+
best_dist = _haversine_km(lat, lon, best_wh.lat, best_wh.lon)
|
| 116 |
+
|
| 117 |
+
return best_wh, best_qty, best_dist
|
gradio/src/fieldops/metrics.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""KPI computation for field service operations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fieldops.models import KPIMetrics, MLPrediction, ProblemInstance, Stage1Plan
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def compute_metrics(
|
| 9 |
+
problem: ProblemInstance,
|
| 10 |
+
assignments: list[dict],
|
| 11 |
+
stage1: Stage1Plan,
|
| 12 |
+
predictions: list[MLPrediction],
|
| 13 |
+
) -> KPIMetrics:
|
| 14 |
+
pred_map = {p.request_id: p for p in predictions}
|
| 15 |
+
part_map = {p.part_id: p for p in problem.spare_parts}
|
| 16 |
+
site_map = {s.site_id: s for s in problem.sites}
|
| 17 |
+
req_map = {r.request_id: r for r in problem.requests}
|
| 18 |
+
|
| 19 |
+
sla_met = 0
|
| 20 |
+
total_mttr = 0.0
|
| 21 |
+
ftf_count = 0
|
| 22 |
+
stockouts = 0
|
| 23 |
+
expedite_count = 0
|
| 24 |
+
logistics_cost = 0.0
|
| 25 |
+
downtime_cost = 0.0
|
| 26 |
+
|
| 27 |
+
for a in assignments:
|
| 28 |
+
req = req_map[a["request_id"]]
|
| 29 |
+
pred = pred_map[a["request_id"]]
|
| 30 |
+
site = site_map.get(req.site_id)
|
| 31 |
+
downtime_rate = site.downtime_cost_per_hour if site else 500
|
| 32 |
+
|
| 33 |
+
duration = pred.predicted_duration_hours
|
| 34 |
+
travel = 0.5 + (req.lat % 1) * 2
|
| 35 |
+
mttr = duration + travel
|
| 36 |
+
total_mttr += mttr
|
| 37 |
+
|
| 38 |
+
if mttr <= req.sla_deadline_hours:
|
| 39 |
+
sla_met += 1
|
| 40 |
+
|
| 41 |
+
if pred.first_time_fix_probability > 0.7:
|
| 42 |
+
ftf_count += 1
|
| 43 |
+
|
| 44 |
+
if a.get("part_source") == "expedite_shipment":
|
| 45 |
+
expedite_count += 1
|
| 46 |
+
part = part_map.get(a["part_id"])
|
| 47 |
+
logistics_cost += (part.expedite_cost if part else 300)
|
| 48 |
+
elif a.get("part_source") == "warehouse_stock":
|
| 49 |
+
part = part_map.get(a["part_id"])
|
| 50 |
+
logistics_cost += (part.unit_cost * 0.1 if part else 50)
|
| 51 |
+
else:
|
| 52 |
+
logistics_cost += 80
|
| 53 |
+
|
| 54 |
+
if a.get("part_source") in ("expedite_shipment",) and a.get("defer"):
|
| 55 |
+
stockouts += 1
|
| 56 |
+
|
| 57 |
+
downtime_cost += mttr * downtime_rate * pred.failure_probability
|
| 58 |
+
|
| 59 |
+
n = max(len(assignments), 1)
|
| 60 |
+
tech_jobs: dict[str, int] = {}
|
| 61 |
+
for a in assignments:
|
| 62 |
+
tech_jobs[a["technician_id"]] = tech_jobs.get(a["technician_id"], 0) + 1
|
| 63 |
+
max_capacity = sum(t.max_jobs_per_day for t in problem.technicians)
|
| 64 |
+
utilization = sum(tech_jobs.values()) / max(max_capacity, 1) * 100
|
| 65 |
+
|
| 66 |
+
return KPIMetrics(
|
| 67 |
+
sla_compliance_pct=round(sla_met / n * 100, 1),
|
| 68 |
+
mean_time_to_repair_hours=round(total_mttr / n, 2),
|
| 69 |
+
first_time_fix_rate_pct=round(ftf_count / n * 100, 1),
|
| 70 |
+
emergency_stockout_count=stockouts,
|
| 71 |
+
technician_utilization_pct=round(utilization, 1),
|
| 72 |
+
total_logistics_cost=round(logistics_cost, 2),
|
| 73 |
+
total_downtime_cost=round(downtime_cost, 2),
|
| 74 |
+
total_holding_cost=round(stage1.total_holding_cost, 2),
|
| 75 |
+
expedite_shipments=expedite_count,
|
| 76 |
+
)
|
gradio/src/fieldops/ml_predictions.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pre-trained ML prediction module (no training — fixed coefficients)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
|
| 7 |
+
from fieldops.constants import PART_TYPES, STRESS_SCENARIOS
|
| 8 |
+
from fieldops.models import MLPrediction, ProblemInstance, ServiceRequest
|
| 9 |
+
|
| 10 |
+
# Pre-trained logistic coefficients (simulating LightGBM export)
|
| 11 |
+
_COEFFS = {
|
| 12 |
+
"intercept": -2.1,
|
| 13 |
+
"age_factor": 0.35,
|
| 14 |
+
"criticality": {"critical": 1.2, "high": 0.7, "medium": 0.3, "low": 0.0},
|
| 15 |
+
"emergency": 0.9,
|
| 16 |
+
"wear_types": {"mechanical": 0.4, "electrical": 0.25, "sensor": 0.15, "wear": 0.5},
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
_DURATION_BASE = {"mechanical": 3.2, "electrical": 2.8, "sensor": 1.5, "wear": 4.0}
|
| 20 |
+
_FTF_BASE = {"electrical": 0.82, "mechanical": 0.75, "sensor": 0.88, "wear": 0.70}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _sigmoid(x: float) -> float:
|
| 24 |
+
return 1.0 / (1.0 + math.exp(-max(-20, min(20, x))))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _site_criticality(problem: ProblemInstance, site_id: str) -> str:
|
| 28 |
+
for s in problem.sites:
|
| 29 |
+
if s.site_id == site_id:
|
| 30 |
+
return s.criticality
|
| 31 |
+
return "medium"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def predict_request(problem: ProblemInstance, request: ServiceRequest) -> MLPrediction:
|
| 35 |
+
stress = STRESS_SCENARIOS.get(problem.stress_scenario, STRESS_SCENARIOS["baseline"])
|
| 36 |
+
crit = _site_criticality(problem, request.site_id)
|
| 37 |
+
|
| 38 |
+
logit = _COEFFS["intercept"]
|
| 39 |
+
logit += _COEFFS["criticality"].get(crit, 0.3)
|
| 40 |
+
logit += _COEFFS["wear_types"].get(request.failure_type, 0.2)
|
| 41 |
+
if request.is_emergency:
|
| 42 |
+
logit += _COEFFS["emergency"]
|
| 43 |
+
logit *= stress["failure_multiplier"]
|
| 44 |
+
|
| 45 |
+
failure_prob = _sigmoid(logit)
|
| 46 |
+
duration = _DURATION_BASE.get(request.failure_type, 3.0) * (0.8 + 0.4 * (1 - failure_prob))
|
| 47 |
+
ftf = _FTF_BASE.get(request.failure_type, 0.78) * (0.9 + 0.1 * (1 - failure_prob))
|
| 48 |
+
revisit = max(0.02, (1 - ftf) * 0.85)
|
| 49 |
+
|
| 50 |
+
part_idx = hash(request.failure_type + request.required_part_type) % len(PART_TYPES)
|
| 51 |
+
predicted_part = PART_TYPES[part_idx]
|
| 52 |
+
|
| 53 |
+
if failure_prob >= 0.6:
|
| 54 |
+
risk_tier = "high"
|
| 55 |
+
elif failure_prob >= 0.35:
|
| 56 |
+
risk_tier = "medium"
|
| 57 |
+
else:
|
| 58 |
+
risk_tier = "low"
|
| 59 |
+
|
| 60 |
+
return MLPrediction(
|
| 61 |
+
request_id=request.request_id,
|
| 62 |
+
site_id=request.site_id,
|
| 63 |
+
failure_probability=round(failure_prob, 4),
|
| 64 |
+
predicted_part_type=predicted_part,
|
| 65 |
+
predicted_duration_hours=round(duration, 2),
|
| 66 |
+
first_time_fix_probability=round(min(ftf, 0.98), 4),
|
| 67 |
+
revisit_probability=round(revisit, 4),
|
| 68 |
+
confidence=round(0.75 + 0.2 * (1 - abs(failure_prob - 0.5)), 4),
|
| 69 |
+
risk_tier=risk_tier,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def predict_all(problem: ProblemInstance) -> list[MLPrediction]:
|
| 74 |
+
return [predict_request(problem, r) for r in problem.requests]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def predictions_to_table_rows(predictions: list[MLPrediction]) -> list[dict]:
|
| 78 |
+
return [
|
| 79 |
+
{
|
| 80 |
+
"Request": p.request_id,
|
| 81 |
+
"P(Failure)": f"{p.failure_probability:.1%}",
|
| 82 |
+
"Part Type": p.predicted_part_type,
|
| 83 |
+
"Duration (h)": p.predicted_duration_hours,
|
| 84 |
+
"FTF": f"{p.first_time_fix_probability:.1%}",
|
| 85 |
+
"Revisit": f"{p.revisit_probability:.1%}",
|
| 86 |
+
"Risk": p.risk_tier,
|
| 87 |
+
}
|
| 88 |
+
for p in predictions
|
| 89 |
+
]
|
gradio/src/fieldops/models.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data models for field service optimization."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import asdict, dataclass, field
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class Warehouse:
|
| 11 |
+
warehouse_id: str
|
| 12 |
+
name: str
|
| 13 |
+
region: str
|
| 14 |
+
lat: float
|
| 15 |
+
lon: float
|
| 16 |
+
capacity_units: int
|
| 17 |
+
expedite_cost_per_unit: float
|
| 18 |
+
|
| 19 |
+
def to_dict(self) -> dict[str, Any]:
|
| 20 |
+
return asdict(self)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class SparePart:
|
| 25 |
+
part_id: str
|
| 26 |
+
name: str
|
| 27 |
+
part_type: str
|
| 28 |
+
unit_cost: float
|
| 29 |
+
holding_cost_per_day: float
|
| 30 |
+
expedite_cost: float
|
| 31 |
+
substitute_part_id: str | None = None
|
| 32 |
+
|
| 33 |
+
def to_dict(self) -> dict[str, Any]:
|
| 34 |
+
return asdict(self)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class InventoryLevel:
|
| 39 |
+
warehouse_id: str
|
| 40 |
+
part_id: str
|
| 41 |
+
quantity: int
|
| 42 |
+
reorder_point: int
|
| 43 |
+
safety_stock: int
|
| 44 |
+
|
| 45 |
+
def to_dict(self) -> dict[str, Any]:
|
| 46 |
+
return asdict(self)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class Technician:
|
| 51 |
+
technician_id: str
|
| 52 |
+
name: str
|
| 53 |
+
skills: list[str]
|
| 54 |
+
skill_level: float
|
| 55 |
+
hourly_rate: float
|
| 56 |
+
home_lat: float
|
| 57 |
+
home_lon: float
|
| 58 |
+
max_jobs_per_day: int
|
| 59 |
+
region: str
|
| 60 |
+
|
| 61 |
+
def to_dict(self) -> dict[str, Any]:
|
| 62 |
+
return asdict(self)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass
|
| 66 |
+
class ServiceSite:
|
| 67 |
+
site_id: str
|
| 68 |
+
name: str
|
| 69 |
+
asset_type: str
|
| 70 |
+
criticality: str
|
| 71 |
+
lat: float
|
| 72 |
+
lon: float
|
| 73 |
+
sla_hours: float
|
| 74 |
+
downtime_cost_per_hour: float
|
| 75 |
+
contract_tier: str
|
| 76 |
+
|
| 77 |
+
def to_dict(self) -> dict[str, Any]:
|
| 78 |
+
return asdict(self)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@dataclass
|
| 82 |
+
class ServiceRequest:
|
| 83 |
+
request_id: str
|
| 84 |
+
site_id: str
|
| 85 |
+
site_name: str
|
| 86 |
+
priority: str
|
| 87 |
+
failure_type: str
|
| 88 |
+
required_part_type: str
|
| 89 |
+
required_skill: str
|
| 90 |
+
estimated_duration_hours: float
|
| 91 |
+
is_emergency: bool
|
| 92 |
+
sla_deadline_hours: float
|
| 93 |
+
lat: float
|
| 94 |
+
lon: float
|
| 95 |
+
|
| 96 |
+
def to_dict(self) -> dict[str, Any]:
|
| 97 |
+
return asdict(self)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@dataclass
|
| 101 |
+
class MLPrediction:
|
| 102 |
+
request_id: str
|
| 103 |
+
site_id: str
|
| 104 |
+
failure_probability: float
|
| 105 |
+
predicted_part_type: str
|
| 106 |
+
predicted_duration_hours: float
|
| 107 |
+
first_time_fix_probability: float
|
| 108 |
+
revisit_probability: float
|
| 109 |
+
confidence: float
|
| 110 |
+
risk_tier: str
|
| 111 |
+
|
| 112 |
+
def to_dict(self) -> dict[str, Any]:
|
| 113 |
+
return asdict(self)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@dataclass
|
| 117 |
+
class Stage1Plan:
|
| 118 |
+
technician_positions: dict[str, tuple[float, float]]
|
| 119 |
+
inventory_targets: list[InventoryLevel]
|
| 120 |
+
supply_contracts: list[dict[str, Any]]
|
| 121 |
+
repair_shop_capacity: int
|
| 122 |
+
total_holding_cost: float
|
| 123 |
+
notes: list[str] = field(default_factory=list)
|
| 124 |
+
|
| 125 |
+
def to_dict(self) -> dict[str, Any]:
|
| 126 |
+
return {
|
| 127 |
+
"technician_positions": self.technician_positions,
|
| 128 |
+
"inventory_targets": [i.to_dict() for i in self.inventory_targets],
|
| 129 |
+
"supply_contracts": self.supply_contracts,
|
| 130 |
+
"repair_shop_capacity": self.repair_shop_capacity,
|
| 131 |
+
"total_holding_cost": self.total_holding_cost,
|
| 132 |
+
"notes": self.notes,
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@dataclass
|
| 137 |
+
class MissionAssignment:
|
| 138 |
+
request_id: str
|
| 139 |
+
technician_id: str
|
| 140 |
+
technician_name: str
|
| 141 |
+
warehouse_id: str
|
| 142 |
+
part_id: str
|
| 143 |
+
part_source: str
|
| 144 |
+
route_order: int
|
| 145 |
+
scheduled_start_hours: float
|
| 146 |
+
estimated_arrival_hours: float
|
| 147 |
+
estimated_completion_hours: float
|
| 148 |
+
use_substitute: bool
|
| 149 |
+
defer_to_later: bool
|
| 150 |
+
rationale: str
|
| 151 |
+
|
| 152 |
+
def to_dict(self) -> dict[str, Any]:
|
| 153 |
+
return asdict(self)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@dataclass
|
| 157 |
+
class KPIMetrics:
|
| 158 |
+
sla_compliance_pct: float
|
| 159 |
+
mean_time_to_repair_hours: float
|
| 160 |
+
first_time_fix_rate_pct: float
|
| 161 |
+
emergency_stockout_count: int
|
| 162 |
+
technician_utilization_pct: float
|
| 163 |
+
total_logistics_cost: float
|
| 164 |
+
total_downtime_cost: float
|
| 165 |
+
total_holding_cost: float
|
| 166 |
+
expedite_shipments: int
|
| 167 |
+
|
| 168 |
+
def to_dict(self) -> dict[str, Any]:
|
| 169 |
+
return asdict(self)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@dataclass
|
| 173 |
+
class SimulationResult:
|
| 174 |
+
replications: int
|
| 175 |
+
sla_compliance_mean: float
|
| 176 |
+
mttr_mean: float
|
| 177 |
+
stockout_rate: float
|
| 178 |
+
avg_logistics_cost: float
|
| 179 |
+
avg_downtime_cost: float
|
| 180 |
+
confidence_interval_95: dict[str, tuple[float, float]]
|
| 181 |
+
|
| 182 |
+
def to_dict(self) -> dict[str, Any]:
|
| 183 |
+
return asdict(self)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@dataclass
|
| 187 |
+
class OptimizationResult:
|
| 188 |
+
strategy: str
|
| 189 |
+
algorithm: str
|
| 190 |
+
stage1_plan: Stage1Plan
|
| 191 |
+
assignments: list[MissionAssignment]
|
| 192 |
+
ml_predictions: list[MLPrediction]
|
| 193 |
+
metrics: KPIMetrics
|
| 194 |
+
simulation: SimulationResult | None
|
| 195 |
+
solve_time_sec: float
|
| 196 |
+
status: str
|
| 197 |
+
notes: list[str] = field(default_factory=list)
|
| 198 |
+
|
| 199 |
+
def to_dict(self) -> dict[str, Any]:
|
| 200 |
+
return {
|
| 201 |
+
"strategy": self.strategy,
|
| 202 |
+
"algorithm": self.algorithm,
|
| 203 |
+
"stage1_plan": self.stage1_plan.to_dict(),
|
| 204 |
+
"assignments": [a.to_dict() for a in self.assignments],
|
| 205 |
+
"ml_predictions": [p.to_dict() for p in self.ml_predictions],
|
| 206 |
+
"metrics": self.metrics.to_dict(),
|
| 207 |
+
"simulation": self.simulation.to_dict() if self.simulation else None,
|
| 208 |
+
"solve_time_sec": self.solve_time_sec,
|
| 209 |
+
"status": self.status,
|
| 210 |
+
"notes": self.notes,
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@dataclass
|
| 215 |
+
class ProblemInstance:
|
| 216 |
+
industry_id: str
|
| 217 |
+
stress_scenario: str
|
| 218 |
+
scenario_label: str
|
| 219 |
+
planning_horizon_days: int
|
| 220 |
+
warehouses: list[Warehouse]
|
| 221 |
+
spare_parts: list[SparePart]
|
| 222 |
+
inventory: list[InventoryLevel]
|
| 223 |
+
technicians: list[Technician]
|
| 224 |
+
sites: list[ServiceSite]
|
| 225 |
+
requests: list[ServiceRequest]
|
| 226 |
+
stock_vs_expedite_ratio: float = 0.5
|
| 227 |
+
reliability_target: float = 0.95
|
| 228 |
+
|
| 229 |
+
def to_dict(self) -> dict[str, Any]:
|
| 230 |
+
return {
|
| 231 |
+
"industry_id": self.industry_id,
|
| 232 |
+
"stress_scenario": self.stress_scenario,
|
| 233 |
+
"scenario_label": self.scenario_label,
|
| 234 |
+
"planning_horizon_days": self.planning_horizon_days,
|
| 235 |
+
"warehouses": [w.to_dict() for w in self.warehouses],
|
| 236 |
+
"spare_parts": [p.to_dict() for p in self.spare_parts],
|
| 237 |
+
"inventory": [i.to_dict() for i in self.inventory],
|
| 238 |
+
"technicians": [t.to_dict() for t in self.technicians],
|
| 239 |
+
"sites": [s.to_dict() for s in self.sites],
|
| 240 |
+
"requests": [r.to_dict() for r in self.requests],
|
| 241 |
+
"stock_vs_expedite_ratio": self.stock_vs_expedite_ratio,
|
| 242 |
+
"reliability_target": self.reliability_target,
|
| 243 |
+
}
|
gradio/src/fieldops/pipeline.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Demo pipeline for Hugging Face Space."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from fieldops.constants import ENGINE_VERSION, INDUSTRY_SCENARIOS, STRESS_SCENARIOS, STRATEGIES
|
| 10 |
+
from fieldops.engine import FieldOpsEngine
|
| 11 |
+
from fieldops.generator import generate_problem
|
| 12 |
+
from fieldops.models import OptimizationResult, ProblemInstance
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class FieldOpsPipeline:
|
| 16 |
+
"""Load pre-built benchmarks and serve interactive field service optimization."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, assets_dir: Path | None = None):
|
| 19 |
+
self.assets_dir = Path(assets_dir) if assets_dir else None
|
| 20 |
+
self.engine = FieldOpsEngine()
|
| 21 |
+
self.summary: dict[str, Any] = {}
|
| 22 |
+
self.benchmarks: list[dict[str, Any]] = []
|
| 23 |
+
|
| 24 |
+
def load(self) -> None:
|
| 25 |
+
if self.assets_dir:
|
| 26 |
+
summary_path = self.assets_dir / "demo" / "summary.json"
|
| 27 |
+
bench_path = self.assets_dir / "demo" / "benchmarks.json"
|
| 28 |
+
if summary_path.exists():
|
| 29 |
+
self.summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
| 30 |
+
if bench_path.exists():
|
| 31 |
+
self.benchmarks = json.loads(bench_path.read_text(encoding="utf-8"))
|
| 32 |
+
|
| 33 |
+
def get_industry_ids(self) -> list[str]:
|
| 34 |
+
return list(INDUSTRY_SCENARIOS.keys())
|
| 35 |
+
|
| 36 |
+
def get_stress_ids(self) -> list[str]:
|
| 37 |
+
return list(STRESS_SCENARIOS.keys())
|
| 38 |
+
|
| 39 |
+
def get_strategy_ids(self) -> list[str]:
|
| 40 |
+
return STRATEGIES
|
| 41 |
+
|
| 42 |
+
def get_industry_label(self, industry_id: str) -> str:
|
| 43 |
+
return INDUSTRY_SCENARIOS.get(industry_id, {}).get("label", industry_id)
|
| 44 |
+
|
| 45 |
+
def get_stress_label(self, stress_id: str) -> str:
|
| 46 |
+
return STRESS_SCENARIOS.get(stress_id, {}).get("label", stress_id)
|
| 47 |
+
|
| 48 |
+
def get_strategy_label(self, strategy_id: str) -> str:
|
| 49 |
+
labels = {
|
| 50 |
+
"two_stage_stochastic": "Two-Stage Stochastic Programming",
|
| 51 |
+
"multi_echelon_inventory": "Multi-Echelon Inventory Optimization",
|
| 52 |
+
"technician_routing": "Technician Routing (OR-Tools)",
|
| 53 |
+
"sample_average_approximation": "Sample Average Approximation (SAA)",
|
| 54 |
+
"alns_field_ops": "ALNS Field Operations",
|
| 55 |
+
"stock_vs_expedite": "Stock vs Expedite Trade-off",
|
| 56 |
+
"monte_carlo": "Monte Carlo Simulation",
|
| 57 |
+
}
|
| 58 |
+
return labels.get(strategy_id, strategy_id)
|
| 59 |
+
|
| 60 |
+
def build_problem(
|
| 61 |
+
self,
|
| 62 |
+
industry_id: str,
|
| 63 |
+
stress_id: str = "baseline",
|
| 64 |
+
num_sites: int | None = None,
|
| 65 |
+
stock_vs_expedite: float = 0.5,
|
| 66 |
+
reliability_target: float = 0.95,
|
| 67 |
+
seed: int = 42,
|
| 68 |
+
) -> ProblemInstance:
|
| 69 |
+
meta = INDUSTRY_SCENARIOS[industry_id]
|
| 70 |
+
return generate_problem(
|
| 71 |
+
industry_id=industry_id,
|
| 72 |
+
stress_id=stress_id,
|
| 73 |
+
num_sites=num_sites or meta["default_sites"],
|
| 74 |
+
stock_vs_expedite=stock_vs_expedite,
|
| 75 |
+
reliability_target=reliability_target,
|
| 76 |
+
seed=seed,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
def run_optimization(
|
| 80 |
+
self,
|
| 81 |
+
problem: ProblemInstance,
|
| 82 |
+
strategy: str,
|
| 83 |
+
) -> OptimizationResult:
|
| 84 |
+
return self.engine.optimize(problem, strategy, run_simulation=True)
|
| 85 |
+
|
| 86 |
+
def run_comparison(self, problem: ProblemInstance) -> list[OptimizationResult]:
|
| 87 |
+
return self.engine.compare_strategies(problem)
|
| 88 |
+
|
| 89 |
+
def run_stock_expedite_analysis(self, problem: ProblemInstance) -> list[OptimizationResult]:
|
| 90 |
+
return self.engine.compare_stock_expedite(problem)
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def version(self) -> str:
|
| 94 |
+
return self.summary.get("version", ENGINE_VERSION)
|
gradio/src/fieldops/routing.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Technician routing with OR-Tools."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from fieldops.models import MissionAssignment, MLPrediction, ProblemInstance, ServiceRequest, Technician
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
|
| 12 |
+
ORTOOLS_AVAILABLE = True
|
| 13 |
+
except ImportError:
|
| 14 |
+
ORTOOLS_AVAILABLE = False
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
| 18 |
+
r = 6371.0
|
| 19 |
+
p1, p2 = math.radians(lat1), math.radians(lat2)
|
| 20 |
+
dp = math.radians(lat2 - lat1)
|
| 21 |
+
dl = math.radians(lon2 - lon1)
|
| 22 |
+
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
| 23 |
+
return 2 * r * math.asin(math.sqrt(a))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _travel_hours(dist_km: float, speed_kmh: float = 60.0) -> float:
|
| 27 |
+
return dist_km / speed_kmh
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _greedy_route(
|
| 31 |
+
tech: Technician,
|
| 32 |
+
requests: list[ServiceRequest],
|
| 33 |
+
predictions: dict[str, MLPrediction],
|
| 34 |
+
) -> list[tuple[ServiceRequest, float, float]]:
|
| 35 |
+
"""Greedy nearest-neighbor fallback routing."""
|
| 36 |
+
remaining = sorted(
|
| 37 |
+
requests,
|
| 38 |
+
key=lambda r: (0 if r.is_emergency else 1, -predictions[r.request_id].failure_probability),
|
| 39 |
+
)
|
| 40 |
+
route: list[tuple[ServiceRequest, float, float]] = []
|
| 41 |
+
cur_lat, cur_lon = tech.home_lat, tech.home_lon
|
| 42 |
+
elapsed = 0.0
|
| 43 |
+
|
| 44 |
+
for req in remaining[: tech.max_jobs_per_day]:
|
| 45 |
+
dist = _haversine_km(cur_lat, cur_lon, req.lat, req.lon)
|
| 46 |
+
travel = _travel_hours(dist)
|
| 47 |
+
arrival = elapsed + travel
|
| 48 |
+
duration = predictions[req.request_id].predicted_duration_hours
|
| 49 |
+
route.append((req, arrival, arrival + duration))
|
| 50 |
+
elapsed = arrival + duration
|
| 51 |
+
cur_lat, cur_lon = req.lat, req.lon
|
| 52 |
+
|
| 53 |
+
return route
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def solve_routing(
|
| 57 |
+
problem: ProblemInstance,
|
| 58 |
+
predictions: list[MLPrediction],
|
| 59 |
+
assignments_meta: list[dict[str, Any]],
|
| 60 |
+
) -> list[MissionAssignment]:
|
| 61 |
+
"""Assign requests to technicians with routing."""
|
| 62 |
+
pred_map = {p.request_id: p for p in predictions}
|
| 63 |
+
meta_map = {m["request_id"]: m for m in assignments_meta}
|
| 64 |
+
results: list[MissionAssignment] = []
|
| 65 |
+
|
| 66 |
+
tech_requests: dict[str, list[ServiceRequest]] = {t.technician_id: [] for t in problem.technicians}
|
| 67 |
+
for req in problem.requests:
|
| 68 |
+
meta = meta_map.get(req.request_id)
|
| 69 |
+
if meta:
|
| 70 |
+
tech_requests[meta["technician_id"]].append(req)
|
| 71 |
+
|
| 72 |
+
for tech in problem.technicians:
|
| 73 |
+
reqs = tech_requests[tech.technician_id]
|
| 74 |
+
if not reqs:
|
| 75 |
+
continue
|
| 76 |
+
|
| 77 |
+
if ORTOOLS_AVAILABLE and len(reqs) <= 12:
|
| 78 |
+
route = _ortools_route(tech, reqs, pred_map)
|
| 79 |
+
else:
|
| 80 |
+
route = _greedy_route(tech, reqs, pred_map)
|
| 81 |
+
|
| 82 |
+
for order, (req, arrival, completion) in enumerate(route, 1):
|
| 83 |
+
meta = meta_map[req.request_id]
|
| 84 |
+
results.append(MissionAssignment(
|
| 85 |
+
request_id=req.request_id,
|
| 86 |
+
technician_id=tech.technician_id,
|
| 87 |
+
technician_name=tech.name,
|
| 88 |
+
warehouse_id=meta["warehouse_id"],
|
| 89 |
+
part_id=meta["part_id"],
|
| 90 |
+
part_source=meta["part_source"],
|
| 91 |
+
route_order=order,
|
| 92 |
+
scheduled_start_hours=arrival - _travel_hours(
|
| 93 |
+
_haversine_km(tech.home_lat, tech.home_lon, req.lat, req.lon)
|
| 94 |
+
),
|
| 95 |
+
estimated_arrival_hours=arrival,
|
| 96 |
+
estimated_completion_hours=completion,
|
| 97 |
+
use_substitute=meta.get("use_substitute", False),
|
| 98 |
+
defer_to_later=meta.get("defer", False),
|
| 99 |
+
rationale=meta.get("rationale", "Optimized assignment."),
|
| 100 |
+
))
|
| 101 |
+
|
| 102 |
+
return results
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _ortools_route(
|
| 106 |
+
tech: Technician,
|
| 107 |
+
requests: list[ServiceRequest],
|
| 108 |
+
pred_map: dict[str, MLPrediction],
|
| 109 |
+
) -> list[tuple[ServiceRequest, float, float]]:
|
| 110 |
+
"""OR-Tools VRP for single technician."""
|
| 111 |
+
n = len(requests)
|
| 112 |
+
if n == 0:
|
| 113 |
+
return []
|
| 114 |
+
|
| 115 |
+
locations = [(tech.home_lat, tech.home_lon)] + [(r.lat, r.lon) for r in requests]
|
| 116 |
+
size = n + 1
|
| 117 |
+
|
| 118 |
+
def dist_matrix(i: int, j: int) -> int:
|
| 119 |
+
lat1, lon1 = locations[i]
|
| 120 |
+
lat2, lon2 = locations[j]
|
| 121 |
+
return int(_haversine_km(lat1, lon1, lat2, lon2) * 100)
|
| 122 |
+
|
| 123 |
+
manager = pywrapcp.RoutingIndexManager(size, 1, 0)
|
| 124 |
+
routing = pywrapcp.RoutingModel(manager)
|
| 125 |
+
|
| 126 |
+
def distance_callback(from_idx: int, to_idx: int) -> int:
|
| 127 |
+
return dist_matrix(manager.IndexToNode(from_idx), manager.IndexToNode(to_idx))
|
| 128 |
+
|
| 129 |
+
transit_idx = routing.RegisterTransitCallback(distance_callback)
|
| 130 |
+
routing.SetArcCostEvaluatorOfAllVehicles(transit_idx)
|
| 131 |
+
|
| 132 |
+
for i, req in enumerate(requests, 1):
|
| 133 |
+
if req.is_emergency:
|
| 134 |
+
routing.AddDisjunction([manager.NodeToIndex(i)], 0)
|
| 135 |
+
|
| 136 |
+
search_params = pywrapcp.DefaultRoutingSearchParameters()
|
| 137 |
+
search_params.first_solution_strategy = (
|
| 138 |
+
routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
|
| 139 |
+
)
|
| 140 |
+
search_params.time_limit.seconds = 5
|
| 141 |
+
|
| 142 |
+
solution = routing.SolveWithParameters(search_params)
|
| 143 |
+
if not solution:
|
| 144 |
+
return _greedy_route(tech, requests, pred_map)
|
| 145 |
+
|
| 146 |
+
route_order: list[int] = []
|
| 147 |
+
index = routing.Start(0)
|
| 148 |
+
while not routing.IsEnd(index):
|
| 149 |
+
node = manager.IndexToNode(index)
|
| 150 |
+
if node > 0:
|
| 151 |
+
route_order.append(node - 1)
|
| 152 |
+
index = solution.Value(routing.NextVar(index))
|
| 153 |
+
|
| 154 |
+
ordered = [requests[i] for i in route_order]
|
| 155 |
+
cur_lat, cur_lon = tech.home_lat, tech.home_lon
|
| 156 |
+
elapsed = 0.0
|
| 157 |
+
result: list[tuple[ServiceRequest, float, float]] = []
|
| 158 |
+
for req in ordered:
|
| 159 |
+
dist = _haversine_km(cur_lat, cur_lon, req.lat, req.lon)
|
| 160 |
+
travel = _travel_hours(dist)
|
| 161 |
+
arrival = elapsed + travel
|
| 162 |
+
duration = pred_map[req.request_id].predicted_duration_hours
|
| 163 |
+
result.append((req, arrival, arrival + duration))
|
| 164 |
+
elapsed = arrival + duration
|
| 165 |
+
cur_lat, cur_lon = req.lat, req.lon
|
| 166 |
+
|
| 167 |
+
return result
|
gradio/src/fieldops/scheduling.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Service scheduling and mission assignment."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fieldops.inventory_optimization import find_nearest_warehouse
|
| 6 |
+
from fieldops.models import MLPrediction, ProblemInstance, ServiceRequest, Technician
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _skill_match(tech: Technician, skill: str) -> float:
|
| 10 |
+
if skill in tech.skills:
|
| 11 |
+
return tech.skill_level
|
| 12 |
+
return tech.skill_level * 0.4
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def assign_missions(
|
| 16 |
+
problem: ProblemInstance,
|
| 17 |
+
predictions: list[MLPrediction],
|
| 18 |
+
) -> list[dict]:
|
| 19 |
+
"""Greedy skill-based assignment with parts sourcing."""
|
| 20 |
+
pred_map = {p.request_id: p for p in predictions}
|
| 21 |
+
part_map = {p.part_type: p for p in problem.spare_parts}
|
| 22 |
+
part_by_id = {p.part_id: p for p in problem.spare_parts}
|
| 23 |
+
|
| 24 |
+
sorted_requests = sorted(
|
| 25 |
+
problem.requests,
|
| 26 |
+
key=lambda r: (
|
| 27 |
+
0 if r.is_emergency else 1,
|
| 28 |
+
r.priority,
|
| 29 |
+
-pred_map[r.request_id].failure_probability,
|
| 30 |
+
),
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
tech_load: dict[str, int] = {t.technician_id: 0 for t in problem.technicians}
|
| 34 |
+
assignments: list[dict] = []
|
| 35 |
+
|
| 36 |
+
for req in sorted_requests:
|
| 37 |
+
pred = pred_map[req.request_id]
|
| 38 |
+
best_tech, best_score = None, -1.0
|
| 39 |
+
|
| 40 |
+
for tech in problem.technicians:
|
| 41 |
+
if tech_load[tech.technician_id] >= tech.max_jobs_per_day:
|
| 42 |
+
continue
|
| 43 |
+
score = _skill_match(tech, req.required_skill)
|
| 44 |
+
if req.is_emergency and tech.region in ("north", "central"):
|
| 45 |
+
score *= 1.1
|
| 46 |
+
if score > best_score:
|
| 47 |
+
best_score = score
|
| 48 |
+
best_tech = tech
|
| 49 |
+
|
| 50 |
+
if best_tech is None:
|
| 51 |
+
best_tech = min(problem.technicians, key=lambda t: tech_load[t.technician_id])
|
| 52 |
+
|
| 53 |
+
part = part_map.get(req.required_part_type) or problem.spare_parts[0]
|
| 54 |
+
wh, qty, _ = find_nearest_warehouse(
|
| 55 |
+
problem.warehouses, problem.inventory, part.part_id, req.lat, req.lon,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
use_substitute = qty == 0
|
| 59 |
+
part_source = "warehouse_stock"
|
| 60 |
+
defer = False
|
| 61 |
+
|
| 62 |
+
if qty == 0:
|
| 63 |
+
if problem.stock_vs_expedite_ratio < 0.5:
|
| 64 |
+
part_source = "expedite_shipment"
|
| 65 |
+
else:
|
| 66 |
+
sub_part = part_by_id.get(part.substitute_part_id or "")
|
| 67 |
+
if sub_part:
|
| 68 |
+
wh2, qty2, _ = find_nearest_warehouse(
|
| 69 |
+
problem.warehouses, problem.inventory,
|
| 70 |
+
sub_part.part_id, req.lat, req.lon,
|
| 71 |
+
)
|
| 72 |
+
if qty2 > 0:
|
| 73 |
+
part = sub_part
|
| 74 |
+
wh = wh2
|
| 75 |
+
use_substitute = True
|
| 76 |
+
part_source = "substitute_part"
|
| 77 |
+
else:
|
| 78 |
+
part_source = "expedite_shipment"
|
| 79 |
+
else:
|
| 80 |
+
part_source = "expedite_shipment"
|
| 81 |
+
|
| 82 |
+
if not req.is_emergency and pred.failure_probability < 0.25 and tech_load[best_tech.technician_id] >= 2:
|
| 83 |
+
defer = True
|
| 84 |
+
part_source = "deferred"
|
| 85 |
+
|
| 86 |
+
tech_load[best_tech.technician_id] += 1
|
| 87 |
+
rationale = (
|
| 88 |
+
f"{'Emergency' if req.is_emergency else 'Scheduled'} — "
|
| 89 |
+
f"skill match {best_score:.0%}, part via {part_source}"
|
| 90 |
+
)
|
| 91 |
+
if defer:
|
| 92 |
+
rationale += " (deferred to next window)"
|
| 93 |
+
|
| 94 |
+
assignments.append({
|
| 95 |
+
"request_id": req.request_id,
|
| 96 |
+
"technician_id": best_tech.technician_id,
|
| 97 |
+
"warehouse_id": wh.warehouse_id,
|
| 98 |
+
"part_id": part.part_id,
|
| 99 |
+
"part_source": part_source,
|
| 100 |
+
"use_substitute": use_substitute,
|
| 101 |
+
"defer": defer,
|
| 102 |
+
"rationale": rationale,
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
return assignments
|
gradio/src/fieldops/simulation.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Monte Carlo simulation with SimPy fallback."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import random
|
| 6 |
+
import statistics
|
| 7 |
+
|
| 8 |
+
from fieldops.metrics import compute_metrics
|
| 9 |
+
from fieldops.models import MissionAssignment, ProblemInstance, SimulationResult, Stage1Plan, MLPrediction
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def run_monte_carlo(
|
| 13 |
+
problem: ProblemInstance,
|
| 14 |
+
assignments: list[MissionAssignment],
|
| 15 |
+
stage1: Stage1Plan,
|
| 16 |
+
predictions: list[MLPrediction],
|
| 17 |
+
replications: int = 30,
|
| 18 |
+
seed: int = 42,
|
| 19 |
+
) -> SimulationResult:
|
| 20 |
+
"""Monte Carlo validation of field service plan."""
|
| 21 |
+
rng = random.Random(seed)
|
| 22 |
+
assign_dicts = [
|
| 23 |
+
{
|
| 24 |
+
"request_id": a.request_id,
|
| 25 |
+
"technician_id": a.technician_id,
|
| 26 |
+
"warehouse_id": a.warehouse_id,
|
| 27 |
+
"part_id": a.part_id,
|
| 28 |
+
"part_source": a.part_source,
|
| 29 |
+
"use_substitute": a.use_substitute,
|
| 30 |
+
"defer": a.defer_to_later,
|
| 31 |
+
}
|
| 32 |
+
for a in assignments
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
sla_vals, mttr_vals, stockout_vals, log_vals, down_vals = [], [], [], [], []
|
| 36 |
+
|
| 37 |
+
for rep in range(replications):
|
| 38 |
+
perturbed = []
|
| 39 |
+
for i, pred in enumerate(predictions):
|
| 40 |
+
noise = rng.gauss(0, 0.15)
|
| 41 |
+
adj = MLPrediction(
|
| 42 |
+
request_id=pred.request_id,
|
| 43 |
+
site_id=pred.site_id,
|
| 44 |
+
failure_probability=min(0.99, max(0.01, pred.failure_probability + noise)),
|
| 45 |
+
predicted_part_type=pred.predicted_part_type,
|
| 46 |
+
predicted_duration_hours=max(0.5, pred.predicted_duration_hours * (1 + rng.gauss(0, 0.1))),
|
| 47 |
+
first_time_fix_probability=min(0.99, max(0.3, pred.first_time_fix_probability + rng.gauss(0, 0.05))),
|
| 48 |
+
revisit_probability=pred.revisit_probability,
|
| 49 |
+
confidence=pred.confidence,
|
| 50 |
+
risk_tier=pred.risk_tier,
|
| 51 |
+
)
|
| 52 |
+
perturbed.append(adj)
|
| 53 |
+
|
| 54 |
+
m = compute_metrics(problem, assign_dicts, stage1, perturbed)
|
| 55 |
+
sla_vals.append(m.sla_compliance_pct)
|
| 56 |
+
mttr_vals.append(m.mean_time_to_repair_hours * (1 + rng.uniform(-0.1, 0.15)))
|
| 57 |
+
stockout_vals.append(m.emergency_stockout_count / max(len(assignments), 1))
|
| 58 |
+
log_vals.append(m.total_logistics_cost * (1 + rng.uniform(-0.05, 0.1)))
|
| 59 |
+
down_vals.append(m.total_downtime_cost * (1 + rng.uniform(-0.08, 0.12)))
|
| 60 |
+
|
| 61 |
+
def ci(vals: list[float]) -> tuple[float, float]:
|
| 62 |
+
if len(vals) < 2:
|
| 63 |
+
v = vals[0] if vals else 0.0
|
| 64 |
+
return (v, v)
|
| 65 |
+
mean = statistics.mean(vals)
|
| 66 |
+
stdev = statistics.stdev(vals)
|
| 67 |
+
margin = 1.96 * stdev / (len(vals) ** 0.5)
|
| 68 |
+
return (round(mean - margin, 2), round(mean + margin, 2))
|
| 69 |
+
|
| 70 |
+
return SimulationResult(
|
| 71 |
+
replications=replications,
|
| 72 |
+
sla_compliance_mean=round(statistics.mean(sla_vals), 1),
|
| 73 |
+
mttr_mean=round(statistics.mean(mttr_vals), 2),
|
| 74 |
+
stockout_rate=round(statistics.mean(stockout_vals), 4),
|
| 75 |
+
avg_logistics_cost=round(statistics.mean(log_vals), 2),
|
| 76 |
+
avg_downtime_cost=round(statistics.mean(down_vals), 2),
|
| 77 |
+
confidence_interval_95={
|
| 78 |
+
"sla_compliance": ci(sla_vals),
|
| 79 |
+
"mttr": ci(mttr_vals),
|
| 80 |
+
"logistics_cost": ci(log_vals),
|
| 81 |
+
},
|
| 82 |
+
)
|
gradio/src/fieldops/stochastic.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Two-stage stochastic programming and Sample Average Approximation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import random
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from fieldops.inventory_optimization import optimize_inventory
|
| 9 |
+
from fieldops.ml_predictions import predict_all
|
| 10 |
+
from fieldops.models import OptimizationResult, ProblemInstance, Stage1Plan
|
| 11 |
+
from fieldops.routing import solve_routing
|
| 12 |
+
from fieldops.scheduling import assign_missions
|
| 13 |
+
from fieldops.metrics import compute_metrics
|
| 14 |
+
from fieldops.simulation import run_monte_carlo
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _generate_scenarios(problem: ProblemInstance, n: int, seed: int = 42) -> list[list[float]]:
|
| 18 |
+
"""SAA scenario generation for failure realizations."""
|
| 19 |
+
rng = random.Random(seed)
|
| 20 |
+
scenarios = []
|
| 21 |
+
for _ in range(n):
|
| 22 |
+
scenario = [rng.betavariate(2, 5) for _ in problem.requests]
|
| 23 |
+
scenarios.append(scenario)
|
| 24 |
+
return scenarios
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def solve_two_stage_stochastic(
|
| 28 |
+
problem: ProblemInstance,
|
| 29 |
+
n_scenarios: int = 20,
|
| 30 |
+
) -> tuple[Stage1Plan, list[dict], list[str]]:
|
| 31 |
+
"""Stage 1: inventory + positioning. Stage 2: assignment under scenarios."""
|
| 32 |
+
stage1 = optimize_inventory(problem, problem.stock_vs_expedite_ratio)
|
| 33 |
+
predictions = predict_all(problem)
|
| 34 |
+
scenarios = _generate_scenarios(problem, n_scenarios)
|
| 35 |
+
|
| 36 |
+
best_assignments = assign_missions(problem, predictions)
|
| 37 |
+
best_cost = float("inf")
|
| 38 |
+
|
| 39 |
+
for scenario in scenarios:
|
| 40 |
+
adj_predictions = []
|
| 41 |
+
for i, pred in enumerate(predictions):
|
| 42 |
+
from fieldops.models import MLPrediction
|
| 43 |
+
adj = MLPrediction(
|
| 44 |
+
request_id=pred.request_id,
|
| 45 |
+
site_id=pred.site_id,
|
| 46 |
+
failure_probability=min(0.99, pred.failure_probability * (0.5 + scenario[i])),
|
| 47 |
+
predicted_part_type=pred.predicted_part_type,
|
| 48 |
+
predicted_duration_hours=pred.predicted_duration_hours * (0.8 + 0.4 * scenario[i]),
|
| 49 |
+
first_time_fix_probability=pred.first_time_fix_probability,
|
| 50 |
+
revisit_probability=pred.revisit_probability,
|
| 51 |
+
confidence=pred.confidence,
|
| 52 |
+
risk_tier=pred.risk_tier,
|
| 53 |
+
)
|
| 54 |
+
adj_predictions.append(adj)
|
| 55 |
+
|
| 56 |
+
assignments = assign_missions(problem, adj_predictions)
|
| 57 |
+
metrics = compute_metrics(problem, assignments, stage1, adj_predictions)
|
| 58 |
+
total = metrics.total_logistics_cost + metrics.total_downtime_cost + stage1.total_holding_cost
|
| 59 |
+
if total < best_cost:
|
| 60 |
+
best_cost = total
|
| 61 |
+
best_assignments = assignments
|
| 62 |
+
|
| 63 |
+
notes = [
|
| 64 |
+
f"Two-stage stochastic: {n_scenarios} SAA scenarios evaluated.",
|
| 65 |
+
"Stage 1: multi-echelon inventory + technician positioning.",
|
| 66 |
+
"Stage 2: mission assignment minimizing expected logistics + downtime cost.",
|
| 67 |
+
]
|
| 68 |
+
return stage1, best_assignments, notes
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def solve_saa(problem: ProblemInstance, n_samples: int = 50) -> tuple[Stage1Plan, list[dict], list[str]]:
|
| 72 |
+
"""Sample Average Approximation for robust planning."""
|
| 73 |
+
stage1, assignments, notes = solve_two_stage_stochastic(problem, n_samples)
|
| 74 |
+
notes.append(f"SAA with {n_samples} independent failure samples.")
|
| 75 |
+
return stage1, assignments, notes
|
gradio/src/fieldops/visualization.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Plotly visualizations for FieldOps."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import plotly.graph_objects as go
|
| 6 |
+
from plotly.subplots import make_subplots
|
| 7 |
+
|
| 8 |
+
from fieldops.models import OptimizationResult, ProblemInstance
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def result_summary_markdown(result: OptimizationResult) -> str:
|
| 12 |
+
m = result.metrics
|
| 13 |
+
sim = result.simulation
|
| 14 |
+
sim_line = ""
|
| 15 |
+
if sim:
|
| 16 |
+
sim_line = (
|
| 17 |
+
f"\n- **Monte Carlo SLA:** {sim.sla_compliance_mean:.1f}% "
|
| 18 |
+
f"(95% CI: {sim.confidence_interval_95['sla_compliance'][0]:.1f}–"
|
| 19 |
+
f"{sim.confidence_interval_95['sla_compliance'][1]:.1f}%)"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
f"### Optimization Result — {result.algorithm}\n"
|
| 24 |
+
f"- **Status:** {result.status} | **Solve time:** {result.solve_time_sec:.2f}s\n"
|
| 25 |
+
f"- **SLA Compliance:** {m.sla_compliance_pct:.1f}% | "
|
| 26 |
+
f"**MTTR:** {m.mean_time_to_repair_hours:.1f}h | "
|
| 27 |
+
f"**FTF Rate:** {m.first_time_fix_rate_pct:.1f}%\n"
|
| 28 |
+
f"- **Technician Utilization:** {m.technician_utilization_pct:.1f}% | "
|
| 29 |
+
f"**Emergency Stockouts:** {m.emergency_stockout_count}\n"
|
| 30 |
+
f"- **Logistics Cost:** ${m.total_logistics_cost:,.0f} | "
|
| 31 |
+
f"**Downtime Cost:** ${m.total_downtime_cost:,.0f} | "
|
| 32 |
+
f"**Holding Cost:** ${m.total_holding_cost:,.0f}\n"
|
| 33 |
+
f"- **Expedite Shipments:** {m.expedite_shipments}"
|
| 34 |
+
f"{sim_line}"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def kpi_radar_chart(result: OptimizationResult):
|
| 39 |
+
m = result.metrics
|
| 40 |
+
categories = [
|
| 41 |
+
"SLA Compliance", "FTF Rate", "Tech Utilization",
|
| 42 |
+
"Cost Efficiency", "Stock Coverage",
|
| 43 |
+
]
|
| 44 |
+
cost_eff = max(0, 100 - min(m.total_logistics_cost / 500, 100))
|
| 45 |
+
stock_cov = max(0, 100 - m.emergency_stockout_count * 20)
|
| 46 |
+
values = [
|
| 47 |
+
m.sla_compliance_pct,
|
| 48 |
+
m.first_time_fix_rate_pct,
|
| 49 |
+
m.technician_utilization_pct,
|
| 50 |
+
cost_eff,
|
| 51 |
+
stock_cov,
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
fig = go.Figure(data=go.Scatterpolar(
|
| 55 |
+
r=values + [values[0]],
|
| 56 |
+
theta=categories + [categories[0]],
|
| 57 |
+
fill="toself",
|
| 58 |
+
name=result.strategy,
|
| 59 |
+
line_color="#2563eb",
|
| 60 |
+
))
|
| 61 |
+
fig.update_layout(
|
| 62 |
+
polar=dict(radialaxis=dict(visible=True, range=[0, 100])),
|
| 63 |
+
title="Field Service KPI Radar",
|
| 64 |
+
height=400,
|
| 65 |
+
margin=dict(t=60, b=40),
|
| 66 |
+
)
|
| 67 |
+
return fig
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def cost_breakdown_chart(result: OptimizationResult):
|
| 71 |
+
m = result.metrics
|
| 72 |
+
labels = ["Logistics", "Downtime", "Holding", "Expedite"]
|
| 73 |
+
values = [
|
| 74 |
+
m.total_logistics_cost,
|
| 75 |
+
m.total_downtime_cost,
|
| 76 |
+
m.total_holding_cost,
|
| 77 |
+
m.expedite_shipments * 250,
|
| 78 |
+
]
|
| 79 |
+
fig = go.Figure(data=[go.Pie(
|
| 80 |
+
labels=labels, values=values, hole=0.4,
|
| 81 |
+
marker_colors=["#3b82f6", "#ef4444", "#f59e0b", "#8b5cf6"],
|
| 82 |
+
)])
|
| 83 |
+
fig.update_layout(title="Cost Breakdown", height=380, margin=dict(t=50, b=20))
|
| 84 |
+
return fig
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def mission_map_chart(problem: ProblemInstance, result: OptimizationResult):
|
| 88 |
+
fig = go.Figure()
|
| 89 |
+
|
| 90 |
+
for wh in problem.warehouses:
|
| 91 |
+
fig.add_trace(go.Scattergeo(
|
| 92 |
+
lon=[wh.lon], lat=[wh.lat],
|
| 93 |
+
mode="markers+text", text=[wh.name],
|
| 94 |
+
marker=dict(size=12, color="#f59e0b", symbol="square"),
|
| 95 |
+
name="Warehouse",
|
| 96 |
+
))
|
| 97 |
+
|
| 98 |
+
for site in problem.sites:
|
| 99 |
+
fig.add_trace(go.Scattergeo(
|
| 100 |
+
lon=[site.lon], lat=[site.lat],
|
| 101 |
+
mode="markers",
|
| 102 |
+
marker=dict(
|
| 103 |
+
size=8,
|
| 104 |
+
color="#ef4444" if site.criticality == "critical" else "#6b7280",
|
| 105 |
+
),
|
| 106 |
+
name="Service Site",
|
| 107 |
+
showlegend=False,
|
| 108 |
+
))
|
| 109 |
+
|
| 110 |
+
for tech in problem.technicians:
|
| 111 |
+
pos = result.stage1_plan.technician_positions.get(
|
| 112 |
+
tech.technician_id, (tech.home_lat, tech.home_lon),
|
| 113 |
+
)
|
| 114 |
+
fig.add_trace(go.Scattergeo(
|
| 115 |
+
lon=[pos[1]], lat=[pos[0]],
|
| 116 |
+
mode="markers",
|
| 117 |
+
marker=dict(size=10, color="#2563eb", symbol="triangle-up"),
|
| 118 |
+
name="Technician",
|
| 119 |
+
showlegend=False,
|
| 120 |
+
))
|
| 121 |
+
|
| 122 |
+
fig.update_layout(
|
| 123 |
+
title="Field Operations Map",
|
| 124 |
+
geo=dict(scope="europe", projection_type="natural earth"),
|
| 125 |
+
height=450,
|
| 126 |
+
margin=dict(t=50, b=20),
|
| 127 |
+
)
|
| 128 |
+
return fig
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def strategy_comparison_chart(results: list[OptimizationResult]):
|
| 132 |
+
strategies = [r.strategy.replace("_", " ").title() for r in results]
|
| 133 |
+
sla = [r.metrics.sla_compliance_pct for r in results]
|
| 134 |
+
mttr = [r.metrics.mean_time_to_repair_hours for r in results]
|
| 135 |
+
cost = [r.metrics.total_logistics_cost + r.metrics.total_downtime_cost for r in results]
|
| 136 |
+
|
| 137 |
+
fig = make_subplots(rows=1, cols=3, subplot_titles=("SLA %", "MTTR (h)", "Total Cost ($)"))
|
| 138 |
+
fig.add_trace(go.Bar(x=strategies, y=sla, marker_color="#2563eb"), row=1, col=1)
|
| 139 |
+
fig.add_trace(go.Bar(x=strategies, y=mttr, marker_color="#ef4444"), row=1, col=2)
|
| 140 |
+
fig.add_trace(go.Bar(x=strategies, y=cost, marker_color="#f59e0b"), row=1, col=3)
|
| 141 |
+
fig.update_layout(title="Strategy Comparison", height=400, showlegend=False,
|
| 142 |
+
margin=dict(t=60, b=80))
|
| 143 |
+
return fig
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def stock_expedite_chart(results: list[OptimizationResult]):
|
| 147 |
+
ratios = [f"{r.stage1_plan.notes[0].split(':')[1].strip() if r.stage1_plan.notes else '50%'}" for r in results]
|
| 148 |
+
holding = [r.metrics.total_holding_cost for r in results]
|
| 149 |
+
expedite = [r.metrics.expedite_shipments * 250 for r in results]
|
| 150 |
+
total = [h + e + r.metrics.total_logistics_cost for h, e, r in zip(holding, expedite, results)]
|
| 151 |
+
|
| 152 |
+
fig = go.Figure()
|
| 153 |
+
fig.add_trace(go.Bar(name="Holding Cost", x=[f"{i*20+20}%" for i in range(len(results))], y=holding))
|
| 154 |
+
fig.add_trace(go.Bar(name="Expedite Cost", x=[f"{i*20+20}%" for i in range(len(results))], y=expedite))
|
| 155 |
+
fig.add_trace(go.Scatter(
|
| 156 |
+
name="Total Cost", x=[f"{i*20+20}%" for i in range(len(results))],
|
| 157 |
+
y=total, mode="lines+markers", line=dict(color="#ef4444", width=3),
|
| 158 |
+
))
|
| 159 |
+
fig.update_layout(
|
| 160 |
+
title="Stock vs Expedite Trade-off",
|
| 161 |
+
barmode="stack", height=400,
|
| 162 |
+
xaxis_title="Stock Ratio", yaxis_title="Cost ($)",
|
| 163 |
+
margin=dict(t=50, b=40),
|
| 164 |
+
)
|
| 165 |
+
return fig
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def assignment_gantt(result: OptimizationResult):
|
| 169 |
+
if not result.assignments:
|
| 170 |
+
return None
|
| 171 |
+
|
| 172 |
+
fig = go.Figure()
|
| 173 |
+
colors = {"P1": "#ef4444", "P2": "#f59e0b", "P3": "#3b82f6", "P4": "#6b7280"}
|
| 174 |
+
|
| 175 |
+
for a in result.assignments:
|
| 176 |
+
fig.add_trace(go.Bar(
|
| 177 |
+
name=a.request_id,
|
| 178 |
+
y=[a.technician_name],
|
| 179 |
+
x=[a.estimated_completion_hours - a.estimated_arrival_hours],
|
| 180 |
+
base=[a.estimated_arrival_hours],
|
| 181 |
+
orientation="h",
|
| 182 |
+
marker_color="#2563eb",
|
| 183 |
+
showlegend=False,
|
| 184 |
+
))
|
| 185 |
+
|
| 186 |
+
fig.update_layout(
|
| 187 |
+
title="Mission Schedule Gantt",
|
| 188 |
+
xaxis_title="Hours from start",
|
| 189 |
+
height=max(300, len(set(a.technician_name for a in result.assignments)) * 50),
|
| 190 |
+
margin=dict(t=50, b=40),
|
| 191 |
+
)
|
| 192 |
+
return fig
|
index.html
CHANGED
|
@@ -1,19 +1,183 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>FieldOps — Service Parts & Field Operations Optimizer</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--accent: #2563eb; --accent2: #4f46e5;
|
| 10 |
+
--bg: #0f172a; --surface: #1e293b; --border: #334155;
|
| 11 |
+
--text: #f8fafc; --muted: #94a3b8;
|
| 12 |
+
--gradient: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
|
| 13 |
+
--ok: #34d399; --warn: #fbbf24;
|
| 14 |
+
}
|
| 15 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 16 |
+
body { font-family: Inter, system-ui, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
|
| 17 |
+
a { color: #93c5fd; text-decoration: none; }
|
| 18 |
+
.hero { padding: 3rem 1.5rem 1.5rem; text-align: center;
|
| 19 |
+
background: radial-gradient(ellipse at 50% 0%, rgba(37,99,235,.15), transparent 60%); }
|
| 20 |
+
.badge { display: inline-block; background: var(--gradient); color: #fff; font-size: .7rem;
|
| 21 |
+
font-weight: 700; letter-spacing: .12em; padding: .35rem 1rem; border-radius: 999px; margin-bottom: 1rem; }
|
| 22 |
+
h1 { font-size: clamp(1.7rem, 5vw, 2.5rem); font-weight: 800; }
|
| 23 |
+
h1 span { background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
| 24 |
+
.subtitle { color: var(--muted); max-width: 780px; margin: .75rem auto 1rem; font-size: .95rem; }
|
| 25 |
+
.container { max-width: 1180px; margin: 0 auto; padding: 0 1.5rem 3rem; }
|
| 26 |
+
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: .85rem; margin: 1.5rem 0; }
|
| 27 |
+
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: .75rem; padding: 1rem; text-align: center; }
|
| 28 |
+
.kpi .val { font-size: 1.45rem; font-weight: 800; color: var(--accent); }
|
| 29 |
+
.kpi .lbl { color: var(--muted); font-size: .75rem; margin-top: .2rem; }
|
| 30 |
+
.links { display: flex; gap: .75rem; justify-content: center; flex-wrap: wrap; margin-top: .75rem; }
|
| 31 |
+
.cta { padding: .65rem 1.2rem; background: var(--gradient); color: #fff; border-radius: .5rem; font-weight: 600; font-size: .85rem; }
|
| 32 |
+
.cta-out { padding: .65rem 1.2rem; border: 1px solid var(--border); border-radius: .5rem; font-size: .85rem; }
|
| 33 |
+
.scenario-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin: 1.25rem 0; }
|
| 34 |
+
.scenario { background: var(--surface); border: 1px solid var(--border); border-radius: .75rem; padding: 1.1rem; border-left: 4px solid var(--accent); }
|
| 35 |
+
.scenario h3 { font-size: 1rem; margin-bottom: .5rem; }
|
| 36 |
+
.scenario .meta { font-size: .82rem; color: var(--muted); margin: .35rem 0; }
|
| 37 |
+
.scenario .metric { font-size: 1.1rem; font-weight: 700; color: var(--ok); }
|
| 38 |
+
select { background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: .5rem; padding: .5rem .75rem; margin: .5rem 0; }
|
| 39 |
+
table { width: 100%; border-collapse: collapse; background: var(--surface); border-radius: .75rem; overflow: hidden;
|
| 40 |
+
border: 1px solid var(--border); margin: 1rem 0; font-size: .8rem; }
|
| 41 |
+
th { background: #152238; text-align: left; padding: .6rem .9rem; color: var(--muted); font-weight: 600; }
|
| 42 |
+
td { padding: .55rem .9rem; border-top: 1px solid var(--border); }
|
| 43 |
+
h2 { font-size: 1.15rem; margin: 1.5rem 0 .5rem; }
|
| 44 |
+
.note { color: var(--muted); font-size: .85rem; margin: .5rem 0 1rem; }
|
| 45 |
+
.footer { text-align: center; color: var(--muted); font-size: .82rem; padding: 1.5rem; border-top: 1px solid var(--border); }
|
| 46 |
+
.stage { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1rem 0; }
|
| 47 |
+
.stage-card { background: var(--surface); border: 1px solid var(--border); border-radius: .75rem; padding: 1rem; }
|
| 48 |
+
.stage-card h3 { font-size: .9rem; color: var(--accent); margin-bottom: .5rem; }
|
| 49 |
+
.stage-card ul { font-size: .82rem; color: var(--muted); padding-left: 1.2rem; }
|
| 50 |
+
@media (max-width: 640px) { .stage { grid-template-columns: 1fr; } }
|
| 51 |
+
</style>
|
| 52 |
+
</head>
|
| 53 |
+
<body>
|
| 54 |
+
<section class="hero">
|
| 55 |
+
<div class="badge">FIELD SERVICE · SPARE PARTS · OPTIMIZATION</div>
|
| 56 |
+
<h1><span>Service Parts & Field Operations Optimizer</span></h1>
|
| 57 |
+
<p class="subtitle">Predict-then-optimize platform for aftermarket field service. ML failure prediction, multi-echelon inventory, technician routing, and Monte Carlo SLA validation.</p>
|
| 58 |
+
<div class="links">
|
| 59 |
+
<a class="cta" href="https://huggingface.co/datasets/alirezaaminzadeh/fieldops-sample-scenarios">Dataset</a>
|
| 60 |
+
<a class="cta-out" href="https://huggingface.co/alirezaaminzadeh/fieldops-solver-config">Solver Config</a>
|
| 61 |
+
<a class="cta-out" href="https://huggingface.co/datasets/alirezaaminzadeh/fieldops-sample-scenarios/tree/main/space-bundle">Gradio Bundle</a>
|
| 62 |
+
</div>
|
| 63 |
+
</section>
|
| 64 |
+
|
| 65 |
+
<div class="container">
|
| 66 |
+
<div class="kpi-grid">
|
| 67 |
+
<div class="kpi"><div class="val">7</div><div class="lbl">Algorithms</div></div>
|
| 68 |
+
<div class="kpi"><div class="val">9</div><div class="lbl">Industries</div></div>
|
| 69 |
+
<div class="kpi"><div class="val" id="k-sla">—</div><div class="lbl">Avg SLA %</div></div>
|
| 70 |
+
<div class="kpi"><div class="val" id="k-mttr">—</div><div class="lbl">Avg MTTR</div></div>
|
| 71 |
+
<div class="kpi"><div class="val" id="k-ftf">—</div><div class="lbl">FTF Rate</div></div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<h2>Two-Stage Decision Model</h2>
|
| 75 |
+
<div class="stage">
|
| 76 |
+
<div class="stage-card">
|
| 77 |
+
<h3>Stage 1 — Pre-Failure Planning</h3>
|
| 78 |
+
<ul>
|
| 79 |
+
<li>Technician positioning across regions</li>
|
| 80 |
+
<li>Multi-echelon spare parts inventory</li>
|
| 81 |
+
<li>Supply contract parameters</li>
|
| 82 |
+
<li>Repair shop capacity allocation</li>
|
| 83 |
+
</ul>
|
| 84 |
+
</div>
|
| 85 |
+
<div class="stage-card">
|
| 86 |
+
<h3>Stage 2 — Post-Failure Response</h3>
|
| 87 |
+
<ul>
|
| 88 |
+
<li>Mission assignment to technicians</li>
|
| 89 |
+
<li>OR-Tools route optimization</li>
|
| 90 |
+
<li>Parts sourcing (stock / substitute / expedite)</li>
|
| 91 |
+
<li>Repair scheduling with deferral</li>
|
| 92 |
+
</ul>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<h2>Scenario Explorer</h2>
|
| 97 |
+
<p class="note">Pre-computed two-stage stochastic optimization results. Select an industry scenario.</p>
|
| 98 |
+
<label>Industry: <select id="scenario-select"></select></label>
|
| 99 |
+
<div class="scenario-grid" id="scenarios"></div>
|
| 100 |
+
|
| 101 |
+
<h2>Benchmark Summary</h2>
|
| 102 |
+
<div style="overflow-x:auto">
|
| 103 |
+
<table>
|
| 104 |
+
<thead>
|
| 105 |
+
<tr><th>Industry</th><th>Stress</th><th>SLA %</th><th>MTTR (h)</th><th>FTF %</th><th>Logistics $</th><th>Solve (s)</th></tr>
|
| 106 |
+
</thead>
|
| 107 |
+
<tbody id="bench-body"></tbody>
|
| 108 |
+
</table>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<h2>Stack</h2>
|
| 112 |
+
<p class="note">OR-Tools VRP · Multi-echelon inventory · Two-stage stochastic · ALNS · SAA · Monte Carlo · Pre-trained ML · Plotly · Gradio console (bundled)</p>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
<footer class="footer">FieldOps v1.0 · Apache 2.0 · Field Service Engineering</footer>
|
| 116 |
+
|
| 117 |
+
<script>
|
| 118 |
+
const SCENARIOS = ["industrial_equipment", "medical_equipment", "telecom", "data_center", "elevators"];
|
| 119 |
+
const LABELS = {
|
| 120 |
+
industrial_equipment: "Industrial Equipment",
|
| 121 |
+
medical_equipment: "Medical Equipment",
|
| 122 |
+
telecom: "Telecom Infrastructure",
|
| 123 |
+
data_center: "Data Center",
|
| 124 |
+
elevators: "Elevators & Lifts"
|
| 125 |
+
};
|
| 126 |
+
const SAMPLES = {};
|
| 127 |
+
|
| 128 |
+
async function load() {
|
| 129 |
+
const [summary, benchmarks, ...samples] = await Promise.all([
|
| 130 |
+
fetch("assets/demo/summary.json").then(r => r.json()),
|
| 131 |
+
fetch("assets/demo/benchmarks.json").then(r => r.json()),
|
| 132 |
+
...SCENARIOS.map(s => fetch(`assets/samples/sample_${s}.json`).then(r => r.json()).catch(() => null))
|
| 133 |
+
]);
|
| 134 |
+
SCENARIOS.forEach((s, i) => { if (samples[i]) SAMPLES[s] = samples[i]; });
|
| 135 |
+
|
| 136 |
+
const sel = document.getElementById("scenario-select");
|
| 137 |
+
SCENARIOS.forEach(s => {
|
| 138 |
+
const o = document.createElement("option");
|
| 139 |
+
o.value = s; o.textContent = LABELS[s] || s;
|
| 140 |
+
sel.appendChild(o);
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
const avgSla = benchmarks.reduce((a, b) => a + b.sla_compliance_pct, 0) / benchmarks.length;
|
| 144 |
+
const avgMttr = benchmarks.reduce((a, b) => a + b.mttr_hours, 0) / benchmarks.length;
|
| 145 |
+
const avgFtf = benchmarks.reduce((a, b) => a + b.ftf_rate_pct, 0) / benchmarks.length;
|
| 146 |
+
document.getElementById("k-sla").textContent = avgSla.toFixed(0) + "%";
|
| 147 |
+
document.getElementById("k-mttr").textContent = avgMttr.toFixed(1) + "h";
|
| 148 |
+
document.getElementById("k-ftf").textContent = avgFtf.toFixed(0) + "%";
|
| 149 |
+
|
| 150 |
+
const tbody = document.getElementById("bench-body");
|
| 151 |
+
benchmarks.forEach(b => {
|
| 152 |
+
const tr = document.createElement("tr");
|
| 153 |
+
tr.innerHTML = `<td>${LABELS[b.industry] || b.industry}</td><td>${b.stress}</td>
|
| 154 |
+
<td>${b.sla_compliance_pct}%</td><td>${b.mttr_hours}</td>
|
| 155 |
+
<td>${b.ftf_rate_pct}%</td><td>$${b.logistics_cost.toLocaleString()}</td>
|
| 156 |
+
<td>${b.solve_time_sec.toFixed(2)}s</td>`;
|
| 157 |
+
tbody.appendChild(tr);
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
function renderScenario(sc) {
|
| 161 |
+
const data = SAMPLES[sc];
|
| 162 |
+
if (!data) return;
|
| 163 |
+
const m = data.optimization.metrics;
|
| 164 |
+
const sim = data.optimization.simulation;
|
| 165 |
+
const grid = document.getElementById("scenarios");
|
| 166 |
+
grid.innerHTML = `
|
| 167 |
+
<div class="scenario">
|
| 168 |
+
<h3>${data.scenario_label}</h3>
|
| 169 |
+
<div class="meta">Sites: ${data.summary.sites} · Technicians: ${data.summary.technicians} · Warehouses: ${data.summary.warehouses}</div>
|
| 170 |
+
<div class="metric">SLA Compliance: ${m.sla_compliance_pct}%</div>
|
| 171 |
+
<div class="meta">MTTR: ${m.mean_time_to_repair_hours}h · FTF: ${m.first_time_fix_rate_pct}% · Utilization: ${m.technician_utilization_pct}%</div>
|
| 172 |
+
<div class="meta">Logistics: $${m.total_logistics_cost.toLocaleString()} · Downtime: $${m.total_downtime_cost.toLocaleString()} · Expedite: ${m.expedite_shipments}</div>
|
| 173 |
+
${sim ? `<div class="meta">Monte Carlo: SLA ${sim.sla_compliance_mean}% (95% CI: ${sim.confidence_interval_95.sla_compliance[0]}–${sim.confidence_interval_95.sla_compliance[1]}%)</div>` : ""}
|
| 174 |
+
</div>`;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
sel.addEventListener("change", () => renderScenario(sel.value));
|
| 178 |
+
renderScenario(SCENARIOS[0]);
|
| 179 |
+
}
|
| 180 |
+
load();
|
| 181 |
+
</script>
|
| 182 |
+
</body>
|
| 183 |
+
</html>
|