Functions/Private/Merge-HvDRSTrendSnapshot.ps1

function Merge-HvDRSTrendSnapshot {
    <#
    .SYNOPSIS
        Smooths a cluster snapshot's load-sensitive fields over a rolling window of
        recent passes, so a single transient spike doesn't trigger a migration.

    .DESCRIPTION
        Persists a bounded history of prior snapshots' smoothable fields to a JSON
        file at -HistoryPath, appends the current snapshot, trims to -WindowSize
        entries, and returns a new snapshot where Node.CpuUtilization,
        Node.NetworkUtilization, VM.CpuUtilization, and VM.MemoryPressure are
        replaced by their average over the window.

        Capacity fields (TotalMemoryMB, AvailableMemoryMB, LogicalProcessorCount)
        and identity fields are passed through unchanged from the current (real)
        snapshot — they gate destination fit right now, not a multi-pass trend.

        A VM or node absent from some prior entries (added/removed between passes)
        is simply averaged over however many entries it does appear in; there is no
        synthetic zero-fill. A missing or corrupt history file bootstraps to a
        single-entry window (the current snapshot only), mirroring the fail-soft
        load pattern used by Get-AffinityRuleSet.

        Supports -WhatIf/-Confirm: a -WhatIf call still reads history and returns
        the trended snapshot (so the preview reflects real smoothing), but does not
        persist the new entry. Without this, a -WhatIf preview pass — which never
        migrates anything — would still write to the shared history file used by
        real passes.

    .OUTPUTS
        A PSCustomObject shaped exactly like Get-ClusterSnapshot's output
        (ClusterName, Timestamp, Nodes, VMs) so it is a drop-in replacement
        wherever a snapshot is consumed.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [PSCustomObject]$Snapshot,

        [Parameter(Mandatory)]
        [string]$HistoryPath,

        [ValidateRange(1, 10)]
        [int]$WindowSize = 3
    )

    # ── Load prior history (fail-soft: missing/corrupt file => fresh window) ──────
    $history = [System.Collections.Generic.List[PSCustomObject]]::new()
    if (Test-Path -LiteralPath $HistoryPath) {
        try {
            $data = Get-Content -LiteralPath $HistoryPath -Raw -ErrorAction Stop | ConvertFrom-Json
            foreach ($entry in @($data.Entries)) { $history.Add($entry) }
        } catch {
            Write-Warning "Could not load HVDRS trend history from '$HistoryPath': $_. Starting a fresh window."
        }
    }

    # ── Append this pass's smoothable fields only ─────────────────────────────────
    $currentEntry = [PSCustomObject]@{
        Nodes = @($Snapshot.Nodes | ForEach-Object {
            [PSCustomObject]@{
                NodeName           = $_.NodeName
                CpuUtilization     = [double]$_.CpuUtilization
                NetworkUtilization = [double]$_.NetworkUtilization
            }
        })
        VMs = @($Snapshot.VMs | ForEach-Object {
            [PSCustomObject]@{
                VMName         = $_.VMName
                CpuUtilization = [double]$_.CpuUtilization
                MemoryPressure = [double]$_.MemoryPressure
            }
        })
    }

    $history.Add($currentEntry)
    while ($history.Count -gt $WindowSize) { $history.RemoveAt(0) }

    # ── Persist the trimmed window ────────────────────────────────────────────────
    # Skipped under -WhatIf: a preview pass never migrates anything, so it must
    # not advance the shared history a real pass relies on.
    if ($PSCmdlet.ShouldProcess($HistoryPath, 'Update HVDRS trend history')) {
        $dir = Split-Path -LiteralPath $HistoryPath
        if ($dir -and -not (Test-Path -LiteralPath $dir)) {
            New-Item -Path $dir -ItemType Directory -Force | Out-Null
        }
        [PSCustomObject]@{
            Version = '1.0'
            Entries = $history.ToArray()
        } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $HistoryPath -Encoding UTF8
    }

    # ── Build the trended (averaged) snapshot ─────────────────────────────────────
    $trendedNodes = foreach ($node in $Snapshot.Nodes) {
        $samples = @($history | ForEach-Object { $_.Nodes | Where-Object { $_.NodeName -eq $node.NodeName } })

        $avgCpu = if ($samples.Count -gt 0) { ($samples | Measure-Object -Property CpuUtilization     -Average).Average } else { $node.CpuUtilization }
        $avgNet = if ($samples.Count -gt 0) { ($samples | Measure-Object -Property NetworkUtilization -Average).Average } else { $node.NetworkUtilization }

        [PSCustomObject]@{
            NodeName              = $node.NodeName
            CpuUtilization        = [Math]::Round($avgCpu, 1)
            TotalMemoryMB         = $node.TotalMemoryMB
            AvailableMemoryMB     = $node.AvailableMemoryMB
            UsedMemoryMB          = $node.UsedMemoryMB
            LogicalProcessorCount = $node.LogicalProcessorCount
            NetworkUtilization    = [Math]::Round($avgNet, 1)
            VMs                   = $node.VMs
        }
    }

    $trendedVMs = foreach ($vm in $Snapshot.VMs) {
        $samples = @($history | ForEach-Object { $_.VMs | Where-Object { $_.VMName -eq $vm.VMName } })

        $avgCpu      = if ($samples.Count -gt 0) { ($samples | Measure-Object -Property CpuUtilization -Average).Average } else { $vm.CpuUtilization }
        $avgPressure = if ($samples.Count -gt 0) { ($samples | Measure-Object -Property MemoryPressure -Average).Average } else { $vm.MemoryPressure }

        [PSCustomObject]@{
            VMName               = $vm.VMName
            VMId                 = $vm.VMId
            CpuUtilization       = [Math]::Round($avgCpu, 1)
            ProcessorCount       = $vm.ProcessorCount
            MemoryAssignedMB     = $vm.MemoryAssignedMB
            MemoryDemandMB       = $vm.MemoryDemandMB
            DynamicMemoryEnabled = $vm.DynamicMemoryEnabled
            MemoryPressure       = [Math]::Round($avgPressure, 1)
            HostNode             = $vm.HostNode
        }
    }

    [PSCustomObject]@{
        ClusterName = $Snapshot.ClusterName
        Timestamp   = $Snapshot.Timestamp
        Nodes       = $trendedNodes
        VMs         = $trendedVMs
    }
}

