Eric-Tsai's picture
Upload 36 files
7a3d380 verified
Raw
History Blame Contribute Delete
10.9 kB
"""Synthetic data generation and CSV loading for claims and adjusters.
Generates data/claims.csv and data/adjusters.csv. To use real data, produce
CSVs with the same columns (or adapt load_claims / load_adjusters).
Time columns are stored as "HH:MM" strings for readability and parsed to
integer minutes since midnight on load.
"""
from __future__ import annotations
import csv
import os
import random
from dataclasses import dataclass, field
import config
def hhmm_to_min(s: str) -> int:
h, m = s.split(":")
return int(h) * 60 + int(m)
def min_to_hhmm(m: int) -> str:
return f"{m // 60:02d}:{m % 60:02d}"
@dataclass
class Claim:
claim_id: str
lat: float
lon: float
peril: str
priority: int # 1 = must inspect today, 2 = high, 3 = normal
window_start: int # minutes since midnight
window_end: int
service_minutes: int
age_days: int = 0 # days since FNOL; escalates the drop penalty
assigned_to: str | None = None # upstream pre-assignment (sequencer)
# Split availability (VRP with multiple time windows): optional
# extra windows beyond [window_start, window_end]. None or empty =
# the classic single window. Service must start AND finish inside
# one chosen window, same semantics as the primary pair.
extra_windows: list[tuple[int, int]] | None = None
@dataclass
class Adjuster:
adjuster_id: str
name: str
home_lat: float
home_lon: float
skills: list[str] = field(default_factory=list)
shift_start: int = 8 * 60
shift_end: int = 17 * 60
# Service territory: only take claims within this road-mile radius of
# home (optional adjusters.csv column; blank/absent = no limit).
max_radius_miles: float | None = None
# ---------------------------------------------------------------------------
# Generation
# ---------------------------------------------------------------------------
ADJUSTER_NAMES = [
"Alice Chen", "Bob Rivera", "Carol Nguyen", "David Okafor", "Erin Walsh",
"Frank Kim", "Grace Patel", "Hank Torres", "Ivy Johnson", "Jack Murphy",
]
# (window label, start, end, weight)
WINDOW_CHOICES = [
("all-day", "08:00", "17:00", 0.4),
("morning", "08:00", "12:00", 0.2),
("afternoon", "12:00", "17:00", 0.2),
("narrow-am", "09:00", "11:00", 0.1),
("narrow-pm", "13:00", "15:00", 0.1),
]
def _rand_point(rng: random.Random) -> tuple[float, float]:
lat = config.REGION_CENTER[0] + rng.uniform(-1, 1) * config.REGION_SPREAD_DEG
lon = config.REGION_CENTER[1] + rng.uniform(-1, 1) * config.REGION_SPREAD_DEG
return round(lat, 5), round(lon, 5)
def generate(n_claims: int = 25, n_adjusters: int = 5, seed: int = 42,
data_dir: str = config.DATA_DIR) -> None:
rng = random.Random(seed)
os.makedirs(data_dir, exist_ok=True)
# --- Adjusters: 2-3 skills each, and every peril covered by >= 2 people
adjusters = []
for i in range(n_adjusters):
lat, lon = _rand_point(rng)
n_skills = rng.choice([2, 2, 3])
skills = rng.sample(config.PERILS, n_skills)
shift_start, shift_end = rng.choice([("08:00", "17:00"),
("07:00", "16:00"),
("08:00", "17:00")])
adjusters.append({
"adjuster_id": f"ADJ-{i + 1:02d}",
"name": ADJUSTER_NAMES[i % len(ADJUSTER_NAMES)],
"home_lat": lat,
"home_lon": lon,
"skills": "|".join(sorted(skills)),
"shift_start": shift_start,
"shift_end": shift_end,
})
# Patch coverage: make sure each peril appears in at least 2 skill sets.
for peril in config.PERILS:
holders = [a for a in adjusters if peril in a["skills"].split("|")]
while len(holders) < 2:
victim = rng.choice([a for a in adjusters if a not in holders])
victim["skills"] = "|".join(sorted(victim["skills"].split("|") + [peril]))
holders.append(victim)
# --- Claims
claims = []
for i in range(n_claims):
lat, lon = _rand_point(rng)
peril = rng.choice(config.PERILS)
r = rng.random()
if r < 0.12:
priority = config.PRIORITY_MUST_TODAY
elif r < 0.40:
priority = config.PRIORITY_HIGH
else:
priority = config.PRIORITY_NORMAL
_, ws, we, _ = rng.choices(
WINDOW_CHOICES, weights=[w for *_, w in WINDOW_CHOICES])[0]
# Serious losses take longer to inspect.
if priority == config.PRIORITY_MUST_TODAY:
service = rng.choice([120, 150, 180])
else:
service = rng.choice([60, 75, 90, 120])
claims.append({
"claim_id": f"CLM-{i + 1:03d}",
"lat": lat,
"lon": lon,
"peril": peril,
"priority": priority,
"window_start": ws,
"window_end": we,
"service_minutes": service,
})
with open(os.path.join(data_dir, "adjusters.csv"), "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(adjusters[0].keys()))
w.writeheader()
w.writerows(adjusters)
with open(os.path.join(data_dir, "claims.csv"), "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(claims[0].keys()))
w.writeheader()
w.writerows(claims)
# ---------------------------------------------------------------------------
# Loading
# ---------------------------------------------------------------------------
def claim_windows(c: Claim) -> list[tuple[int, int]]:
"""All availability windows of a claim, primary first, sorted."""
wins = [(c.window_start, c.window_end)]
for lo, hi in (c.extra_windows or []):
wins.append((int(lo), int(hi)))
return sorted(set(wins))
def arrival_ranges(c: Claim) -> list[tuple[int, int]]:
"""Allowed SERVICE-START ranges: inside a window, finishing by its
end (the same tightening every backend applies), merged/sorted."""
ranges = []
for lo, hi in claim_windows(c):
ranges.append((lo, max(lo, hi - c.service_minutes)))
ranges.sort()
merged = [list(ranges[0])]
for lo, hi in ranges[1:]:
if lo <= merged[-1][1] + 1:
merged[-1][1] = max(merged[-1][1], hi)
else:
merged.append([lo, hi])
return [(lo, hi) for lo, hi in merged]
def win_str(c: Claim) -> str:
"""Display text for all of a claim's availability windows."""
return " | ".join(f"{min_to_hhmm(lo)}-{min_to_hhmm(hi)}"
for lo, hi in claim_windows(c))
def _parse_windows(text):
"""'08:00-10:30|16:00-17:00' -> list of (start_min, end_min)."""
wins = []
for part in text.split("|"):
lo, hi = part.strip().split("-")
wins.append((hhmm_to_min(lo.strip()), hhmm_to_min(hi.strip())))
return wins
def load_claims(data_dir: str = config.DATA_DIR) -> list[Claim]:
claims = []
with open(os.path.join(data_dir, "claims.csv"), newline="") as f:
for row in csv.DictReader(f):
claims.append(Claim(
claim_id=row["claim_id"],
lat=float(row["lat"]),
lon=float(row["lon"]),
peril=row["peril"],
priority=int(row["priority"]),
window_start=hhmm_to_min(row["window_start"]),
window_end=hhmm_to_min(row["window_end"]),
service_minutes=int(row["service_minutes"]),
age_days=int(row.get("age_days") or 0),
assigned_to=(row.get("assigned_to") or "").strip() or None,
))
wtext = (row.get("windows") or "").strip()
if wtext:
wins = _parse_windows(wtext)
c = claims[-1]
c.window_start, c.window_end = wins[0]
c.extra_windows = wins[1:] or None
return claims
def load_adjusters(data_dir: str = config.DATA_DIR) -> list[Adjuster]:
adjusters = []
with open(os.path.join(data_dir, "adjusters.csv"), newline="") as f:
for row in csv.DictReader(f):
adjusters.append(Adjuster(
adjuster_id=row["adjuster_id"],
name=row["name"],
home_lat=float(row["home_lat"]),
home_lon=float(row["home_lon"]),
skills=row["skills"].split("|"),
shift_start=hhmm_to_min(row["shift_start"]),
shift_end=hhmm_to_min(row["shift_end"]),
max_radius_miles=(float(row["max_radius_miles"])
if row.get("max_radius_miles")
else None),
))
return adjusters
# ---------------------------------------------------------------------------
# Writing (round-trip of the loaders, optional columns included)
# ---------------------------------------------------------------------------
def write_claims_csv(claims: list[Claim], path: str) -> str:
"""Write claims in the exact upload schema, optional columns included."""
with_assign = any(c.assigned_to for c in claims)
with_windows = any(c.extra_windows for c in claims)
with open(path, "w", newline="") as f:
w = csv.writer(f)
header = ["claim_id", "lat", "lon", "peril", "priority",
"window_start", "window_end", "service_minutes",
"age_days"]
if with_assign:
header.append("assigned_to")
if with_windows:
header.append("windows")
w.writerow(header)
for c in claims:
row = [c.claim_id, c.lat, c.lon, c.peril, c.priority,
min_to_hhmm(c.window_start),
min_to_hhmm(c.window_end),
c.service_minutes, c.age_days]
if with_assign:
row.append(c.assigned_to or "")
if with_windows:
row.append("|".join(f"{min_to_hhmm(lo)}-{min_to_hhmm(hi)}"
for lo, hi in claim_windows(c))
if c.extra_windows else "")
w.writerow(row)
return path
def write_adjusters_csv(adjusters: list[Adjuster], path: str) -> str:
"""Write adjusters in the exact upload schema."""
with open(path, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["adjuster_id", "name", "home_lat", "home_lon",
"skills", "shift_start", "shift_end",
"max_radius_miles"])
for a in adjusters:
w.writerow([a.adjuster_id, a.name, a.home_lat, a.home_lon,
"|".join(a.skills),
min_to_hhmm(a.shift_start),
min_to_hhmm(a.shift_end),
"" if a.max_radius_miles is None
else a.max_radius_miles])
return path