///|
/// A weighted measurement of an asset health indicator.
pub struct DegradationObservation {
time : Double
value : Double
weight : Double
}
///|
pub fn degradation_observation(
time : Double,
value : Double,
weight : Double,
) -> DegradationObservation {
if time < 0.0 || weight <= 0.0 {
abort("invalid degradation observation")
}
{ time, value, weight }
}
///|
pub(all) enum DegradationLaw {
DegradationLinear
DegradationExponential
DegradationPower
DegradationLogistic
} derive(Debug, Eq)
///|
pub struct DegradationTrack {
name : String
observations : Array[DegradationObservation]
lower_limit : Double
upper_limit : Double
direction : Int
}
///|
pub fn degradation_track(
name : String,
observations : Array[DegradationObservation],
lower_limit : Double,
upper_limit : Double,
direction : Int,
) -> DegradationTrack {
if observations.is_empty() ||
lower_limit > upper_limit ||
(direction != 1 && direction != -1) {
abort("invalid degradation track")
}
let ordered = observations.copy()
ordered.sort_by((left, right) => {
if left.time < right.time {
-1
} else if left.time > right.time {
1
} else {
0
}
})
{ name, observations: ordered, lower_limit, upper_limit, direction }
}
///|
pub fn degradation_count(track : DegradationTrack) -> Int {
track.observations.length()
}
///|
pub fn degradation_first_time(track : DegradationTrack) -> Double {
track.observations[0].time
}
///|
pub fn degradation_last_time(track : DegradationTrack) -> Double {
track.observations[track.observations.length() - 1].time
}
///|
pub fn degradation_duration(track : DegradationTrack) -> Double {
degradation_last_time(track) - degradation_first_time(track)
}
///|
pub fn degradation_times(track : DegradationTrack) -> Array[Double] {
track.observations.map(observation => observation.time)
}
///|
pub fn degradation_values(track : DegradationTrack) -> Array[Double] {
track.observations.map(observation => observation.value)
}
///|
pub fn degradation_weights(track : DegradationTrack) -> Array[Double] {
track.observations.map(observation => observation.weight)
}
///|
pub fn degradation_weight(track : DegradationTrack) -> Double {
track.observations.fold(init=0.0, (total, observation) => {
total + observation.weight
})
}
///|
pub fn degradation_mean(track : DegradationTrack) -> Double {
weighted_mean(degradation_values(track), degradation_weights(track))
}
///|
pub fn degradation_variance(track : DegradationTrack) -> Double {
weighted_variance(degradation_values(track), degradation_weights(track))
}
///|
pub fn degradation_standard_deviation(track : DegradationTrack) -> Double {
degradation_variance(track).sqrt()
}
///|
pub fn degradation_minimum(track : DegradationTrack) -> Double {
min_value(degradation_values(track))
}
///|
pub fn degradation_maximum(track : DegradationTrack) -> Double {
max_value(degradation_values(track))
}
///|
pub fn degradation_range(track : DegradationTrack) -> Double {
degradation_maximum(track) - degradation_minimum(track)
}
///|
pub fn degradation_limit(track : DegradationTrack) -> Double {
if track.direction > 0 {
track.upper_limit
} else {
track.lower_limit
}
}
///|
pub fn degradation_margin(track : DegradationTrack, value : Double) -> Double {
if track.direction > 0 {
track.upper_limit - value
} else {
value - track.lower_limit
}
}
///|
pub fn degradation_normalized_margin(
track : DegradationTrack,
value : Double,
) -> Double {
let span = track.upper_limit - track.lower_limit
if span == 0.0 {
0.0
} else {
degradation_margin(track, value) / span
}
}
///|
pub fn degradation_is_beyond_limit(
track : DegradationTrack,
value : Double,
) -> Bool {
if track.direction > 0 {
value >= track.upper_limit
} else {
value <= track.lower_limit
}
}
///|
pub fn degradation_is_warning(
track : DegradationTrack,
value : Double,
warning_fraction : Double,
) -> Bool {
if warning_fraction < 0.0 || warning_fraction > 1.0 {
abort("warning fraction must be in [0, 1]")
}
let threshold = if track.direction > 0 {
track.upper_limit -
(track.upper_limit - track.lower_limit) * warning_fraction
} else {
track.lower_limit +
(track.upper_limit - track.lower_limit) * warning_fraction
}
if track.direction > 0 {
value >= threshold
} else {
value <= threshold
}
}
///|
pub fn degradation_out_of_bounds_count(track : DegradationTrack) -> Int {
track.observations.fold(init=0, (count, observation) => {
if observation.value < track.lower_limit ||
observation.value > track.upper_limit {
count + 1
} else {
count
}
})
}
///|
pub fn degradation_out_of_bounds_fraction(track : DegradationTrack) -> Double {
degradation_out_of_bounds_count(track).to_double() /
degradation_count(track).to_double()
}
///|
pub fn degradation_monotonicity(track : DegradationTrack) -> Double {
if degradation_count(track) < 2 {
1.0
} else {
let mut good = 0
for i in 1.. 0 && change >= 0.0) ||
(track.direction < 0 && change <= 0.0) {
good += 1
}
}
good.to_double() / (degradation_count(track) - 1).to_double()
}
}
///|
pub fn degradation_total_change(track : DegradationTrack) -> Double {
if degradation_count(track) < 2 {
0.0
} else {
track.observations[track.observations.length() - 1].value -
track.observations[0].value
}
}
///|
pub fn degradation_rate(track : DegradationTrack) -> Double {
let duration = degradation_duration(track)
if duration == 0.0 {
0.0
} else {
degradation_total_change(track) / duration
}
}
///|
pub fn degradation_absolute_rate(track : DegradationTrack) -> Double {
degradation_rate(track).abs()
}
///|
pub fn degradation_directional_rate(track : DegradationTrack) -> Double {
degradation_rate(track) * track.direction.to_double()
}
///|
pub fn degradation_last_value(track : DegradationTrack) -> Double {
track.observations[track.observations.length() - 1].value
}
///|
pub fn degradation_first_value(track : DegradationTrack) -> Double {
track.observations[0].value
}
///|
pub fn degradation_recent_rate(track : DegradationTrack, count : Int) -> Double {
if count < 2 {
abort("recent count must be at least two")
}
let start = (track.observations.length() - count).max(0)
let points = Array::makei(track.observations.length() - start, i => {
track.observations[start + i]
})
degradation_rate(
degradation_track(
track.name,
points,
track.lower_limit,
track.upper_limit,
track.direction,
),
)
}
///|
pub struct DegradationFit {
law : DegradationLaw
coefficients : Array[Double]
residuals : Array[Double]
rmse : Double
r_squared : Double
converged : Bool
iterations : Int
}
///|
pub fn degradation_fit(
law : DegradationLaw,
coefficients : Array[Double],
residuals : Array[Double],
rmse : Double,
r_squared : Double,
converged : Bool,
iterations : Int,
) -> DegradationFit {
{ law, coefficients, residuals, rmse, r_squared, converged, iterations }
}
///|
fn degradation_regression(track : DegradationTrack) -> (Double, Double, Double) {
let times = degradation_times(track)
let values = degradation_values(track)
let weights = degradation_weights(track)
let mean_time = weighted_mean(times, weights)
let mean_value = weighted_mean(values, weights)
let mut numerator = 0.0
let mut denominator = 0.0
for i in 0.. DegradationFit {
let (slope, intercept, r2) = degradation_regression(track)
let residuals = track.observations.map(observation => {
observation.value - (intercept + slope * observation.time)
})
let rmse = mean(residuals.map(value => value * value)).sqrt()
degradation_fit(
DegradationLinear,
[intercept, slope],
residuals,
rmse,
r2,
true,
1,
)
}
///|
pub fn degradation_fit_exponential(track : DegradationTrack) -> DegradationFit {
let positive = track.observations.filter(observation => {
observation.value > 0.0
})
if positive.length() < 2 {
abort("exponential fit requires positive values")
}
let transformed = degradation_track(
track.name,
positive.map(observation => {
degradation_observation(
observation.time,
@math.ln(observation.value),
observation.weight,
)
}),
@math.ln(track.lower_limit.max(1.0e-300)),
@math.ln(track.upper_limit.max(1.0e-300)),
track.direction,
)
let fit = degradation_fit_linear(transformed)
let residuals = track.observations.map(observation => {
let prediction = @math.exp(
fit.coefficients[0] + fit.coefficients[1] * observation.time,
)
observation.value - prediction
})
let rmse = mean(residuals.map(value => value * value)).sqrt()
degradation_fit(
DegradationExponential,
[@math.exp(fit.coefficients[0]), fit.coefficients[1]],
residuals,
rmse,
fit.r_squared,
fit.converged,
fit.iterations,
)
}
///|
pub fn degradation_fit_power(track : DegradationTrack) -> DegradationFit {
let positive = track.observations.filter(observation => {
observation.value > 0.0 && observation.time > 0.0
})
if positive.length() < 2 {
abort("power fit requires positive time and value")
}
let transformed = degradation_track(
track.name,
positive.map(observation => {
degradation_observation(
@math.ln(observation.time),
@math.ln(observation.value),
observation.weight,
)
}),
@math.ln(track.lower_limit.max(1.0e-300)),
@math.ln(track.upper_limit.max(1.0e-300)),
track.direction,
)
let fit = degradation_fit_linear(transformed)
let residuals = track.observations.map(observation => {
if observation.time <= 0.0 {
0.0
} else {
let prediction = @math.exp(fit.coefficients[0]) *
@math.pow(observation.time, fit.coefficients[1])
observation.value - prediction
}
})
let rmse = mean(residuals.map(value => value * value)).sqrt()
degradation_fit(
DegradationPower,
[@math.exp(fit.coefficients[0]), fit.coefficients[1]],
residuals,
rmse,
fit.r_squared,
fit.converged,
fit.iterations,
)
}
///|
pub fn degradation_fit_best(track : DegradationTrack) -> DegradationFit {
let candidates = [
degradation_fit_linear(track),
degradation_fit_exponential(track),
degradation_fit_power(track),
]
let mut best = candidates[0]
for candidate in candidates[1:] {
if candidate.rmse < best.rmse {
best = candidate
}
}
best
}
///|
pub fn degradation_fit_predict(fit : DegradationFit, time : Double) -> Double {
match fit.law {
DegradationLinear => fit.coefficients[0] + fit.coefficients[1] * time
DegradationExponential =>
fit.coefficients[0] * @math.exp(fit.coefficients[1] * time)
DegradationPower =>
if time <= 0.0 {
0.0
} else {
fit.coefficients[0] * @math.pow(time, fit.coefficients[1])
}
DegradationLogistic => {
let denominator = 1.0 +
@math.exp(-fit.coefficients[1] * (time - fit.coefficients[2]))
fit.coefficients[0] / denominator
}
}
}
///|
pub fn degradation_fit_derivative(
fit : DegradationFit,
time : Double,
) -> Double {
match fit.law {
DegradationLinear => fit.coefficients[1]
DegradationExponential =>
degradation_fit_predict(fit, time) * fit.coefficients[1]
DegradationPower =>
if time <= 0.0 {
0.0
} else {
fit.coefficients[0] *
fit.coefficients[1] *
@math.pow(time, fit.coefficients[1] - 1.0)
}
DegradationLogistic => {
let p = degradation_fit_predict(fit, time)
fit.coefficients[1] * p * (1.0 - p / fit.coefficients[0])
}
}
}
///|
pub fn degradation_fit_acceleration(
fit : DegradationFit,
time : Double,
) -> Double {
let left = degradation_fit_derivative(fit, time - 1.0e-3)
let right = degradation_fit_derivative(fit, time + 1.0e-3)
(right - left) / 2.0e-3
}
///|
pub fn degradation_fit_confidence_scale(
fit : DegradationFit,
confidence : Double,
) -> Double {
if confidence <= 0.0 || confidence >= 1.0 {
abort("confidence must be in (0, 1)")
}
let z = standard_normal_inv(0.5 + confidence / 2.0)
z * fit.rmse
}
///|
pub fn degradation_fit_interval(
fit : DegradationFit,
time : Double,
confidence : Double,
) -> MetricEstimate {
let estimate = degradation_fit_predict(fit, time)
let spread = degradation_fit_confidence_scale(fit, confidence)
metric_estimate(
estimate~,
lower=(estimate - spread).max(0.0),
upper=estimate + spread,
confidence_level=confidence,
)
}
///|
pub fn degradation_fit_grid(
fit : DegradationFit,
times : Array[Double],
) -> Array[Double] {
times.map(time => degradation_fit_predict(fit, time))
}
///|
pub fn degradation_fit_derivative_grid(
fit : DegradationFit,
times : Array[Double],
) -> Array[Double] {
times.map(time => degradation_fit_derivative(fit, time))
}
///|
pub fn degradation_fit_crossing_time(
fit : DegradationFit,
limit : Double,
lower : Double,
upper : Double,
) -> Double {
if upper <= lower {
abort("upper time must exceed lower time")
}
let increasing = degradation_fit_predict(fit, upper) >
degradation_fit_predict(fit, lower)
let mut left = lower
let mut right = upper
for _ in 0..<80 {
let middle = (left + right) / 2.0
let value = degradation_fit_predict(fit, middle)
if (increasing && value < limit) || (!increasing && value > limit) {
left = middle
} else {
right = middle
}
}
(left + right) / 2.0
}
///|
pub fn degradation_remaining_life(
track : DegradationTrack,
fit : DegradationFit,
current_time : Double,
) -> Double {
let limit = degradation_limit(track)
let crossing = degradation_fit_crossing_time(
fit,
limit,
current_time,
current_time + degradation_duration(track).max(1.0) * 100.0,
)
(crossing - current_time).max(0.0)
}
///|
pub fn degradation_rul_interval(
track : DegradationTrack,
fit : DegradationFit,
current_time : Double,
confidence : Double,
) -> MetricEstimate {
let rul = degradation_remaining_life(track, fit, current_time)
let spread = degradation_fit_confidence_scale(fit, confidence) /
degradation_fit_derivative(fit, current_time).abs().max(1.0e-12)
metric_estimate(
estimate=rul,
lower=(rul - spread).max(0.0),
upper=rul + spread,
confidence_level=confidence,
)
}
///|
pub struct DegradationFeatureSet {
mean : Double
standard_deviation : Double
slope : Double
acceleration : Double
monotonicity : Double
recent_slope : Double
threshold_margin : Double
out_of_bounds_fraction : Double
volatility : Double
health_index : Double
}
///|
pub fn degradation_features(track : DegradationTrack) -> DegradationFeatureSet {
let fit = degradation_fit_linear(track)
let last_time = degradation_last_time(track)
let margin = degradation_normalized_margin(
track,
degradation_last_value(track),
)
let volatility = if degradation_mean(track) == 0.0 {
degradation_standard_deviation(track)
} else {
degradation_standard_deviation(track) / degradation_mean(track).abs()
}
{
mean: degradation_mean(track),
standard_deviation: degradation_standard_deviation(track),
slope: degradation_fit_derivative(fit, last_time),
acceleration: degradation_fit_acceleration(fit, last_time),
monotonicity: degradation_monotonicity(track),
recent_slope: degradation_recent_rate(
track,
degradation_count(track).min(5).max(2),
),
threshold_margin: margin,
out_of_bounds_fraction: degradation_out_of_bounds_fraction(track),
volatility,
health_index: degradation_health_index(track),
}
}
///|
pub fn degradation_health_index(track : DegradationTrack) -> Double {
let margin = degradation_normalized_margin(
track,
degradation_last_value(track),
)
let monotonicity = degradation_monotonicity(track)
let volatility = degradation_standard_deviation(track) /
degradation_range(track).max(1.0e-12)
let direction_score = if degradation_directional_rate(track) >= 0.0 {
1.0
} else {
0.0
}
(0.55 * margin.max(0.0).min(1.0) +
0.30 * monotonicity +
0.15 * (1.0 - volatility).max(0.0) * direction_score).min(1.0)
}
///|
pub fn degradation_health_label(index : Double) -> String {
if index >= 0.85 {
"healthy"
} else if index >= 0.65 {
"watch"
} else if index >= 0.40 {
"degraded"
} else {
"critical"
}
}
///|
pub fn degradation_health_gap(
track : DegradationTrack,
target : Double,
) -> Double {
(target - degradation_health_index(track)).max(0.0)
}
///|
pub fn degradation_warning_score(
track : DegradationTrack,
warning_fraction : Double,
) -> Double {
if degradation_is_beyond_limit(track, degradation_last_value(track)) {
1.0
} else if degradation_is_warning(
track,
degradation_last_value(track),
warning_fraction,
) {
0.75
} else {
0.0
}
}
///|
pub fn degradation_alert_level(track : DegradationTrack) -> Int {
let index = degradation_health_index(track)
if index < 0.4 {
3
} else if index < 0.65 {
2
} else if index < 0.85 {
1
} else {
0
}
}
///|
pub fn degradation_change_points(
track : DegradationTrack,
threshold : Double,
) -> Array[Int] {
if threshold < 0.0 {
abort("change threshold must be non-negative")
}
let result = []
if degradation_count(track) >= 3 {
for i in 1..<(degradation_count(track) - 1) {
let before = track.observations[i].value - track.observations[i - 1].value
let after = track.observations[i + 1].value - track.observations[i].value
if (after - before).abs() >= threshold {
result.push(i)
}
}
}
result
}
///|
pub fn degradation_residual_autocorrelation(
_track : DegradationTrack,
fit : DegradationFit,
lag : Int,
) -> Double {
autocorrelation_lag(fit.residuals, lag)
}
///|
pub fn degradation_residual_normalized(fit : DegradationFit) -> Array[Double] {
let center = mean(fit.residuals)
let scale = variance(fit.residuals, unbiased=false).sqrt().max(1.0e-12)
fit.residuals.map(value => (value - center) / scale)
}
///|
pub fn degradation_residual_abs_mean(fit : DegradationFit) -> Double {
mean(fit.residuals.map(value => value.abs()))
}
///|
pub fn degradation_residual_bias(fit : DegradationFit) -> Double {
mean(fit.residuals)
}
///|
pub fn degradation_residual_max(fit : DegradationFit) -> Double {
max_value(fit.residuals.map(value => value.abs()))
}
///|
pub fn degradation_fit_score(fit : DegradationFit) -> Double {
fit.r_squared.max(0.0).min(1.0) * @math.exp(-fit.rmse.abs())
}
///|
pub fn degradation_fit_is_adequate(
fit : DegradationFit,
maximum_rmse : Double,
minimum_r_squared : Double,
) -> Bool {
fit.rmse <= maximum_rmse &&
fit.r_squared >= minimum_r_squared &&
fit.converged
}
///|
pub fn degradation_fit_rank(
fits : Array[DegradationFit],
) -> Array[DegradationFit] {
let result = fits.copy()
result.sort_by((left, right) => {
let left_score = degradation_fit_score(left)
let right_score = degradation_fit_score(right)
if left_score > right_score {
-1
} else if left_score < right_score {
1
} else {
0
}
})
result
}
///|
pub fn degradation_track_checksum(track : DegradationTrack) -> Double {
track.observations.fold(init=0.0, (total, observation) => {
total + observation.time + observation.value + observation.weight
})
}
///|
pub fn degradation_fit_checksum(fit : DegradationFit) -> Double {
fit.coefficients.fold(init=fit.rmse + fit.r_squared, (total, value) => {
total + value
}) +
fit.residuals.fold(init=0.0, (total, value) => total + value)
}
///|
pub fn degradation_track_resample(
track : DegradationTrack,
step : Double,
) -> DegradationTrack {
if step <= 0.0 {
abort("resampling step must be positive")
}
let start = degradation_first_time(track)
let stop = degradation_last_time(track)
let count = ((stop - start) / step).floor().to_int() + 1
let values = Array::makei(count, i => {
let time = start + i.to_double() * step
let mut nearest = track.observations[0]
let mut distance = (nearest.time - time).abs()
for observation in track.observations[1:] {
let candidate_distance = (observation.time - time).abs()
if candidate_distance < distance {
nearest = observation
distance = candidate_distance
}
}
degradation_observation(time, nearest.value, nearest.weight)
})
degradation_track(
track.name,
values,
track.lower_limit,
track.upper_limit,
track.direction,
)
}
///|
pub fn degradation_track_window(
track : DegradationTrack,
start : Double,
stop : Double,
) -> DegradationTrack {
if stop <= start {
abort("window stop must exceed start")
}
degradation_track(
track.name,
track.observations.filter(observation => {
observation.time >= start && observation.time <= stop
}),
track.lower_limit,
track.upper_limit,
track.direction,
)
}
///|
pub fn degradation_track_append(
track : DegradationTrack,
observation : DegradationObservation,
) -> DegradationTrack {
degradation_track(
track.name,
track.observations + [observation],
track.lower_limit,
track.upper_limit,
track.direction,
)
}
///|
pub fn degradation_track_shift(
track : DegradationTrack,
time_shift : Double,
value_shift : Double,
) -> DegradationTrack {
degradation_track(
track.name,
track.observations.map(observation => {
degradation_observation(
observation.time + time_shift,
observation.value + value_shift,
observation.weight,
)
}),
track.lower_limit + value_shift,
track.upper_limit + value_shift,
track.direction,
)
}
///|
pub fn degradation_track_scale(
track : DegradationTrack,
value_scale : Double,
) -> DegradationTrack {
if value_scale <= 0.0 {
abort("value scale must be positive")
}
degradation_track(
track.name,
track.observations.map(observation => {
degradation_observation(
observation.time,
observation.value * value_scale,
observation.weight,
)
}),
track.lower_limit * value_scale,
track.upper_limit * value_scale,
track.direction,
)
}
///|
pub fn degradation_track_merge(
left : DegradationTrack,
right : DegradationTrack,
) -> DegradationTrack {
if left.lower_limit != right.lower_limit ||
left.upper_limit != right.upper_limit ||
left.direction != right.direction {
abort("degradation tracks have incompatible limits")
}
degradation_track(
left.name,
left.observations + right.observations,
left.lower_limit,
left.upper_limit,
left.direction,
)
}
///|
pub fn degradation_track_difference(
left : DegradationTrack,
right : DegradationTrack,
) -> DegradationTrack {
if degradation_count(left) != degradation_count(right) {
abort("track lengths must match")
}
degradation_track(
left.name,
Array::makei(degradation_count(left), i => {
degradation_observation(
left.observations[i].time,
left.observations[i].value - right.observations[i].value,
left.observations[i].weight,
)
}),
left.lower_limit - right.upper_limit,
left.upper_limit - right.lower_limit,
left.direction,
)
}
///|
pub fn degradation_track_ratio(
left : DegradationTrack,
right : DegradationTrack,
) -> DegradationTrack {
if degradation_count(left) != degradation_count(right) {
abort("track lengths must match")
}
degradation_track(
left.name,
Array::makei(degradation_count(left), i => {
degradation_observation(
left.observations[i].time,
left.observations[i].value / right.observations[i].value.max(1.0e-300),
left.observations[i].weight,
)
}),
0.0,
1.0e300,
left.direction,
)
}
///|
pub struct DegradationFleetSummary {
track_count : Int
healthy_count : Int
warning_count : Int
critical_count : Int
average_health : Double
minimum_health : Double
maximum_risk : Double
}
///|
pub fn degradation_fleet_summary(
tracks : Array[DegradationTrack],
warning_fraction : Double,
) -> DegradationFleetSummary {
if tracks.is_empty() {
abort("tracks must not be empty")
}
let health = tracks.map(track => degradation_health_index(track))
let warning = tracks.fold(init=0, (count, track) => {
if degradation_is_warning(
track,
degradation_last_value(track),
warning_fraction,
) {
count + 1
} else {
count
}
})
let critical = health.fold(init=0, (count, value) => {
if value < 0.4 {
count + 1
} else {
count
}
})
{
track_count: tracks.length(),
healthy_count: health.fold(init=0, (count, value) => {
if value >= 0.85 {
count + 1
} else {
count
}
}),
warning_count: warning,
critical_count: critical,
average_health: mean(health),
minimum_health: min_value(health),
maximum_risk: 1.0 - min_value(health),
}
}
///|
pub fn degradation_fleet_health(
tracks : Array[DegradationTrack],
) -> Array[Double] {
tracks.map(track => degradation_health_index(track))
}
///|
pub fn degradation_fleet_risk(
tracks : Array[DegradationTrack],
) -> Array[Double] {
tracks.map(track => 1.0 - degradation_health_index(track))
}
///|
pub fn degradation_fleet_rank(
tracks : Array[DegradationTrack],
) -> Array[DegradationTrack] {
let result = tracks.copy()
result.sort_by((left, right) => {
let left_risk = 1.0 - degradation_health_index(left)
let right_risk = 1.0 - degradation_health_index(right)
if left_risk > right_risk {
-1
} else if left_risk < right_risk {
1
} else {
0
}
})
result
}
///|
pub fn degradation_fleet_priorities(
tracks : Array[DegradationTrack],
budget : Int,
) -> Array[DegradationTrack] {
if budget < 0 {
abort("budget must be non-negative")
}
let ranked = degradation_fleet_rank(tracks)
Array::makei(budget.min(ranked.length()), i => ranked[i])
}
///|
pub fn degradation_fleet_checksum(tracks : Array[DegradationTrack]) -> Double {
tracks.fold(init=0.0, (total, track) => {
total + degradation_track_checksum(track) + degradation_health_index(track)
})
}