///|
/// Aggregate acceptance metrics for one schedule and request.
pub(all) struct ScheduleAnalytics {
total_visits : Int
required_visits : Int
assigned_visits : Int
assigned_required_visits : Int
optional_unassigned : Int
required_unassigned : Int
coverage_percent : Int
required_coverage_percent : Int
total_service_minutes : Int
total_travel_minutes : Int
travel_share_percent : Int
average_worker_utilization_percent : Int
busiest_worker_id : String?
busiest_worker_minutes : Int
zones_served : Array[String]
reason_counts : Array[ReasonCount]
} derive(Debug, Eq)
///|
/// Frequency of one stable unassigned reason code.
pub(all) struct ReasonCount {
code : String
count : Int
} derive(Debug, Eq)
///|
fn percentage(numerator : Int, denominator : Int) -> Int {
if denominator <= 0 {
0
} else {
clamp_int(numerator * 100 / denominator, 0, 100)
}
}
///|
/// Count required visits in a request.
pub fn count_required_visits(visits : Array[Visit]) -> Int {
let mut count = 0
for visit in visits {
if visit.required {
count = count + 1
}
}
count
}
///|
/// Count assigned visits that are required.
pub fn count_assigned_required(
assignments : Array[Assignment],
visits : Array[Visit],
) -> Int {
let mut count = 0
for assignment in assignments {
match find_visit(visits, assignment.visit_id) {
Some(visit) => if visit.required { count = count + 1 }
None => ()
}
}
count
}
///|
/// Count required and optional unassigned visits separately.
pub fn unassigned_breakdown(
unassigned : Array[UnassignedVisit],
visits : Array[Visit],
) -> (Int, Int) {
let mut required = 0
let mut optional = 0
for missing in unassigned {
match find_visit(visits, missing.visit_id) {
Some(visit) =>
if visit.required {
required = required + 1
} else {
optional = optional + 1
}
None => required = required + 1
}
}
(required, optional)
}
///|
/// Aggregate unassigned reason codes into stable sorted counters.
pub fn count_unassigned_reasons(
unassigned : Array[UnassignedVisit],
) -> Array[ReasonCount] {
let codes : Array[String] = []
for missing in unassigned {
for code in missing.reason_codes {
push_unique_string(codes, code)
}
}
let result : Array[ReasonCount] = []
for code in sort_strings(codes) {
let mut count = 0
for missing in unassigned {
if string_array_contains(missing.reason_codes, code) {
count = count + 1
}
}
result.push({ code, count })
}
result
}
///|
/// List service zones represented by assigned visits.
pub fn assigned_zones(
assignments : Array[Assignment],
visits : Array[Visit],
) -> Array[String] {
let zones : Array[String] = []
for assignment in assignments {
match find_visit(visits, assignment.visit_id) {
Some(visit) => push_unique_string(zones, visit.location.zone)
None => ()
}
}
sort_strings(zones)
}
///|
/// Determine the busiest worker by service minutes with stable id tie-break.
pub fn busiest_worker(loads : Array[WorkerLoad]) -> (String?, Int) {
let mut best_id : String? = None
let mut best_minutes = -1
for load in loads {
let tie_break = match best_id {
Some(id) => load.worker_id < id
None => true
}
let should_replace = load.service_minutes > best_minutes ||
(load.service_minutes == best_minutes && tie_break)
if should_replace {
best_id = Some(load.worker_id)
best_minutes = load.service_minutes
}
}
if best_minutes < 0 {
(None, 0)
} else {
(best_id, best_minutes)
}
}
///|
/// Average worker utilization from existing normalized loads.
pub fn average_utilization(loads : Array[WorkerLoad]) -> Int {
if loads.length() == 0 {
return 0
}
let mut total = 0
for load in loads {
total = total + load.utilization_percent
}
total / loads.length()
}
///|
/// Compute transparent acceptance metrics from a schedule.
pub fn analyze_schedule(
schedule : Schedule,
request : ScheduleRequest,
) -> ScheduleAnalytics {
let required_visits = count_required_visits(request.visits)
let assigned_required = count_assigned_required(
schedule.assignments,
request.visits,
)
let (required_unassigned, optional_unassigned) = unassigned_breakdown(
schedule.unassigned,
request.visits,
)
let loads = all_worker_loads(request.workers, schedule.assignments)
let mut service_minutes = 0
for load in loads {
service_minutes = service_minutes + load.service_minutes
}
let travel = total_schedule_travel(schedule.assignments)
let (busiest_id, busiest_minutes) = busiest_worker(loads)
{
total_visits: request.visits.length(),
required_visits,
assigned_visits: schedule.assignments.length(),
assigned_required_visits: assigned_required,
optional_unassigned,
required_unassigned,
coverage_percent: percentage(
schedule.assignments.length(),
request.visits.length(),
),
required_coverage_percent: percentage(assigned_required, required_visits),
total_service_minutes: service_minutes,
total_travel_minutes: travel,
travel_share_percent: percentage(travel, service_minutes + travel),
average_worker_utilization_percent: average_utilization(loads),
busiest_worker_id: busiest_id,
busiest_worker_minutes: busiest_minutes,
zones_served: assigned_zones(schedule.assignments, request.visits),
reason_counts: count_unassigned_reasons(schedule.unassigned),
}
}
///|
/// Human-readable analytics for acceptance reports.
pub fn analytics_summary(value : ScheduleAnalytics) -> String {
let builder = StringBuilder::new()
builder.write_string(
"coverage=\{value.coverage_percent}% (\{value.assigned_visits}/\{value.total_visits}), required_coverage=\{value.required_coverage_percent}% (\{value.assigned_required_visits}/\{value.required_visits})\n",
)
builder.write_string(
"service_minutes=\{value.total_service_minutes}, travel_minutes=\{value.total_travel_minutes}, travel_share=\{value.travel_share_percent}%\n",
)
builder.write_string(
"average_worker_utilization=\{value.average_worker_utilization_percent}%, zones=\{value.zones_served.join(", ")}\n",
)
match value.busiest_worker_id {
Some(id) =>
builder.write_string(
"busiest_worker=\{id} (\{value.busiest_worker_minutes} service minutes)\n",
)
None => builder.write_string("busiest_worker=none\n")
}
if value.reason_counts.length() > 0 {
builder.write_string("unassigned_reasons:\n")
for item in value.reason_counts {
builder.write_string("- \{item.code}: \{item.count}\n")
}
}
builder.to_string()
}