# /// script
# requires-python = ">=3.10"
# ///
# Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
# https://thedataengineering.com/lab-code/LICENSE.txt
"""Autoscaler flapping and the effect of delay.

An autoscaler targets 60% utilization on a 1-minute average, and new
machines arrive d minutes after they are ordered. Constant demand; fleet size
over time for d = 0, 2 and 5 minutes; then separate up and down thresholds
with a slow scale-down.
"""
import argparse, json, math

p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("--demand", type=float, default=600, help="Constant demand, in units one machine serves at full load.")
p.add_argument("--start", type=int, default=400, help="Machines at the start.")
p.add_argument("--minutes", type=int, default=120, help="Simulated minutes.")
p.add_argument("--delays", type=lambda s: [int(v) for v in s.split(",")], default=[0, 2, 5], help="Provisioning delays d, minutes.")
p.add_argument("--json", help="Also write the results to this JSON file.")
args = p.parse_args()
TARGET = 0.6

def run(d, hysteresis):
    """The controller sees only utilization, which cannot exceed 100%: an under-sized fleet hides how much it is short."""
    fleet, pending, rows, history = float(args.start), [], [], []
    for m in range(args.minutes):
        fleet += sum(n for t, n in pending if t == m); pending = [(t, n) for t, n in pending if t > m]
        history.append(min(1.0, args.demand / fleet))
        util = history[-2] if len(history) > 1 else history[-1]          # the average over the last full minute: one minute old
        desired = fleet * util / TARGET
        on_order = sum(n for _, n in pending)
        if hysteresis:
            if util > 0.7: pending.append((m + d, max(0.0, desired - fleet - on_order)))   # counts what is already on order
            elif util < 0.5: fleet -= (fleet - desired) * 0.1                              # slow scale-down
        else:
            if desired > fleet: pending.append((m + d, desired - fleet))                    # ignores what is on order
            else: fleet = desired                                                           # scale-down is immediate
        rows.append({"minute": m, "fleet": fleet, "delay": d, "policy": "hysteresis + slow scale-down" if hysteresis else "single target"})
    tail = [r["fleet"] for r in rows[-40:]]
    return rows, max(tail) - min(tail)

rows, summary = [], []
for d in args.delays:
    r, swing = run(d, False); rows += r
    summary.append({"delay": d, "policy": "single target", "swing": swing})
    print(f"d = {d} min, single target: fleet swings by {swing:6.0f} machines in the last 40 minutes")
r, swing = run(max(args.delays), True); rows += r
summary.append({"delay": max(args.delays), "policy": "hysteresis + slow scale-down", "swing": swing})
print(f"d = {max(args.delays)} min, hysteresis + slow scale-down: swing {swing:.0f}")

if args.json:
    with open(args.json, "w") as f:
        json.dump({"tables": {"fleet": rows, "summary": summary}}, f)
