///|
/// Streaming forecasting models for operational demand and metric series.
///
/// The models expose both point forecasts and uncertainty bands. They update
/// in O(1) or O(period) per observation, which keeps them useful for service
/// health metrics, inventory signals, and low-latency baseline prediction.
pub struct ForecastPoint {
timestamp : Int64
value : Double
lower : Double
upper : Double
}
///|
pub fn ForecastPoint::new(
timestamp : Int64,
value : Double,
lower : Double,
upper : Double,
) -> ForecastPoint {
{ timestamp, value, lower, upper }
}
///|
pub fn ForecastPoint::timestamp(self : ForecastPoint) -> Int64 {
self.timestamp
}
///|
pub fn ForecastPoint::value(self : ForecastPoint) -> Double {
self.value
}
///|
pub fn ForecastPoint::lower(self : ForecastPoint) -> Double {
self.lower
}
///|
pub fn ForecastPoint::upper(self : ForecastPoint) -> Double {
self.upper
}
///|
pub struct OnlineHoltWinters {
period : Int
alpha : Double
beta : Double
gamma : Double
mut level : Double
mut trend : Double
seasonals : Array[Double]
errors : OnlineQuantileSketch
mut steps : Int
mut initialized : Bool
}
///|
pub fn OnlineHoltWinters::new(
period : Int,
alpha? : Double = 0.2,
beta? : Double = 0.05,
gamma? : Double = 0.1,
error_capacity? : Int = 256,
) -> OnlineHoltWinters {
let safe_period = if period < 1 { 1 } else { period }
{
period: safe_period,
alpha: clamp(alpha, 0.001, 1.0),
beta: clamp(beta, 0.001, 1.0),
gamma: clamp(gamma, 0.001, 1.0),
level: 0.0,
trend: 0.0,
seasonals: Array::make(safe_period, 0.0),
errors: OnlineQuantileSketch::new(capacity=error_capacity),
steps: 0,
initialized: false,
}
}
///|
pub fn OnlineHoltWinters::update(
self : OnlineHoltWinters,
value : Double,
) -> Double {
let observation = if value.is_nan() || value.is_inf() { 0.0 } else { value }
if !self.initialized {
self.level = observation
self.trend = 0.0
self.initialized = true
self.steps = 1
observation
} else {
let index = self.steps % self.period
let previous_level = self.level
let seasonal = self.seasonals[index]
self.level = self.alpha * (observation - seasonal) +
(1.0 - self.alpha) * (self.level + self.trend)
self.trend = self.beta * (self.level - previous_level) +
(1.0 - self.beta) * self.trend
self.seasonals[index] = self.gamma * (observation - self.level) +
(1.0 - self.gamma) * seasonal
let residual = observation - (previous_level + self.trend + seasonal)
self.errors.update(residual.abs())
self.steps += 1
self.level + self.trend + seasonal
}
}
///|
pub fn OnlineHoltWinters::predict(
self : OnlineHoltWinters,
horizon : Int,
start_timestamp? : Int64 = 0L,
step? : Int64 = 1L,
) -> Array[ForecastPoint] {
let count = if horizon < 0 { 0 } else { horizon }
let result : Array[ForecastPoint] = []
let spread = self.errors.quantile(0.9).unwrap_or(0.0)
for i in 0.. Double {
self.level
}
///|
pub fn OnlineHoltWinters::trend(self : OnlineHoltWinters) -> Double {
self.trend
}
///|
pub fn OnlineHoltWinters::period(self : OnlineHoltWinters) -> Int {
self.period
}
///|
pub fn OnlineHoltWinters::steps(self : OnlineHoltWinters) -> Int {
self.steps
}
///|
pub fn OnlineHoltWinters::residual_scale(self : OnlineHoltWinters) -> Double {
self.errors.quantile(0.5).unwrap_or(0.0)
}
///|
pub fn OnlineHoltWinters::reset(self : OnlineHoltWinters) -> Unit {
self.level = 0.0
self.trend = 0.0
self.seasonals.fill(0.0)
self.errors.reset()
self.steps = 0
self.initialized = false
}
///|
/// Croston-style intermittent demand forecaster. It separately smooths
/// non-zero demand size and the interval between non-zero arrivals.
pub struct IntermittentDemandForecaster {
alpha : Double
mut demand : Double
mut interval : Double
mut since_demand : Int
mut observations : Int
mut nonzero : Int
}
///|
pub fn IntermittentDemandForecaster::new(
alpha? : Double = 0.1,
) -> IntermittentDemandForecaster {
{
alpha: clamp(alpha, 0.001, 1.0),
demand: 0.0,
interval: 1.0,
since_demand: 0,
observations: 0,
nonzero: 0,
}
}
///|
pub fn IntermittentDemandForecaster::update(
self : IntermittentDemandForecaster,
value : Double,
) -> Double {
let observation = if value.is_nan() || value.is_inf() || value < 0.0 {
0.0
} else {
value
}
self.observations += 1
self.since_demand += 1
if observation > 0.0 {
if self.nonzero == 0 {
self.demand = observation
self.interval = self.since_demand.to_double()
} else {
self.demand = self.alpha * observation + (1.0 - self.alpha) * self.demand
self.interval = self.alpha * self.since_demand.to_double() +
(1.0 - self.alpha) * self.interval
}
self.since_demand = 0
self.nonzero += 1
}
self.forecast()
}
///|
pub fn IntermittentDemandForecaster::forecast(
self : IntermittentDemandForecaster,
) -> Double {
if self.interval <= 0.0 {
0.0
} else {
self.demand / self.interval
}
}
///|
pub fn IntermittentDemandForecaster::demand_size(
self : IntermittentDemandForecaster,
) -> Double {
self.demand
}
///|
pub fn IntermittentDemandForecaster::interval(
self : IntermittentDemandForecaster,
) -> Double {
self.interval
}
///|
pub fn IntermittentDemandForecaster::observations(
self : IntermittentDemandForecaster,
) -> Int {
self.observations
}
///|
pub fn IntermittentDemandForecaster::nonzero(
self : IntermittentDemandForecaster,
) -> Int {
self.nonzero
}
///|
pub fn IntermittentDemandForecaster::predict(
self : IntermittentDemandForecaster,
horizon : Int,
start_timestamp? : Int64 = 0L,
step? : Int64 = 1L,
) -> Array[ForecastPoint] {
let result : Array[ForecastPoint] = []
let count = if horizon < 0 { 0 } else { horizon }
let value = self.forecast()
for i in 0.. Unit {
self.demand = 0.0
self.interval = 1.0
self.since_demand = 0
self.observations = 0
self.nonzero = 0
}
///|
pub struct SeasonalNaiveBaseline {
period : Int
history : Array[Double]
mut observations : Int
}
///|
pub fn SeasonalNaiveBaseline::new(period : Int) -> SeasonalNaiveBaseline {
{ period: if period < 1 { 1 } else { period }, history: [], observations: 0 }
}
///|
pub fn SeasonalNaiveBaseline::update(
self : SeasonalNaiveBaseline,
value : Double,
) -> Double {
let safe = if value.is_nan() || value.is_inf() { 0.0 } else { value }
let forecast = self.forecast()
self.history.push(safe)
if self.history.length() > self.period {
let _ = self.history.remove(0)
}
self.observations += 1
forecast
}
///|
pub fn SeasonalNaiveBaseline::forecast(self : SeasonalNaiveBaseline) -> Double {
match self.history.last() {
Some(value) => value
None => 0.0
}
}
///|
pub fn SeasonalNaiveBaseline::predict(
self : SeasonalNaiveBaseline,
horizon : Int,
start_timestamp? : Int64 = 0L,
step? : Int64 = 1L,
) -> Array[ForecastPoint] {
let result : Array[ForecastPoint] = []
let count = if horizon < 0 { 0 } else { horizon }
let value = self.forecast()
for i in 0.. Int {
self.period
}
///|
pub fn SeasonalNaiveBaseline::observations(self : SeasonalNaiveBaseline) -> Int {
self.observations
}
///|
pub fn SeasonalNaiveBaseline::history(
self : SeasonalNaiveBaseline,
) -> Array[Double] {
copy_vector(self.history)
}
///|
pub fn SeasonalNaiveBaseline::reset(self : SeasonalNaiveBaseline) -> Unit {
self.history.clear()
self.observations = 0
}
///|
pub struct ForecastErrorTracker {
mut observations : Int
mut absolute_error : Double
mut squared_error : Double
mut percentage_error : Double
mut bias : Double
mut underestimates : Int
mut overestimates : Int
mut worst_error : Double
}
///|
pub fn ForecastErrorTracker::new() -> ForecastErrorTracker {
{
observations: 0,
absolute_error: 0.0,
squared_error: 0.0,
percentage_error: 0.0,
bias: 0.0,
underestimates: 0,
overestimates: 0,
worst_error: 0.0,
}
}
///|
pub fn ForecastErrorTracker::observe(
self : ForecastErrorTracker,
actual : Double,
forecast : Double,
) -> Unit {
if actual.is_nan() ||
forecast.is_nan() ||
actual.is_inf() ||
forecast.is_inf() {
()
} else {
let error = actual - forecast
let absolute = error.abs()
self.observations += 1
self.absolute_error += absolute
self.squared_error += error * error
self.bias += error
if actual.abs() > 1.0e-12 {
self.percentage_error += absolute / actual.abs()
}
if error >= 0.0 {
self.underestimates += 1
} else {
self.overestimates += 1
}
if absolute > self.worst_error {
self.worst_error = absolute
}
}
}
///|
pub fn ForecastErrorTracker::observations(self : ForecastErrorTracker) -> Int {
self.observations
}
///|
pub fn ForecastErrorTracker::mae(self : ForecastErrorTracker) -> Double {
if self.observations == 0 {
0.0
} else {
self.absolute_error / self.observations.to_double()
}
}
///|
pub fn ForecastErrorTracker::rmse(self : ForecastErrorTracker) -> Double {
if self.observations == 0 {
0.0
} else {
(self.squared_error / self.observations.to_double()).sqrt()
}
}
///|
pub fn ForecastErrorTracker::mape(self : ForecastErrorTracker) -> Double {
if self.observations == 0 {
0.0
} else {
self.percentage_error / self.observations.to_double()
}
}
///|
pub fn ForecastErrorTracker::bias(self : ForecastErrorTracker) -> Double {
if self.observations == 0 {
0.0
} else {
self.bias / self.observations.to_double()
}
}
///|
pub fn ForecastErrorTracker::underestimates(self : ForecastErrorTracker) -> Int {
self.underestimates
}
///|
pub fn ForecastErrorTracker::overestimates(self : ForecastErrorTracker) -> Int {
self.overestimates
}
///|
pub fn ForecastErrorTracker::worst_error(self : ForecastErrorTracker) -> Double {
self.worst_error
}
///|
pub fn ForecastErrorTracker::reset(self : ForecastErrorTracker) -> Unit {
self.observations = 0
self.absolute_error = 0.0
self.squared_error = 0.0
self.percentage_error = 0.0
self.bias = 0.0
self.underestimates = 0
self.overestimates = 0
self.worst_error = 0.0
}
///|
pub struct ForecastBlendModel {
weights : Map[String, Double]
forecasts : Map[String, Array[ForecastPoint]]
mut updates : Int
}
///|
pub fn ForecastBlendModel::new() -> ForecastBlendModel {
{ weights: Map([]), forecasts: Map([]), updates: 0 }
}
///|
pub fn ForecastBlendModel::set_weight(
self : ForecastBlendModel,
model : String,
weight : Double,
) -> Bool {
if model == "" {
false
} else {
self.weights[model] = if weight < 0.0 { 0.0 } else { weight }
true
}
}
///|
pub fn ForecastBlendModel::observe(
self : ForecastBlendModel,
model : String,
points : Array[ForecastPoint],
) -> Bool {
if model == "" {
false
} else {
self.forecasts[model] = points.map(point => point)
self.updates += 1
true
}
}
///|
pub fn ForecastBlendModel::predict(
self : ForecastBlendModel,
) -> Array[ForecastPoint] {
if self.forecasts.is_empty() {
[]
} else {
let first = self.forecasts.values().next()
match first {
None => []
Some(points) => {
let result = Array::makei(points.length(), i => {
ForecastPoint::new(points[i].timestamp(), 0.0, 0.0, 0.0)
})
let weight_sum = Ref(0.0)
self.forecasts.each((model, values) => {
let weight = self.weights.get(model).unwrap_or(1.0)
weight_sum.val += weight
let limit = if values.length() < result.length() {
values.length()
} else {
result.length()
}
for i in 0.. 0.0 {
for i in 0.. Double {
self.weights.get(model).unwrap_or(0.0)
}
///|
pub fn ForecastBlendModel::models(self : ForecastBlendModel) -> Array[String] {
let result : Array[String] = []
self.forecasts.each((model, _) => result.push(model))
result
}
///|
pub fn ForecastBlendModel::updates(self : ForecastBlendModel) -> Int {
self.updates
}
///|
pub fn ForecastBlendModel::reset(self : ForecastBlendModel) -> Unit {
self.forecasts.clear()
self.weights.clear()
self.updates = 0
}