///|
/// Parallel change load.
///
/// These measurements ignore risk and dependencies: they count the records that
/// cover a minute, which is what a change calendar shows. They answer questions
/// like "how many changes run at 22:00", "when is the plan busiest" and "how
/// long do we keep more than two changes in flight".
///|
/// Highest number of changes that run at the same time.
pub(all) struct PeakConcurrency {
minute : Int
count : Int
}
///|
/// Render as `3 changes at 2026-10-01T22:00`.
pub fn PeakConcurrency::to_line(self : PeakConcurrency) -> String {
self.count.to_string() + " changes at " + format_instant(self.minute)
}
///|
/// One sample of a load profile.
pub(all) struct LoadPoint {
start : Int
count : Int
}
///|
/// Render as `2026-10-01T22:00 load 3`.
pub fn LoadPoint::to_line(self : LoadPoint) -> String {
format_instant(self.start) + " load " + self.count.to_string()
}
///|
/// Number of changes covering the minute.
///
/// Freezes are not changes, so they never raise this count.
pub fn concurrency_at(plan : Plan, minute : Int) -> Int {
let mut count = 0
for change in plan.changes() {
if minute >= change.start && minute < change.end {
count = count + 1
}
}
count
}
///|
/// Number of changes of one service covering the minute. An empty service name
/// matches the changes that have no service.
pub fn service_concurrency_at(
plan : Plan,
service : String,
minute : Int,
) -> Int {
let mut count = 0
for change in plan.changes() {
if change.service == service &&
minute >= change.start &&
minute < change.end {
count = count + 1
}
}
count
}
///|
/// Highest number of simultaneous changes, or `None` for a plan without
/// changes.
///
/// The maximum can only be reached at the start of some change, so the search
/// walks the start minutes and breaks ties towards the earliest one. The answer
/// is therefore deterministic.
pub fn peak_concurrency(plan : Plan) -> PeakConcurrency? {
let changes = plan.changes()
if changes.length() == 0 {
return None
}
let mut best : PeakConcurrency? = None
for change in changes {
let count = concurrency_at(plan, change.start)
match best {
None => best = Some({ minute: change.start, count, })
Some(current) =>
if count > current.count {
best = Some({ minute: change.start, count, })
}
}
}
best
}
///|
/// Intervals during which at least `min_count` changes run at the same time,
/// merged and ordered.
///
/// `min_count` of `1` therefore returns the blocked intervals of the changes,
/// and a higher value narrows that down to the crowded part of the plan.
pub fn busiest_intervals(plan : Plan, min_count : Int) -> Array[Slot] {
let boundaries : Array[Int] = []
for change in plan.changes() {
push_unique_int(boundaries, change.start)
push_unique_int(boundaries, change.end)
}
let ordered = sorted_ints(boundaries)
let crowded : Array[Slot] = []
let mut index = 0
while index + 1 < ordered.length() {
let from = ordered[index]
let to = ordered[index + 1]
if to > from && concurrency_at(plan, from) >= min_count {
crowded.push({ start: from, end: to, })
}
index = index + 1
}
merge_slots(crowded)
}
///|
/// Highest number of changes running at any minute inside the interval.
pub fn max_concurrency_in(plan : Plan, slot : Slot) -> Int {
let mut best = 0
for change in plan.changes() {
let start = if change.start > slot.start {
change.start
} else {
slot.start
}
if start < slot.end && start < change.end {
let count = concurrency_at(plan, start)
if count > best {
best = count
}
}
}
best
}
///|
/// Sample the number of running changes every `step_minutes` from `from` while
/// the sample stays before `to`.
///
/// A step of zero or less produces no samples. Sampling stops after 10000
/// points so that a long horizon with a tiny step cannot build an unbounded
/// array; the caller keeps control of the cost by choosing the step.
pub fn load_profile(
plan : Plan,
from : Int,
to : Int,
step_minutes : Int,
) -> Array[LoadPoint] {
let points : Array[LoadPoint] = []
if step_minutes <= 0 || to <= from {
return points
}
let mut minute = from
while minute < to && points.length() < 10000 {
points.push({ start: minute, count: concurrency_at(plan, minute), })
minute = minute + step_minutes
}
points
}
///|
/// Total length of every change, counting minutes shared by several changes
/// once per change.
///
/// This is the workload the plan asks for, while `blocked_minutes` is the
/// calendar time it occupies.
pub fn change_minutes(plan : Plan) -> Int {
let mut total = 0
for change in plan.changes() {
total = total + change.duration()
}
total
}
///|
/// Total length of every freeze, counting shared minutes once per freeze.
pub fn freeze_minutes(plan : Plan) -> Int {
let mut total = 0
for freeze in plan.freezes() {
total = total + freeze.duration()
}
total
}