use crate::server::models::{LoadInterpolation, LoadPoint, LoadProfile};
pub fn validate_load_profile(profile: &LoadProfile) -> Result<(), String> {
if profile.points.len() < 2 {
return Err("load.points must contain at least two points".to_owned());
}
if profile.points[0].at_ms != 0 {
return Err("load.points[0].atMs must be 0".to_owned());
}
if profile.runner_max_rps <= 0.0 {
return Err("load.runnerMaxRps must be positive".to_owned());
}
for point in &profile.points {
if !(0.0..=100.0).contains(&point.intensity) {
return Err("load.points intensity must be between 0 and 100".to_owned());
}
}
for pair in profile.points.windows(2) {
if pair[1].at_ms <= pair[0].at_ms {
return Err("load.points must be strictly increasing by atMs".to_owned());
}
}
Ok(())
}
pub fn calculate_tick_ms(profile: &LoadProfile) -> u64 {
let min_interval = profile
.points
.windows(2)
.map(|pair| pair[1].at_ms.saturating_sub(pair[0].at_ms))
.filter(|interval| *interval > 0)
.min()
.unwrap_or(10_000);
(min_interval / 10).clamp(100, 1000)
}
pub fn calculate_dispatch_tick_ms(profile: &LoadProfile) -> u64 {
calculate_tick_ms(profile).min(100)
}
pub fn sample_intensity(profile: &LoadProfile, elapsed_ms: u64) -> f64 {
let last = profile
.points
.last()
.expect("validated profile must contain points");
if elapsed_ms >= last.at_ms {
return last.intensity;
}
let (start, end) = find_segment(&profile.points, elapsed_ms);
if elapsed_ms >= end.at_ms {
return end.intensity;
}
match profile.interpolation {
LoadInterpolation::Step => start.intensity,
LoadInterpolation::Linear => interpolate_linear(start, end, elapsed_ms),
LoadInterpolation::Smooth => {
let raw_t = segment_t(start, end, elapsed_ms);
let smooth_t = raw_t * raw_t * (3.0 - 2.0 * raw_t);
start.intensity + (end.intensity - start.intensity) * smooth_t
}
}
}
pub fn local_rps_limit(profile: &LoadProfile, elapsed_ms: u64) -> f64 {
profile.runner_max_rps * sample_intensity(profile, elapsed_ms) / 100.0
}
pub fn timeline_end_ms(profile: &LoadProfile) -> u64 {
profile
.points
.last()
.map(|point| point.at_ms)
.unwrap_or_default()
}
fn find_segment(points: &[LoadPoint], elapsed_ms: u64) -> (&LoadPoint, &LoadPoint) {
points
.windows(2)
.find(|pair| elapsed_ms >= pair[0].at_ms && elapsed_ms < pair[1].at_ms)
.map(|pair| (&pair[0], &pair[1]))
.unwrap_or_else(|| (&points[points.len() - 2], &points[points.len() - 1]))
}
fn interpolate_linear(start: &LoadPoint, end: &LoadPoint, elapsed_ms: u64) -> f64 {
let t = segment_t(start, end, elapsed_ms);
start.intensity + (end.intensity - start.intensity) * t
}
fn segment_t(start: &LoadPoint, end: &LoadPoint, elapsed_ms: u64) -> f64 {
let span = end.at_ms.saturating_sub(start.at_ms).max(1) as f64;
let offset = elapsed_ms.saturating_sub(start.at_ms) as f64;
(offset / span).clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use crate::server::models::{LoadInterpolation, LoadPoint, LoadProfile};
use serde_json::json;
#[test]
fn accepts_valid_wave_profile() {
let profile = LoadProfile {
points: vec![
LoadPoint {
at_ms: 0,
intensity: 10.0,
},
LoadPoint {
at_ms: 60_000,
intensity: 80.0,
},
],
interpolation: LoadInterpolation::Smooth,
runner_max_rps: 1000.0,
grace_period_ms: 30_000,
};
assert!(super::validate_load_profile(&profile).is_ok());
}
#[test]
fn accepts_wave_profile_without_max_in_flight() {
let profile: LoadProfile = serde_json::from_value(json!({
"points": [
{ "atMs": 0, "intensity": 10.0 },
{ "atMs": 60_000, "intensity": 80.0 }
],
"interpolation": "smooth",
"runnerMaxRps": 1000.0,
"gracePeriodMs": 30_000
}))
.expect("wave profile without maxInFlight");
assert!(super::validate_load_profile(&profile).is_ok());
}
#[test]
fn rejects_wave_without_zero_start() {
let profile = LoadProfile {
points: vec![
LoadPoint {
at_ms: 100,
intensity: 10.0,
},
LoadPoint {
at_ms: 60_000,
intensity: 80.0,
},
],
interpolation: LoadInterpolation::Smooth,
runner_max_rps: 1000.0,
grace_period_ms: 30_000,
};
assert_eq!(
super::validate_load_profile(&profile).unwrap_err(),
"load.points[0].atMs must be 0"
);
}
#[test]
fn rejects_non_increasing_points() {
let profile = LoadProfile {
points: vec![
LoadPoint {
at_ms: 0,
intensity: 10.0,
},
LoadPoint {
at_ms: 0,
intensity: 80.0,
},
],
interpolation: LoadInterpolation::Smooth,
runner_max_rps: 1000.0,
grace_period_ms: 30_000,
};
assert_eq!(
super::validate_load_profile(&profile).unwrap_err(),
"load.points must be strictly increasing by atMs"
);
}
#[test]
fn rejects_out_of_range_intensity() {
let profile = LoadProfile {
points: vec![
LoadPoint {
at_ms: 0,
intensity: 10.0,
},
LoadPoint {
at_ms: 60_000,
intensity: 120.0,
},
],
interpolation: LoadInterpolation::Smooth,
runner_max_rps: 1000.0,
grace_period_ms: 30_000,
};
assert_eq!(
super::validate_load_profile(&profile).unwrap_err(),
"load.points intensity must be between 0 and 100"
);
}
#[test]
fn calculates_dynamic_tick_with_minimum_and_maximum() {
let long_profile = LoadProfile {
points: vec![
LoadPoint {
at_ms: 0,
intensity: 10.0,
},
LoadPoint {
at_ms: 60_000,
intensity: 80.0,
},
],
interpolation: LoadInterpolation::Smooth,
runner_max_rps: 1000.0,
grace_period_ms: 30_000,
};
assert_eq!(super::calculate_tick_ms(&long_profile), 1000);
let short_profile = LoadProfile {
points: vec![
LoadPoint {
at_ms: 0,
intensity: 10.0,
},
LoadPoint {
at_ms: 500,
intensity: 80.0,
},
],
interpolation: LoadInterpolation::Smooth,
runner_max_rps: 1000.0,
grace_period_ms: 30_000,
};
assert_eq!(super::calculate_tick_ms(&short_profile), 100);
}
#[test]
fn dispatch_tick_uses_fine_grained_100ms_cadence() {
let long_profile = LoadProfile {
points: vec![
LoadPoint {
at_ms: 0,
intensity: 10.0,
},
LoadPoint {
at_ms: 120_000,
intensity: 80.0,
},
],
interpolation: LoadInterpolation::Smooth,
runner_max_rps: 1000.0,
grace_period_ms: 30_000,
};
assert_eq!(super::calculate_tick_ms(&long_profile), 1000);
assert_eq!(super::calculate_dispatch_tick_ms(&long_profile), 100);
}
#[test]
fn interpolates_linear_values() {
let profile = LoadProfile {
points: vec![
LoadPoint {
at_ms: 0,
intensity: 10.0,
},
LoadPoint {
at_ms: 1000,
intensity: 90.0,
},
],
interpolation: LoadInterpolation::Linear,
runner_max_rps: 1000.0,
grace_period_ms: 30_000,
};
assert_eq!(super::sample_intensity(&profile, 500), 50.0);
}
#[test]
fn interpolates_smoothstep_values() {
let profile = LoadProfile {
points: vec![
LoadPoint {
at_ms: 0,
intensity: 0.0,
},
LoadPoint {
at_ms: 1000,
intensity: 100.0,
},
],
interpolation: LoadInterpolation::Smooth,
runner_max_rps: 1000.0,
grace_period_ms: 30_000,
};
assert!((super::sample_intensity(&profile, 250) - 15.625).abs() < 0.001);
assert_eq!(super::sample_intensity(&profile, 500), 50.0);
}
#[test]
fn interpolates_step_values() {
let profile = LoadProfile {
points: vec![
LoadPoint {
at_ms: 0,
intensity: 10.0,
},
LoadPoint {
at_ms: 1000,
intensity: 90.0,
},
],
interpolation: LoadInterpolation::Step,
runner_max_rps: 1000.0,
grace_period_ms: 30_000,
};
assert_eq!(super::sample_intensity(&profile, 999), 10.0);
assert_eq!(super::sample_intensity(&profile, 1000), 90.0);
}
}