///|
fn sensitivity_point(
route : RouteProfile,
congestion : Double,
baseline_eta : Double,
input : Double,
) -> SensitivityPoint {
let breakdown = forecast_breakdown(route, speed=input, congestion~)
{
input,
eta_seconds: breakdown.total_eta_seconds,
speed: safe_div(route.total_distance(), breakdown.total_eta_seconds, 0.0),
confidence: breakdown.confidence,
delta_from_baseline: breakdown.total_eta_seconds - baseline_eta,
}
}
///|
fn monotonic_eta(points : Array[SensitivityPoint]) -> Bool {
if points.length() <= 1 {
true
} else {
let mut valid = true
let mut previous = points[0].eta_seconds
for point in points[1:] {
if point.eta_seconds > previous {
valid = false
}
previous = point.eta_seconds
}
valid
}
}
///|
/// Evaluate ETA sensitivity to live speed.
pub fn speed_sensitivity(
route : RouteProfile,
congestion~ : Double,
baseline_speed? : Double = 8.0,
minimum_speed? : Double = 2.0,
maximum_speed? : Double = 20.0,
steps? : Int = 8,
) -> SensitivityReport {
let baseline = forecast_breakdown(route, speed=baseline_speed, congestion~)
let values : Array[SensitivityPoint] = []
let count = if steps < 1 { 1 } else { steps }
for index in 0..<=count {
let input = minimum_speed +
(maximum_speed - minimum_speed) * index.to_double() / count.to_double()
values.push(
sensitivity_point(route, congestion, baseline.total_eta_seconds, input),
)
}
let slope = safe_div(
values[values.length() - 1].eta_seconds - values[0].eta_seconds,
values[values.length() - 1].input - values[0].input,
0.0,
)
{
parameter: "live_speed",
baseline: sensitivity_point(
route,
congestion,
baseline.total_eta_seconds,
baseline_speed,
),
points: values,
monotonic: monotonic_eta(values),
elasticity: slope,
}
}
///|
/// Evaluate ETA sensitivity to congestion.
pub fn congestion_sensitivity(
route : RouteProfile,
speed? : Double = 8.0,
steps? : Int = 10,
) -> SensitivityReport {
let baseline = forecast_breakdown(route, speed~, congestion=0.3)
let values : Array[SensitivityPoint] = []
let count = if steps < 1 { 1 } else { steps }
for index in 0..<=count {
let input = index.to_double() / count.to_double()
let breakdown = forecast_breakdown(route, speed~, congestion=input)
values.push({
input,
eta_seconds: breakdown.total_eta_seconds,
speed: safe_div(route.total_distance(), breakdown.total_eta_seconds, 0.0),
confidence: breakdown.confidence,
delta_from_baseline: breakdown.total_eta_seconds -
baseline.total_eta_seconds,
})
}
let slope = safe_div(
values[values.length() - 1].eta_seconds - values[0].eta_seconds,
values[values.length() - 1].input - values[0].input,
0.0,
)
{
parameter: "congestion",
baseline: {
input: 0.3,
eta_seconds: baseline.total_eta_seconds,
speed: safe_div(route.total_distance(), baseline.total_eta_seconds, 0.0),
confidence: baseline.confidence,
delta_from_baseline: 0.0,
},
points: values,
monotonic: values.length() <= 1 || !monotonic_eta(values),
elasticity: slope,
}
}
///|
/// Return the largest absolute ETA change in a sensitivity report.
pub fn maximum_sensitivity_delta(report : SensitivityReport) -> Double {
let values : Array[Double] = []
for point in report.points {
values.push(abs_double(point.delta_from_baseline))
}
if values.length() == 0 {
0.0
} else {
quantile(values, probability=1.0)
}
}
///|
/// Return the point closest to a requested input.
pub fn nearest_sensitivity_point(
report : SensitivityReport,
input : Double,
) -> SensitivityPoint? {
if report.points.length() == 0 {
None
} else {
let mut best = report.points[0]
let mut distance = abs_double(best.input - input)
for point in report.points[1:] {
let next_distance = abs_double(point.input - input)
if next_distance < distance {
best = point
distance = next_distance
}
}
Some(best)
}
}
///|
/// Render a sensitivity curve for chart ingestion.
pub fn format_sensitivity(report : SensitivityReport) -> String {
let builder = StringBuilder()
builder.write_string(
"parameter=\{report.parameter} monotonic=\{report.monotonic} elasticity=\{report.elasticity}\n",
)
for point in report.points {
builder.write_string(
"\{point.input},\{point.eta_seconds},\{point.speed},\{point.confidence}\n",
)
}
builder.to_string()
}