function Reset-HvDRSTrendHistory {
    <#
    .SYNOPSIS
        Clears HVDRS's trend-smoothing history for a cluster after migrations have
        actually executed.

    .DESCRIPTION
        Merge-HvDRSTrendSnapshot's rolling average is a poor fit for the first few
        passes after a real migration: it keeps averaging in pre-migration samples
        of the nodes/VMs that just moved, so a node that was just drained (or that
        just received load) can look artificially busy or idle for up to
        -TrendWindow more passes — potentially undoing, or delaying recognition of,
        the very rebalancing that just happened.

        Called by Invoke-HvDRS right after a pass that actually executed at least
        one migration (never under -WhatIf/-RecommendOnly/maintenance, where
        nothing moved and the existing history is still valid). The next pass
        bootstraps a fresh single-entry window from the next real snapshot, the
        same fail-soft behavior Merge-HvDRSTrendSnapshot already uses for a
        missing/corrupt file.

        A missing history file is a no-op, not an error — there is nothing to
        reset. A failure to delete an existing file is reported with
        Write-Warning rather than thrown, since a stale trend file only degrades
        smoothing for a few passes; it must never fail an otherwise-successful
        DRS pass.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string]$HistoryPath
    )

    if (-not (Test-Path -LiteralPath $HistoryPath)) { return }

    if ($PSCmdlet.ShouldProcess($HistoryPath, 'Reset HVDRS trend history after executed migrations')) {
        try {
            Remove-Item -LiteralPath $HistoryPath -Force -ErrorAction Stop
        } catch {
            Write-Warning "Could not reset HVDRS trend history at '$HistoryPath' after migrating: $_"
        }
    }
}