Private/Aggregation.ps1

#Requires -Version 5.1

function Read-SMBeatSampleFiles {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot,
        [datetime]$StartUtc,
        [datetime]$EndUtc
    )

    $dir = Get-SMBeatSampleDirectory -DataRoot $DataRoot
    $records = New-Object System.Collections.Generic.List[object]
    if (-not (Test-Path -LiteralPath $dir)) {
        return @()
    }

    $useNative = Initialize-SMBeatSampleJsonNative
    $startDay = $StartUtc.Date.AddDays(-1)
    $endDay = $EndUtc.Date.AddDays(1)
    $files = @(Get-ChildItem -LiteralPath $dir -Filter '*.jsonl' -ErrorAction SilentlyContinue | Sort-Object Name)
    foreach ($file in $files) {
        $dayPart = [System.IO.Path]::GetFileNameWithoutExtension($file.Name)
        $fileDay = [datetime]::MinValue
        if (-not [datetime]::TryParseExact($dayPart, 'yyyy-MM-dd', $script:SMBeatInvariant, [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$fileDay)) {
            continue
        }
        if ($fileDay -lt $startDay -or $fileDay -gt $endDay) {
            continue
        }

        if ($useNative) {
            $batch = [SMBeat.Native.SampleJson]::ReadFile($file.FullName, $StartUtc, $EndUtc)
            foreach ($rec in $batch) {
                [void]$records.Add($rec)
            }
            continue
        }

        foreach ($line in [System.IO.File]::ReadLines($file.FullName, $script:SMBeatUtf8NoBom)) {
            if ([string]::IsNullOrWhiteSpace($line)) {
                continue
            }
            $obj = ConvertFrom-SMBeatJson -Json $line
            $ts = ConvertFrom-SMBeatUtcString -Value $obj.ts
            if ($ts -lt $StartUtc -or $ts -ge $EndUtc) {
                continue
            }
            [void]$records.Add($obj)
        }
    }

    return $records.ToArray()
}

function Read-SMBeatSampleWindows {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot,
        [datetime]$PrevStart,
        [datetime]$PrevEnd,
        [datetime]$WindowStart,
        [datetime]$WindowEnd
    )

    $dir = Get-SMBeatSampleDirectory -DataRoot $DataRoot
    if ((Initialize-SMBeatSampleJsonNative) -and (Test-Path -LiteralPath $dir)) {
        $pair = [SMBeat.Native.SampleJson]::ReadDirectory($dir, $PrevStart, $PrevEnd, $WindowStart, $WindowEnd)
        return [PSCustomObject]@{
            Previous = @($pair.Previous)
            Current  = @($pair.Current)
        }
    }

    $spanStart = $PrevStart
    if ($WindowStart -lt $spanStart) {
        $spanStart = $WindowStart
    }
    $spanEnd = $WindowEnd
    if ($PrevEnd -gt $spanEnd) {
        $spanEnd = $PrevEnd
    }

    $all = @(Read-SMBeatSampleFiles -DataRoot $DataRoot -StartUtc $spanStart -EndUtc $spanEnd)
    $prev = New-Object System.Collections.Generic.List[object]
    $cur = New-Object System.Collections.Generic.List[object]
    foreach ($rec in $all) {
        $ts = Get-SMBeatRecordUtc -Record $rec
        if ($ts -ge $PrevStart -and $ts -lt $PrevEnd) {
            [void]$prev.Add($rec)
        }
        if ($ts -ge $WindowStart -and $ts -lt $WindowEnd) {
            [void]$cur.Add($rec)
        }
    }

    [PSCustomObject]@{
        Previous = $prev.ToArray()
        Current  = $cur.ToArray()
    }
}

function Get-SMBeatExpectedSampleCount {
    param(
        [datetime]$StartUtc,
        [datetime]$EndUtc,
        [int]$IntervalSec
    )

    if ($IntervalSec -le 0) {
        $IntervalSec = 60
    }

    $seconds = ($EndUtc - $StartUtc).TotalSeconds
    if ($seconds -le 0) {
        return 1
    }

    [math]::Max(1, [int][math]::Floor($seconds / $IntervalSec))
}

function Add-SMBeatAccumulator {
    param(
        [hashtable]$Map,
        [string]$Key,
        [int64]$Read,
        [int64]$Write,
        [int64]$Sent,
        [int64]$Received
    )

    if (-not $Map.ContainsKey($Key)) {
        $Map[$Key] = @{
            Read     = [int64]0
            Write    = [int64]0
            Sent     = [int64]0
            Received = [int64]0
        }
    }

    $Map[$Key].Read += $Read
    $Map[$Key].Write += $Write
    $Map[$Key].Sent += $Sent
    $Map[$Key].Received += $Received
}

function Test-SMBeatVolumeNear {
    param(
        [int64]$Left,
        [int64]$Right,
        [double]$MinRatio = 0.8
    )

    $hi = $Left
    if ($Right -gt $hi) {
        $hi = $Right
    }
    $lo = $Left
    if ($Right -lt $lo) {
        $lo = $Right
    }
    if ($hi -le 0) {
        return $true
    }

    return (([double]$lo / [double]$hi) -ge $MinRatio)
}

function Get-SMBeatEtwRetrievedBytes {
    param(
        [hashtable]$ByUser,
        [int64]$ShareSent
    )

    $userSent = [int64]0
    if ($null -ne $ByUser) {
        foreach ($uk in @($ByUser.Keys)) {
            $userSent += [int64]$ByUser[$uk].Sent
        }
    }
    if ($userSent -gt $ShareSent) {
        return $userSent
    }

    return $ShareSent
}

function ConvertTo-SMBeatNamedTotals {
    param(
        [hashtable]$Map
    )

    $list = New-Object System.Collections.Generic.List[object]
    foreach ($key in $Map.Keys) {
        $v = $Map[$key]
        $sentB = [int64]$v.Sent
        $recvB = [int64]$v.Received
        $list.Add([PSCustomObject]@{
            Name           = $key
            ReadBytes      = [int64]$v.Read
            WriteBytes     = [int64]$v.Write
            SentBytes      = $sentB
            ReceivedBytes  = $recvB
            RetrievedBytes = $sentB
            WrittenBytes   = $recvB
            ReadGB        = (ConvertTo-SMBeatGB -Bytes $v.Read)
            WriteGB       = (ConvertTo-SMBeatGB -Bytes $v.Write)
            SentGB        = (ConvertTo-SMBeatGB -Bytes $sentB)
            ReceivedGB    = (ConvertTo-SMBeatGB -Bytes $recvB)
            RetrievedGB   = (ConvertTo-SMBeatGB -Bytes $sentB)
            WrittenGB     = (ConvertTo-SMBeatGB -Bytes $recvB)
        }) | Out-Null
    }

    $list | Sort-Object -Property @{
        Expression = {
            $outB = [int64]0
            $inB = [int64]0
            if ($_.SentBytes) { $outB = [int64]$_.SentBytes }
            if ($_.ReceivedBytes) { $inB = [int64]$_.ReceivedBytes }
            if ($outB -gt $inB) { $outB } else { $inB }
        }
    } -Descending
}

function New-SMBeatEmptyTotals {
    [PSCustomObject]@{
        ReadBytes     = [int64]0
        WriteBytes    = [int64]0
        SentBytes     = [int64]0
        ReceivedBytes = [int64]0
        NicBytesIn    = [int64]0
        NicBytesOut   = [int64]0
        ReadGB       = 0
        WriteGB      = 0
        SentGB       = 0
        ReceivedGB   = 0
        NicInGB      = 0
        NicOutGB     = 0
        RetrievedBytes = [int64]0
        RetrievedGB  = 0
        WrittenBytes   = [int64]0
        WrittenGB     = 0
        SmbPerfSentBytes     = [int64]0
        SmbPerfReceivedBytes = [int64]0
        SmbPerfSentGB        = 0
        SmbPerfReceivedGB    = 0
    }
}

function ConvertTo-SMBeatTotalsObject {
    param(
        [int64]$Read,
        [int64]$Write,
        [int64]$Sent,
        [int64]$Received,
        [int64]$NicIn,
        [int64]$NicOut,
        [int64]$Retrieved,
        [int64]$Written,
        [int64]$SmbPerfSent = 0,
        [int64]$SmbPerfReceived = 0
    )

    if (-not $PSBoundParameters.ContainsKey('Retrieved')) {
        $Retrieved = $NicOut
    }
    if (-not $PSBoundParameters.ContainsKey('Written')) {
        $Written = $Received
    }

    [PSCustomObject]@{
        ReadBytes      = $Read
        WriteBytes     = $Write
        SentBytes      = $Sent
        ReceivedBytes  = $Received
        NicBytesIn     = $NicIn
        NicBytesOut    = $NicOut
        ReadGB        = (ConvertTo-SMBeatGB -Bytes $Read)
        WriteGB       = (ConvertTo-SMBeatGB -Bytes $Write)
        SentGB        = (ConvertTo-SMBeatGB -Bytes $Sent)
        ReceivedGB    = (ConvertTo-SMBeatGB -Bytes $Received)
        NicInGB       = (ConvertTo-SMBeatGB -Bytes $NicIn)
        NicOutGB      = (ConvertTo-SMBeatGB -Bytes $NicOut)
        RetrievedBytes = $Retrieved
        RetrievedGB   = (ConvertTo-SMBeatGB -Bytes $Retrieved)
        WrittenBytes   = $Written
        WrittenGB     = (ConvertTo-SMBeatGB -Bytes $Written)
        SmbPerfSentBytes     = $SmbPerfSent
        SmbPerfReceivedBytes = $SmbPerfReceived
        SmbPerfSentGB        = (ConvertTo-SMBeatGB -Bytes $SmbPerfSent)
        SmbPerfReceivedGB    = (ConvertTo-SMBeatGB -Bytes $SmbPerfReceived)
    }
}

function Get-SMBeatPeakStats {
    param(
        [object[]]$Hourly,
        [int64]$RetrievedBytes,
        [int64]$WrittenBytes,
        [datetime]$StartUtc,
        [datetime]$EndUtc
    )

    $peakStart = $null
    $peakBytes = [int64]0
    $peakWriteStart = $null
    $peakWriteBytes = [int64]0
    foreach ($row in @($Hourly)) {
        if ($null -eq $row) { continue }
        $b = [int64]0
        if ($row.PSObject.Properties['RetrievedBytes'] -and $row.RetrievedBytes) {
            $b = [int64]$row.RetrievedBytes
        }
        if ($b -gt $peakBytes) {
            $peakBytes = $b
            $peakStart = $row.PeriodStart
        }

        $w = [int64]0
        if ($row.PSObject.Properties['WrittenBytes'] -and $row.WrittenBytes) {
            $w = [int64]$row.WrittenBytes
        }
        elseif ($row.PSObject.Properties['ReceivedBytes'] -and $row.ReceivedBytes) {
            $w = [int64]$row.ReceivedBytes
        }
        if ($w -gt $peakWriteBytes) {
            $peakWriteBytes = $w
            $peakWriteStart = $row.PeriodStart
        }
    }

    $hours = ($EndUtc - $StartUtc).TotalHours
    if ($hours -le 0) {
        $hours = 1
    }

    [PSCustomObject]@{
        PeakStart                  = $peakStart
        PeakRetrievedBytes         = $peakBytes
        PeakRetrievedGB           = (ConvertTo-SMBeatGB -Bytes $peakBytes)
        AverageRetrievedGBPerHour = (ConvertTo-SMBeatGB -Bytes ([int64][math]::Round($RetrievedBytes / $hours)))
        PeakWrittenStart           = $peakWriteStart
        PeakWrittenBytes           = $peakWriteBytes
        PeakWrittenGB             = (ConvertTo-SMBeatGB -Bytes $peakWriteBytes)
        AverageWrittenGBPerHour   = (ConvertTo-SMBeatGB -Bytes ([int64][math]::Round($WrittenBytes / $hours)))
    }
}

function Resolve-SMBeatSeriesBucket {
    param(
        [hashtable]$Map,
        [datetime]$Start
    )

    $key = $Start.ToString('o', $script:SMBeatInvariant)
    if (-not $Map.ContainsKey($key)) {
        $Map[$key] = @{
            Start        = $Start
            Read         = [int64]0
            Write        = [int64]0
            Sent         = [int64]0
            Received     = [int64]0
            ServerRead   = [int64]0
            ServerWrite  = [int64]0
            ServerSent   = [int64]0
            ServerRecv   = [int64]0
            HasShare     = $false
            NicIn        = [int64]0
            NicOut       = [int64]0
            Tcp445Sent   = [int64]0
            Tcp445Recv   = [int64]0
            HasTcp445    = $false
        }
    }

    $Map[$key]
}

function Add-SMBeatSeriesDelta {
    param(
        [hashtable]$Bucket,
        $Record
    )

    $kind = [string]$Record.kind
    if ($kind -eq 'server') {
        $Bucket.ServerRead += [int64]$Record.readBytesDelta
        $Bucket.ServerWrite += [int64]$Record.writeBytesDelta
        $Bucket.ServerSent += [int64]$Record.sentBytesDelta
        $Bucket.ServerRecv += [int64]$Record.receivedBytesDelta
    }
    elseif ($kind -eq 'share') {
        $Bucket.Read += [int64]$Record.readBytesDelta
        $Bucket.Write += [int64]$Record.writeBytesDelta
        $Bucket.Sent += [int64]$Record.sentBytesDelta
        $Bucket.Received += [int64]$Record.receivedBytesDelta
        $Bucket.HasShare = $true
    }
    elseif ($kind -eq 'tcp445') {
        $name = [string]$Record.name
        if ($name -eq '_listen445') {
            $Bucket.Tcp445Sent += [int64]$Record.sentBytesDelta
            $Bucket.Tcp445Recv += [int64]$Record.receivedBytesDelta
            $Bucket.HasTcp445 = $true
        }
    }
    elseif ($kind -eq 'nic') {
        $Bucket.NicIn += [int64]$Record.receivedBytesDelta
        $Bucket.NicOut += [int64]$Record.sentBytesDelta
    }
}

function ConvertTo-SMBeatSeriesRows {
    param(
        [hashtable]$Buckets,
        [string]$Granularity
    )

    $result = New-Object System.Collections.Generic.List[object]
    foreach ($key in ($Buckets.Keys | Sort-Object)) {
        $b = $Buckets[$key]
        $end = Get-SMBeatBucketEnd -BucketStart $b.Start -Granularity $Granularity
        $useShare = [bool]$b.HasShare
        $readB = $b.ServerRead
        $writeB = $b.ServerWrite
        $sentB = $b.ServerSent
        $recvB = $b.ServerRecv
        if ($useShare) {
            $readB = $b.Read
            $writeB = $b.Write
            $sentB = $b.Sent
            $recvB = $b.Received
        }
        $retrievedB = $b.NicOut
        $writtenB = $recvB
        if ([bool]$b.HasTcp445) {
            $retrievedB = $b.Tcp445Sent
            $writtenB = $b.Tcp445Recv
            if ([bool]$b.HasShare -and $b.Received -gt $writtenB -and $b.Received -gt (10 * [math]::Max($writtenB, [int64]1))) {
                $writtenB = $b.Received
            }
            if ($b.NicOut -gt 1048576 -and $b.Tcp445Sent -gt $b.NicOut -and $b.Tcp445Sent -gt [int64]([double]$b.NicOut * 1.1)) {
                $etwRead = [int64]0
                if ([bool]$b.HasShare) {
                    $etwRead = [int64]$b.Sent
                }
                if ($etwRead -gt 1048576 -and (Test-SMBeatVolumeNear -Left $etwRead -Right $b.NicOut)) {
                    $retrievedB = $etwRead
                }
            }
        }
        $result.Add([PSCustomObject]@{
            Granularity    = $Granularity
            PeriodStart    = $b.Start
            PeriodEnd      = $end
            ReadBytes      = $readB
            WriteBytes     = $writeB
            SentBytes      = $sentB
            ReceivedBytes  = $recvB
            NicBytesIn     = $b.NicIn
            NicBytesOut    = $b.NicOut
            RetrievedBytes = $retrievedB
            WrittenBytes   = $writtenB
            ReadGB        = (ConvertTo-SMBeatGB -Bytes $readB)
            WriteGB       = (ConvertTo-SMBeatGB -Bytes $writeB)
            SentGB        = (ConvertTo-SMBeatGB -Bytes $sentB)
            ReceivedGB    = (ConvertTo-SMBeatGB -Bytes $recvB)
            NicInGB       = (ConvertTo-SMBeatGB -Bytes $b.NicIn)
            NicOutGB      = (ConvertTo-SMBeatGB -Bytes $b.NicOut)
            RetrievedGB   = (ConvertTo-SMBeatGB -Bytes $retrievedB)
            WrittenGB     = (ConvertTo-SMBeatGB -Bytes $writtenB)
        }) | Out-Null
    }

    $result.ToArray()
}

function Get-SMBeatSeriesSet {
    param(
        [object[]]$Records,
        [datetime]$StartUtc,
        [datetime]$EndUtc,
        [TimeZoneInfo]$TimeZone,
        [string[]]$Granularity
    )

    $wantHour = $Granularity -contains 'Hour'
    $wantDay = $Granularity -contains 'Day'
    $wantWeek = $Granularity -contains 'Week'
    $hourMap = @{}
    $dayMap = @{}
    $weekMap = @{}

    if ($null -eq $Records) {
        $Records = @()
    }

    if ($wantHour -or $wantDay -or $wantWeek) {
        foreach ($rec in $Records) {
            $ts = Get-SMBeatRecordUtc -Record $rec
            $local = Convert-SMBeatUtcToTimeZone -Utc $ts -TimeZone $TimeZone
            if ($wantHour) {
                $bStart = Get-SMBeatBucketStart -LocalTime $local -Granularity Hour
                $bucket = Resolve-SMBeatSeriesBucket -Map $hourMap -Start $bStart
                Add-SMBeatSeriesDelta -Bucket $bucket -Record $rec
            }
            if ($wantDay) {
                $bStart = Get-SMBeatBucketStart -LocalTime $local -Granularity Day
                $bucket = Resolve-SMBeatSeriesBucket -Map $dayMap -Start $bStart
                Add-SMBeatSeriesDelta -Bucket $bucket -Record $rec
            }
            if ($wantWeek) {
                $bStart = Get-SMBeatBucketStart -LocalTime $local -Granularity Week
                $bucket = Resolve-SMBeatSeriesBucket -Map $weekMap -Start $bStart
                Add-SMBeatSeriesDelta -Bucket $bucket -Record $rec
            }
        }
    }

    [PSCustomObject]@{
        Hour  = @(ConvertTo-SMBeatSeriesRows -Buckets $hourMap -Granularity Hour)
        Day   = @(ConvertTo-SMBeatSeriesRows -Buckets $dayMap -Granularity Day)
        Week  = @(ConvertTo-SMBeatSeriesRows -Buckets $weekMap -Granularity Week)
    }
}

function Get-SMBeatSeries {
    param(
        [object[]]$Records,
        [datetime]$StartUtc,
        [datetime]$EndUtc,
        [TimeZoneInfo]$TimeZone,
        [string]$Granularity
    )

    $set = Get-SMBeatSeriesSet -Records $Records -StartUtc $StartUtc -EndUtc $EndUtc -TimeZone $TimeZone -Granularity @($Granularity)
    if ($Granularity -eq 'Day') {
        return @($set.Day)
    }
    if ($Granularity -eq 'Week') {
        return @($set.Week)
    }

    @($set.Hour)
}

function ConvertTo-SMBeatNativeRecordList {
    param(
        [AllowEmptyCollection()]
        [object[]]$Records
    )

    $list = New-Object 'System.Collections.Generic.List[SMBeat.Native.SampleRecord]'
    if ($null -eq $Records -or $Records.Count -eq 0) {
        return ,$list
    }

    foreach ($rec in $Records) {
        if ($null -eq $rec) {
            continue
        }
        if ($rec -is [SMBeat.Native.SampleRecord]) {
            [void]$list.Add($rec)
            continue
        }

        $n = New-Object SMBeat.Native.SampleRecord
        $props = $rec.PSObject.Properties
        if ($props['ts'] -and $rec.ts) {
            $n.ts = [string]$rec.ts
        }
        $n.TsUtc = Get-SMBeatRecordUtc -Record $rec
        if ($props['server']) {
            $n.server = [string]$rec.server
        }
        if ($props['kind']) {
            $n.kind = [string]$rec.kind
        }
        if ($props['name']) {
            $n.name = [string]$rec.name
        }
        if ($props['client']) {
            $n.client = [string]$rec.client
        }
        if ($props['user']) {
            $n.user = [string]$rec.user
        }
        if ($props['readBytesDelta'] -and $rec.readBytesDelta) {
            $n.readBytesDelta = [int64]$rec.readBytesDelta
        }
        if ($props['writeBytesDelta'] -and $rec.writeBytesDelta) {
            $n.writeBytesDelta = [int64]$rec.writeBytesDelta
        }
        if ($props['sentBytesDelta'] -and $rec.sentBytesDelta) {
            $n.sentBytesDelta = [int64]$rec.sentBytesDelta
        }
        if ($props['receivedBytesDelta'] -and $rec.receivedBytesDelta) {
            $n.receivedBytesDelta = [int64]$rec.receivedBytesDelta
        }
        if ($props['sampleIntervalSec'] -and $rec.sampleIntervalSec) {
            $n.sampleIntervalSec = [int]$rec.sampleIntervalSec
        }
        [void]$list.Add($n)
    }

    return ,$list
}

function ConvertTo-SMBeatNamedTotalsFromNative {
    param($Items)

    $list = New-Object System.Collections.Generic.List[object]
    if ($null -eq $Items) {
        return @()
    }

    foreach ($i in @($Items)) {
        if ($null -eq $i) {
            continue
        }
        $readB = [int64]$i.Read
        $writeB = [int64]$i.Write
        $sentB = [int64]$i.Sent
        $recvB = [int64]$i.Received
        [void]$list.Add([PSCustomObject]@{
            Name           = [string]$i.Name
            ReadBytes      = $readB
            WriteBytes     = $writeB
            SentBytes      = $sentB
            ReceivedBytes  = $recvB
            RetrievedBytes = $sentB
            WrittenBytes   = $recvB
            ReadGB        = (ConvertTo-SMBeatGB -Bytes $readB)
            WriteGB       = (ConvertTo-SMBeatGB -Bytes $writeB)
            SentGB        = (ConvertTo-SMBeatGB -Bytes $sentB)
            ReceivedGB    = (ConvertTo-SMBeatGB -Bytes $recvB)
            RetrievedGB   = (ConvertTo-SMBeatGB -Bytes $sentB)
            WrittenGB     = (ConvertTo-SMBeatGB -Bytes $recvB)
        })
    }

    $list.ToArray()
}

function ConvertTo-SMBeatSeriesRowsFromNative {
    param($Items)

    $list = New-Object System.Collections.Generic.List[object]
    if ($null -eq $Items) {
        return @()
    }

    foreach ($i in @($Items)) {
        if ($null -eq $i) {
            continue
        }
        $readB = [int64]$i.ReadBytes
        $writeB = [int64]$i.WriteBytes
        $sentB = [int64]$i.SentBytes
        $recvB = [int64]$i.ReceivedBytes
        $nicInB = [int64]$i.NicBytesIn
        $nicOutB = [int64]$i.NicBytesOut
        $retrievedB = [int64]$i.RetrievedBytes
        $writtenB = [int64]$i.WrittenBytes
        [void]$list.Add([PSCustomObject]@{
            Granularity    = [string]$i.Granularity
            PeriodStart    = [datetime]$i.PeriodStart
            PeriodEnd      = [datetime]$i.PeriodEnd
            ReadBytes      = $readB
            WriteBytes     = $writeB
            SentBytes      = $sentB
            ReceivedBytes  = $recvB
            NicBytesIn     = $nicInB
            NicBytesOut    = $nicOutB
            RetrievedBytes = $retrievedB
            WrittenBytes   = $writtenB
            ReadGB        = (ConvertTo-SMBeatGB -Bytes $readB)
            WriteGB       = (ConvertTo-SMBeatGB -Bytes $writeB)
            SentGB        = (ConvertTo-SMBeatGB -Bytes $sentB)
            ReceivedGB    = (ConvertTo-SMBeatGB -Bytes $recvB)
            NicInGB       = (ConvertTo-SMBeatGB -Bytes $nicInB)
            NicOutGB      = (ConvertTo-SMBeatGB -Bytes $nicOutB)
            RetrievedGB   = (ConvertTo-SMBeatGB -Bytes $retrievedB)
            WrittenGB     = (ConvertTo-SMBeatGB -Bytes $writtenB)
        })
    }

    $list.ToArray()
}

function ConvertTo-SMBeatReportFromAggregate {
    param(
        $Aggregate,
        [object[]]$Records,
        [datetime]$StartUtc,
        [datetime]$EndUtc,
        [TimeZoneInfo]$TimeZone,
        [string[]]$Granularity,
        [int]$IntervalSec,
        $PreviousTotals,
        [switch]$TotalsOnly,
        $GeneratedUtc
    )

    if ($null -eq $Records) {
        $Records = @()
    }

    $stamp = Get-SMBeatReportGeneratedUtc -GeneratedUtc $GeneratedUtc

    $warnings = New-Object System.Collections.Generic.List[string]
    $actual = [int]$Aggregate.ListenCount
    if ($actual -eq 0) {
        $actual = [int]$Aggregate.ServerKindCount
    }
    $expected = Get-SMBeatExpectedSampleCount -StartUtc $StartUtc -EndUtc $EndUtc -IntervalSec $IntervalSec
    $coverage = 0
    if ($expected -gt 0) {
        $coverage = [math]::Min(100, [math]::Round(100.0 * $actual / $expected, 1))
    }
    if ($coverage -lt 90) {
        $warnings.Add(("Coverage {0}% - collector gaps are possible (expected ~{1} server samples, found {2})." -f $coverage, $expected, $actual)) | Out-Null
    }
    if ($Aggregate.WarnNicOut) {
        $warnings.Add('NIC Out is much larger than SMB Sent. On some hosts SMB share counters miss large reads; retrieved volume falls back to NIC Out.') | Out-Null
    }
    if ($Aggregate.WarnEstataRead) {
        $warnings.Add('TCP 445 outbound is larger than NIC Out and ETW retrieved. Headline uses ETW so it matches users and shares; ESTATA can overcount long reads.') | Out-Null
    }
    if ($Aggregate.WarnShareLow) {
        $warnings.Add('Share bytes are much smaller than TCP 445 retrieved. Sessions open before the collector started map to (unknown) until reconnect; ETW buffer loss is also possible.') | Out-Null
    }
    if ($Aggregate.WarnEtwWritten) {
        $warnings.Add('TCP 445 inbound is much smaller than ETW written. Short-lived writes are counted per user and share; ESTATA often misses them.') | Out-Null
    }

    $hourly = @()
    $daily = @()
    $weekly = @()
    $hourlyForPeak = @()
    if (-not $TotalsOnly) {
        if ($Granularity -contains 'Hour') {
            $hourly = @(ConvertTo-SMBeatSeriesRowsFromNative -Items $Aggregate.Hourly)
        }
        if ($Granularity -contains 'Day') {
            $daily = @(ConvertTo-SMBeatSeriesRowsFromNative -Items $Aggregate.Daily)
        }
        if ($Granularity -contains 'Week') {
            $weekly = @(ConvertTo-SMBeatSeriesRowsFromNative -Items $Aggregate.Weekly)
        }
        $hourlyForPeak = @(ConvertTo-SMBeatSeriesRowsFromNative -Items $Aggregate.HourlyForPeak)
    }

    $peak = Get-SMBeatPeakStats -Hourly $hourlyForPeak -RetrievedBytes ([int64]$Aggregate.RetrievedBytes) -WrittenBytes ([int64]$Aggregate.WrittenBytes) -StartUtc $StartUtc -EndUtc $EndUtc

    [PSCustomObject]@{
        PSTypeName                 = 'Merlin.SMBeat.Report'
        StartUtc                   = $StartUtc
        EndUtc                     = $EndUtc
        TimeZone                   = $TimeZone.Id
        CoveragePct                = $coverage
        IntervalSec                = $IntervalSec
        Warnings                   = $warnings.ToArray()
        Totals                     = (ConvertTo-SMBeatTotalsObject -Read ([int64]$Aggregate.ReadBytes) -Write ([int64]$Aggregate.WriteBytes) -Sent ([int64]$Aggregate.SentBytes) -Received ([int64]$Aggregate.ReceivedBytes) -NicIn ([int64]$Aggregate.NicBytesIn) -NicOut ([int64]$Aggregate.NicBytesOut) -Retrieved ([int64]$Aggregate.RetrievedBytes) -Written ([int64]$Aggregate.WrittenBytes) -SmbPerfSent ([int64]$Aggregate.SmbPerfSentBytes) -SmbPerfReceived ([int64]$Aggregate.SmbPerfReceivedBytes))
        ByServer                   = @(ConvertTo-SMBeatNamedTotalsFromNative -Items $Aggregate.ByServer)
        ByShare                    = @(ConvertTo-SMBeatNamedTotalsFromNative -Items $Aggregate.ByShare)
        BySmbPerf                  = @(ConvertTo-SMBeatNamedTotalsFromNative -Items $Aggregate.BySmbPerf)
        ByClient                   = @(ConvertTo-SMBeatNamedTotalsFromNative -Items $Aggregate.ByClient)
        ByUser                     = @(ConvertTo-SMBeatNamedTotalsFromNative -Items $Aggregate.ByUser)
        Hourly                     = $hourly
        Daily                      = $daily
        Weekly                     = $weekly
        PeakStart                  = $peak.PeakStart
        PeakRetrievedGB           = $peak.PeakRetrievedGB
        AverageRetrievedGBPerHour = $peak.AverageRetrievedGBPerHour
        PeakWrittenStart           = $peak.PeakWrittenStart
        PeakWrittenGB             = $peak.PeakWrittenGB
        AverageWrittenGBPerHour   = $peak.AverageWrittenGBPerHour
        PreviousTotals             = $PreviousTotals
        SampleCount                = $Records.Count
        ModuleVersion              = (Get-SMBeatModuleVersion)
        GeneratedUtc               = $stamp
        CollectedSinceUtc          = (Get-SMBeatFirstListenUtcFromRecords -Records $Records)
    }
}

function Get-SMBeatReportGeneratedUtc {
    param($GeneratedUtc)

    if ($null -ne $GeneratedUtc -and $GeneratedUtc -is [datetime] -and $GeneratedUtc -ne [datetime]::MinValue) {
        return [datetime]$GeneratedUtc
    }

    Get-SMBeatUtcNow
}

function Test-SMBeatListenSample {
    param($Record)

    if ($null -eq $Record) {
        return $false
    }

    $kind = ''
    $name = ''
    if ($Record.PSObject.Properties['kind'] -and $Record.kind) {
        $kind = [string]$Record.kind
    }
    if ($Record.PSObject.Properties['name'] -and $Record.name) {
        $name = [string]$Record.name
    }
    if ($kind -eq 'tcp445' -and $name -eq '_listen445') {
        return $true
    }
    if ($kind -eq 'server') {
        return $true
    }

    $false
}

function Get-SMBeatFirstListenUtcFromRecords {
    param(
        [AllowEmptyCollection()]
        [object[]]$Records
    )

    $first = [datetime]::MaxValue
    foreach ($rec in @($Records)) {
        if (-not (Test-SMBeatListenSample -Record $rec)) {
            continue
        }
        $ts = Get-SMBeatRecordUtc -Record $rec
        if ($ts -eq [datetime]::MinValue) {
            continue
        }
        if ($ts -lt $first) {
            $first = $ts
        }
    }

    if ($first -eq [datetime]::MaxValue) {
        return $null
    }

    $first
}

function Get-SMBeatCollectionStartUtc {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot
    )

    $dir = Get-SMBeatSampleDirectory -DataRoot $DataRoot
    if (-not (Test-Path -LiteralPath $dir)) {
        return $null
    }

    $useNative = Initialize-SMBeatSampleJsonNative
    $files = @(Get-ChildItem -LiteralPath $dir -Filter '*.jsonl' -ErrorAction SilentlyContinue | Sort-Object -Property Name)
    foreach ($file in $files) {
        foreach ($line in [System.IO.File]::ReadLines($file.FullName, $script:SMBeatUtf8NoBom)) {
            if ([string]::IsNullOrWhiteSpace($line)) {
                continue
            }
            $rec = $null
            if ($useNative) {
                $rec = [SMBeat.Native.SampleJson]::ParseLine($line)
            }
            else {
                $rec = ConvertFrom-SMBeatJson -Json $line
            }
            if (-not (Test-SMBeatListenSample -Record $rec)) {
                continue
            }
            $ts = Get-SMBeatRecordUtc -Record $rec
            if ($ts -ne [datetime]::MinValue) {
                return $ts
            }
        }
    }

    $null
}

function New-SMBeatReportFromSamples {
    param(
        [object[]]$Records,
        [datetime]$StartUtc,
        [datetime]$EndUtc,
        [TimeZoneInfo]$TimeZone,
        [string[]]$Granularity,
        [int]$IntervalSec,
        $PreviousTotals,
        [switch]$TotalsOnly,
        [switch]$UseManaged,
        $GeneratedUtc
    )

    if ($null -eq $Records) {
        $Records = @()
    }

    $GeneratedUtc = Get-SMBeatReportGeneratedUtc -GeneratedUtc $GeneratedUtc

    if (-not $UseManaged -and (Initialize-SMBeatSampleJsonNative)) {
        $list = ConvertTo-SMBeatNativeRecordList -Records $Records
        $grans = @()
        if ($null -ne $Granularity) {
            $grans = [string[]]@($Granularity)
        }
        $tzId = 'UTC'
        if ($null -ne $TimeZone) {
            $tzId = [string]$TimeZone.Id
        }
        $arr = $list.ToArray()
        $agg = [SMBeat.Native.SampleAggregator]::Run($arr, $StartUtc, $EndUtc, $tzId, $grans, [int]$IntervalSec, [bool]$TotalsOnly)
        $result = ConvertTo-SMBeatReportFromAggregate -Aggregate $agg -Records $Records -StartUtc $StartUtc -EndUtc $EndUtc -TimeZone $TimeZone -Granularity $Granularity -IntervalSec $IntervalSec -PreviousTotals $PreviousTotals -TotalsOnly:$TotalsOnly -GeneratedUtc $GeneratedUtc
        if (-not $TotalsOnly) {
            Add-SMBeatReportSpans -Report $result -Records $Records -TimeZone $TimeZone -IntervalSec $IntervalSec | Out-Null
        }
        return $result
    }

    $managed = New-SMBeatReportFromSamplesManaged -Records $Records -StartUtc $StartUtc -EndUtc $EndUtc -TimeZone $TimeZone -Granularity $Granularity -IntervalSec $IntervalSec -PreviousTotals $PreviousTotals -TotalsOnly:$TotalsOnly -GeneratedUtc $GeneratedUtc
    if (-not $TotalsOnly) {
        Add-SMBeatReportSpans -Report $managed -Records $Records -TimeZone $TimeZone -IntervalSec $IntervalSec | Out-Null
    }
    $managed
}

function New-SMBeatReportFromSamplesManaged {
    param(
        [object[]]$Records,
        [datetime]$StartUtc,
        [datetime]$EndUtc,
        [TimeZoneInfo]$TimeZone,
        [string[]]$Granularity,
        [int]$IntervalSec,
        $PreviousTotals,
        [switch]$TotalsOnly,
        $GeneratedUtc
    )

    if ($null -eq $Records) {
        $Records = @()
    }

    $GeneratedUtc = Get-SMBeatReportGeneratedUtc -GeneratedUtc $GeneratedUtc

    $warnings = New-Object System.Collections.Generic.List[string]
    $serverRecords = @($Records | Where-Object { $_.kind -eq 'tcp445' -and $_.name -eq '_listen445' })
    if ($serverRecords.Count -eq 0) {
        $serverRecords = @($Records | Where-Object { $_.kind -eq 'server' })
    }
    $expected = Get-SMBeatExpectedSampleCount -StartUtc $StartUtc -EndUtc $EndUtc -IntervalSec $IntervalSec
    $actual = $serverRecords.Count
    $coverage = 0
    if ($expected -gt 0) {
        $coverage = [math]::Min(100, [math]::Round(100.0 * $actual / $expected, 1))
    }
    if ($coverage -lt 90) {
        $warnings.Add(("Coverage {0}% - collector gaps are possible (expected ~{1} server samples, found {2})." -f $coverage, $expected, $actual)) | Out-Null
    }

    $serverRead = [int64]0
    $serverWrite = [int64]0
    $serverSent = [int64]0
    $serverRecv = [int64]0
    $shareRead = [int64]0
    $shareWrite = [int64]0
    $shareSent = [int64]0
    $shareRecv = [int64]0
    $nicIn = [int64]0
    $nicOut = [int64]0
    $tcp445Sent = [int64]0
    $tcp445Recv = [int64]0
    $hasTcp445 = $false
    $byServerFromServer = @{}
    $byServerFromShares = @{}
    $byServerFromUsers = @{}
    $byServerFromTcp = @{}
    $byShare = @{}
    $byClient = @{}
    $byClientTcp = @{}
    $byClientEtw = @{}
    $byUser = @{}
    $bySmbPerf = @{}
    $smbPerfServerSent = [int64]0
    $smbPerfServerRecv = [int64]0
    $smbPerfShareSent = [int64]0
    $smbPerfShareRecv = [int64]0
    $hasSmbServer = $false

    foreach ($rec in $Records) {
        $r = [int64]0
        $w = [int64]0
        $s = [int64]0
        $v = [int64]0
        if ($rec.readBytesDelta) { $r = [int64]$rec.readBytesDelta }
        if ($rec.writeBytesDelta) { $w = [int64]$rec.writeBytesDelta }
        if ($rec.sentBytesDelta) { $s = [int64]$rec.sentBytesDelta }
        if ($rec.receivedBytesDelta) { $v = [int64]$rec.receivedBytesDelta }

        $kind = [string]$rec.kind
        $serverName = [string]$rec.server

        if ($kind -eq 'server') {
            $serverRead += $r
            $serverWrite += $w
            $serverSent += $s
            $serverRecv += $v
            Add-SMBeatAccumulator -Map $byServerFromServer -Key $serverName -Read $r -Write $w -Sent $s -Received $v
        }
        elseif ($kind -eq 'share') {
            $shareRead += $r
            $shareWrite += $w
            $shareSent += $s
            $shareRecv += $v
            Add-SMBeatAccumulator -Map $byShare -Key $rec.name -Read $r -Write $w -Sent $s -Received $v
            Add-SMBeatAccumulator -Map $byServerFromShares -Key $serverName -Read $r -Write $w -Sent $s -Received $v
        }
        elseif ($kind -eq 'session') {
            $clientKey = $rec.client
            if ([string]::IsNullOrWhiteSpace([string]$clientKey)) {
                $clientKey = [string]$rec.name
            }
            $userKey = $rec.user
            if ([string]::IsNullOrWhiteSpace([string]$userKey)) {
                $userKey = '(unknown)'
            }
            if (-not [string]::IsNullOrWhiteSpace([string]$clientKey)) {
                Add-SMBeatAccumulator -Map $byClient -Key ([string]$clientKey) -Read $r -Write $w -Sent $s -Received $v
            }
            Add-SMBeatAccumulator -Map $byUser -Key ([string]$userKey) -Read $r -Write $w -Sent $s -Received $v
        }
        elseif ($kind -eq 'client') {
            $clientKey = $rec.client
            if ([string]::IsNullOrWhiteSpace([string]$clientKey)) {
                $clientKey = [string]$rec.name
            }
            if (-not [string]::IsNullOrWhiteSpace([string]$clientKey)) {
                Add-SMBeatAccumulator -Map $byClientEtw -Key ([string]$clientKey) -Read $r -Write $w -Sent $s -Received $v
            }
        }
        elseif ($kind -eq 'user') {
            $userKey = $rec.user
            if ([string]::IsNullOrWhiteSpace([string]$userKey)) {
                $userKey = [string]$rec.name
            }
            if ([string]::IsNullOrWhiteSpace([string]$userKey)) {
                $userKey = '(unknown)'
            }
            Add-SMBeatAccumulator -Map $byUser -Key ([string]$userKey) -Read $r -Write $w -Sent $s -Received $v
            if (-not [string]::IsNullOrWhiteSpace($serverName)) {
                Add-SMBeatAccumulator -Map $byServerFromUsers -Key $serverName -Read $r -Write $w -Sent $s -Received $v
            }
        }
        elseif ($kind -eq 'tcp445') {
            $hasTcp445 = $true
            $name = [string]$rec.name
            if ($name -eq '_listen445') {
                $tcp445Sent += $s
                $tcp445Recv += $v
                Add-SMBeatAccumulator -Map $byServerFromTcp -Key $serverName -Read 0 -Write 0 -Sent $s -Received $v
            }
            else {
                $clientKey = $rec.client
                if ([string]::IsNullOrWhiteSpace([string]$clientKey)) {
                    $clientKey = $name
                }
                Add-SMBeatAccumulator -Map $byClientTcp -Key ([string]$clientKey) -Read 0 -Write 0 -Sent $s -Received $v
            }
        }
        elseif ($kind -eq 'nic') {
            $nicIn += $v
            $nicOut += $s
        }
        elseif ($kind -eq 'smbperf') {
            $smbName = [string]$rec.name
            if ($smbName -eq '_server') {
                $hasSmbServer = $true
                $smbPerfServerSent += $s
                $smbPerfServerRecv += $v
            }
            else {
                Add-SMBeatAccumulator -Map $bySmbPerf -Key $smbName -Read $r -Write $w -Sent $s -Received $v
                $smbPerfShareSent += $s
                $smbPerfShareRecv += $v
            }
        }
    }

    $useShareTotals = ($shareSent + $shareRecv) -gt ($serverSent + $serverRecv)
    $read = $serverRead
    $write = $serverWrite
    $sent = $serverSent
    $recv = $serverRecv
    $byServer = $byServerFromServer
    if ($useShareTotals) {
        $read = $shareRead
        $write = $shareWrite
        $sent = $shareSent
        $recv = $shareRecv
        $byServer = $byServerFromShares
    }
    if ($hasTcp445) {
        $byServer = $byServerFromTcp
        if ($byClientTcp.Count -gt 0) {
            $byClient = $byClientTcp
        }
    }
    if ($byClientEtw.Count -gt 0) {
        if ($byClient.Count -eq 0) {
            $byClient = $byClientEtw
        }
        else {
            foreach ($ek in @($byClientEtw.Keys)) {
                $etw = $byClientEtw[$ek]
                if (-not $byClient.ContainsKey($ek)) {
                    $byClient[$ek] = $etw
                    continue
                }
                $cur = $byClient[$ek]
                $etwSent = [int64]$etw.Sent
                $curSent = [int64]$cur.Sent
                if ($etwSent -gt 1048576 -and $curSent -gt [int64]([double]$etwSent * 1.1)) {
                    $cur.Sent = $etwSent
                }
                elseif ($etwSent -gt $curSent) {
                    $cur.Sent = $etwSent
                }
                if ([int64]$etw.Received -gt [int64]$cur.Received) { $cur.Received = $etw.Received }
                if ([int64]$etw.Read -gt [int64]$cur.Read) { $cur.Read = $etw.Read }
                if ([int64]$etw.Write -gt [int64]$cur.Write) { $cur.Write = $etw.Write }
            }
        }
    }

    $retrieved = $nicOut
    $written = $recv
    if ($hasTcp445) {
        $retrieved = $tcp445Sent
        $written = $tcp445Recv
    }
    elseif ($nicOut -gt 104857600 -and $nicOut -gt (10 * [math]::Max($sent, [int64]1))) {
        $warnings.Add('NIC Out is much larger than SMB Sent. On some hosts SMB share counters miss large reads; retrieved volume falls back to NIC Out.') | Out-Null
    }

    $etwRetrieved = Get-SMBeatEtwRetrievedBytes -ByUser $byUser -ShareSent $shareSent
    $etwWritten = $shareRecv
    foreach ($uk in @($byUser.Keys)) {
        $uRecv = [int64]$byUser[$uk].Received
        if ($uRecv -gt $etwWritten) {
            $etwWritten = $uRecv
        }
    }

    $retrievedFromEtw = $false
    if ($hasTcp445 -and $nicOut -gt 1048576 -and $retrieved -gt $nicOut -and $retrieved -gt [int64]([double]$nicOut * 1.1)) {
        if ($etwRetrieved -gt 1048576 -and (Test-SMBeatVolumeNear -Left $etwRetrieved -Right $nicOut)) {
            $retrieved = $etwRetrieved
            $retrievedFromEtw = $true
            $warnings.Add('TCP 445 outbound is larger than NIC Out and ETW retrieved. Headline uses ETW so it matches users and shares; ESTATA can overcount long reads.') | Out-Null
        }
    }

    if ($hasTcp445 -and $retrieved -gt 10485760) {
        $shareFloor = [int64]([math]::Floor($retrieved * 0.1))
        if ($shareSent -lt $shareFloor) {
            $warnings.Add('Share bytes are much smaller than TCP 445 retrieved. Sessions open before the collector started map to (unknown) until reconnect; ETW buffer loss is also possible.') | Out-Null
        }
    }

    if ($hasTcp445 -and $etwWritten -gt 1048576 -and $etwWritten -gt (10 * [math]::Max($written, [int64]1))) {
        $written = $etwWritten
        $warnings.Add('TCP 445 inbound is much smaller than ETW written. Short-lived writes are counted per user and share; ESTATA often misses them.') | Out-Null
    }

    foreach ($sk in @($byServer.Keys)) {
        $etwRecv = [int64]0
        $etwSent = [int64]0
        if ($byServerFromShares.ContainsKey($sk)) {
            $etwRecv = [int64]$byServerFromShares[$sk].Received
            $etwSent = [int64]$byServerFromShares[$sk].Sent
        }
        if ($byServerFromUsers.ContainsKey($sk)) {
            $uRecv = [int64]$byServerFromUsers[$sk].Received
            $uSent = [int64]$byServerFromUsers[$sk].Sent
            if ($uRecv -gt $etwRecv) {
                $etwRecv = $uRecv
            }
            if ($uSent -gt $etwSent) {
                $etwSent = $uSent
            }
        }
        $curRecv = [int64]$byServer[$sk].Received
        if ($etwRecv -gt 1048576 -and $etwRecv -gt (10 * [math]::Max($curRecv, [int64]1))) {
            $byServer[$sk].Received = $etwRecv
        }
        $curSent = [int64]$byServer[$sk].Sent
        if ($retrievedFromEtw -and $etwSent -gt 1048576) {
            $byServer[$sk].Sent = $etwSent
        }
        elseif ($etwSent -gt 1048576 -and $curSent -gt [int64]([double]$etwSent * 1.1) -and $nicOut -gt 1048576 -and (Test-SMBeatVolumeNear -Left $etwSent -Right $nicOut)) {
            $byServer[$sk].Sent = $etwSent
        }
    }

    if ($hasTcp445 -and $byServer.Count -eq 1) {
        foreach ($sk in @($byServer.Keys)) {
            $byServer[$sk].Sent = $retrieved
            $byServer[$sk].Received = $written
        }
    }

    $hourly = @()
    $daily = @()
    $weekly = @()
    $hourlyForPeak = @()
    if (-not $TotalsOnly) {
        $wanted = New-Object System.Collections.Generic.List[string]
        foreach ($g in @($Granularity)) {
            if ($g -and -not $wanted.Contains($g)) {
                [void]$wanted.Add($g)
            }
        }
        if (-not $wanted.Contains('Hour')) {
            [void]$wanted.Add('Hour')
        }
        $seriesSet = Get-SMBeatSeriesSet -Records $Records -StartUtc $StartUtc -EndUtc $EndUtc -TimeZone $TimeZone -Granularity $wanted.ToArray()
        if ($Granularity -contains 'Hour') {
            $hourly = @($seriesSet.Hour)
        }
        if ($Granularity -contains 'Day') {
            $daily = @($seriesSet.Day)
        }
        if ($Granularity -contains 'Week') {
            $weekly = @($seriesSet.Week)
        }
        $hourlyForPeak = @($seriesSet.Hour)
    }
    $smbPerfSent = $smbPerfShareSent
    $smbPerfRecv = $smbPerfShareRecv
    if ($hasSmbServer -and (($smbPerfServerSent + $smbPerfServerRecv) -ge ($smbPerfShareSent + $smbPerfShareRecv))) {
        $smbPerfSent = $smbPerfServerSent
        $smbPerfRecv = $smbPerfServerRecv
    }

    $peak = Get-SMBeatPeakStats -Hourly $hourlyForPeak -RetrievedBytes $retrieved -WrittenBytes $written -StartUtc $StartUtc -EndUtc $EndUtc

    [PSCustomObject]@{
        PSTypeName                 = 'Merlin.SMBeat.Report'
        StartUtc                   = $StartUtc
        EndUtc                     = $EndUtc
        TimeZone                   = $TimeZone.Id
        CoveragePct                = $coverage
        IntervalSec                = $IntervalSec
        Warnings                   = $warnings.ToArray()
        Totals                     = (ConvertTo-SMBeatTotalsObject -Read $read -Write $write -Sent $sent -Received $recv -NicIn $nicIn -NicOut $nicOut -Retrieved $retrieved -Written $written -SmbPerfSent $smbPerfSent -SmbPerfReceived $smbPerfRecv)
        ByServer                   = @(ConvertTo-SMBeatNamedTotals -Map $byServer)
        ByShare                    = @(ConvertTo-SMBeatNamedTotals -Map $byShare)
        BySmbPerf                  = @(ConvertTo-SMBeatNamedTotals -Map $bySmbPerf)
        ByClient                   = @(ConvertTo-SMBeatNamedTotals -Map $byClient)
        ByUser                     = @(ConvertTo-SMBeatNamedTotals -Map $byUser)
        Hourly                     = $hourly
        Daily                      = $daily
        Weekly                     = $weekly
        PeakStart                  = $peak.PeakStart
        PeakRetrievedGB           = $peak.PeakRetrievedGB
        AverageRetrievedGBPerHour = $peak.AverageRetrievedGBPerHour
        PeakWrittenStart           = $peak.PeakWrittenStart
        PeakWrittenGB             = $peak.PeakWrittenGB
        AverageWrittenGBPerHour   = $peak.AverageWrittenGBPerHour
        PreviousTotals             = $PreviousTotals
        SampleCount                = $Records.Count
        ModuleVersion              = (Get-SMBeatModuleVersion)
        GeneratedUtc               = $GeneratedUtc
        CollectedSinceUtc          = (Get-SMBeatFirstListenUtcFromRecords -Records $Records)
    }
}

function Get-SMBeatRecordsInWindow {
    param(
        [AllowEmptyCollection()]
        [object[]]$Records,
        [datetime]$StartUtc,
        [datetime]$EndUtc
    )

    $list = New-Object System.Collections.Generic.List[object]
    foreach ($rec in @($Records)) {
        $ts = Get-SMBeatRecordUtc -Record $rec
        if ($ts -eq [datetime]::MinValue) {
            continue
        }
        if ($ts -ge $StartUtc -and $ts -lt $EndUtc) {
            [void]$list.Add($rec)
        }
    }

    $list
}

function New-SMBeatSpanSnapshot {
    param(
        [AllowEmptyCollection()]
        [object[]]$Records,
        [datetime]$StartUtc,
        [datetime]$EndUtc,
        [TimeZoneInfo]$TimeZone,
        [int]$IntervalSec
    )

    $slice = @(Get-SMBeatRecordsInWindow -Records $Records -StartUtc $StartUtc -EndUtc $EndUtc)
    $mini = New-SMBeatReportFromSamples -Records $slice -StartUtc $StartUtc -EndUtc $EndUtc -TimeZone $TimeZone -Granularity @() -IntervalSec $IntervalSec -TotalsOnly
    [PSCustomObject]@{
        StartUtc = $StartUtc
        EndUtc   = $EndUtc
        Totals   = $mini.Totals
    }
}

function Add-SMBeatReportSpans {
    param(
        [Parameter(Mandatory = $true)]
        $Report,
        [AllowEmptyCollection()]
        [object[]]$Records,
        [TimeZoneInfo]$TimeZone,
        [int]$IntervalSec
    )

    if ($IntervalSec -le 0) {
        $IntervalSec = 60
    }

    $anchor = Get-SMBeatUtcNow
    if ($Report.PSObject.Properties['GeneratedUtc'] -and $Report.GeneratedUtc -ne [datetime]::MinValue) {
        $anchor = [datetime]$Report.GeneratedUtc
    }

    $Report | Add-Member -NotePropertyName SpanHour -NotePropertyValue (New-SMBeatSpanSnapshot -Records $Records -StartUtc $anchor.AddHours(-1) -EndUtc $anchor -TimeZone $TimeZone -IntervalSec $IntervalSec) -Force
    $Report | Add-Member -NotePropertyName SpanDay -NotePropertyValue (New-SMBeatSpanSnapshot -Records $Records -StartUtc $anchor.AddDays(-1) -EndUtc $anchor -TimeZone $TimeZone -IntervalSec $IntervalSec) -Force
    $Report | Add-Member -NotePropertyName SpanWeek -NotePropertyValue (New-SMBeatSpanSnapshot -Records $Records -StartUtc $anchor.AddDays(-7) -EndUtc $anchor -TimeZone $TimeZone -IntervalSec $IntervalSec) -Force
    $Report
}

function Merge-SMBeatSpanSnapshots {
    param(
        [object[]]$Reports,
        [string]$Name
    )

    $start = $null
    $end = $null
    $read = [int64]0
    $write = [int64]0
    $sent = [int64]0
    $recv = [int64]0
    $nicIn = [int64]0
    $nicOut = [int64]0
    $retrieved = [int64]0
    $written = [int64]0
    $smbS = [int64]0
    $smbR = [int64]0
    $found = $false

    foreach ($r in @($Reports)) {
        if (-not $r.PSObject.Properties[$Name] -or $null -eq $r.$Name) {
            continue
        }
        $span = $r.$Name
        if ($null -eq $span.Totals) {
            continue
        }
        $found = $true
        if ($null -eq $start) {
            $start = $span.StartUtc
            $end = $span.EndUtc
        }
        $t = $span.Totals
        $read += [int64]$t.ReadBytes
        $write += [int64]$t.WriteBytes
        $sent += [int64]$t.SentBytes
        $recv += [int64]$t.ReceivedBytes
        $nicIn += [int64]$t.NicBytesIn
        $nicOut += [int64]$t.NicBytesOut
        if ($t.RetrievedBytes) { $retrieved += [int64]$t.RetrievedBytes } else { $retrieved += [int64]$t.NicBytesOut }
        if ($t.PSObject.Properties['WrittenBytes'] -and $t.WrittenBytes) { $written += [int64]$t.WrittenBytes } else { $written += [int64]$t.ReceivedBytes }
        if ($t.PSObject.Properties['SmbPerfSentBytes'] -and $t.SmbPerfSentBytes) { $smbS += [int64]$t.SmbPerfSentBytes }
        if ($t.PSObject.Properties['SmbPerfReceivedBytes'] -and $t.SmbPerfReceivedBytes) { $smbR += [int64]$t.SmbPerfReceivedBytes }
    }

    if (-not $found) {
        return $null
    }

    [PSCustomObject]@{
        StartUtc = $start
        EndUtc   = $end
        Totals   = (ConvertTo-SMBeatTotalsObject -Read $read -Write $write -Sent $sent -Received $recv -NicIn $nicIn -NicOut $nicOut -Retrieved $retrieved -Written $written -SmbPerfSent $smbS -SmbPerfReceived $smbR)
    }
}

function New-SMBeatReportFromDataRoot {
    param(
        [string]$DataRoot,
        [datetime]$WindowStart,
        [datetime]$WindowEnd,
        [datetime]$PrevStart,
        [datetime]$PrevEnd,
        [TimeZoneInfo]$TimeZone,
        [string[]]$Granularity
    )

    $config = Read-SMBeatConfig -DataRoot $DataRoot
    $interval = [int]$config.IntervalSec
    if ($interval -le 0) { $interval = 60 }
    $generatedUtc = Get-SMBeatUtcNow
    $spanStart = $generatedUtc.AddDays(-7)
    $loadStart = $WindowStart
    if ($spanStart -lt $loadStart) {
        $loadStart = $spanStart
    }
    $loadEnd = $WindowEnd
    if ($generatedUtc -gt $loadEnd) {
        $loadEnd = $generatedUtc
    }
    $windows = Read-SMBeatSampleWindows -DataRoot $DataRoot -PrevStart $PrevStart -PrevEnd $PrevEnd -WindowStart $loadStart -WindowEnd $loadEnd
    $loaded = @($windows.Current)
    $records = @(Get-SMBeatRecordsInWindow -Records $loaded -StartUtc $WindowStart -EndUtc $WindowEnd)
    $prevRecords = @($windows.Previous)
    $prevTotals = $null
    if ($prevRecords.Count -gt 0) {
        $prevReport = New-SMBeatReportFromSamples -Records $prevRecords -StartUtc $PrevStart -EndUtc $PrevEnd -TimeZone $TimeZone -Granularity @() -IntervalSec $interval -TotalsOnly
        $prevTotals = $prevReport.Totals
    }

    $report = New-SMBeatReportFromSamples -Records $records -StartUtc $WindowStart -EndUtc $WindowEnd -TimeZone $TimeZone -Granularity $Granularity -IntervalSec $interval -PreviousTotals $prevTotals -GeneratedUtc $generatedUtc
    Add-SMBeatReportSpans -Report $report -Records $loaded -TimeZone $TimeZone -IntervalSec $interval | Out-Null
    $diskSince = Get-SMBeatCollectionStartUtc -DataRoot $DataRoot
    if ($null -ne $diskSince) {
        $report | Add-Member -NotePropertyName CollectedSinceUtc -NotePropertyValue $diskSince -Force
    }
    $report
}

function Resolve-SMBeatReportWindow {
    param(
        [datetime]$Start,
        [datetime]$End,
        [int]$LastHours,
        [int]$LastDays,
        [int]$LastWeeks
    )

    $endUtc = Get-SMBeatUtcNow
    if ($PSBoundParameters.ContainsKey('End') -and $End) {
        $endUtc = $End.ToUniversalTime()
    }

    $startUtc = $null
    if ($PSBoundParameters.ContainsKey('Start') -and $Start) {
        $startUtc = $Start.ToUniversalTime()
    }
    elseif ($LastHours -gt 0) {
        $startUtc = $endUtc.AddHours(-1 * $LastHours)
    }
    elseif ($LastDays -gt 0) {
        $startUtc = $endUtc.AddDays(-1 * $LastDays)
    }
    elseif ($LastWeeks -gt 0) {
        $startUtc = $endUtc.AddDays(-7 * $LastWeeks)
    }
    else {
        $startUtc = $endUtc.AddDays(-7)
    }

    if ($startUtc -ge $endUtc) {
        throw 'Start must be earlier than End.'
    }

    [PSCustomObject]@{
        StartUtc = $startUtc
        EndUtc   = $endUtc
    }
}

function Merge-SMBeatNamedTotalLists {
    param(
        [object[]]$Lists
    )

    $map = @{}
    foreach ($list in $Lists) {
        if ($null -eq $list) { continue }
        foreach ($row in @($list)) {
            Add-SMBeatAccumulator -Map $map -Key ([string]$row.Name) -Read ([int64]$row.ReadBytes) -Write ([int64]$row.WriteBytes) -Sent ([int64]$row.SentBytes) -Received ([int64]$row.ReceivedBytes)
        }
    }
    @(ConvertTo-SMBeatNamedTotals -Map $map)
}

function Merge-SMBeatSeriesLists {
    param(
        [object[]]$Lists,
        [string]$Granularity
    )

    $map = @{}
    foreach ($list in $Lists) {
        if ($null -eq $list) { continue }
        foreach ($row in @($list)) {
            $key = $row.PeriodStart.ToString('o')
            if (-not $map.ContainsKey($key)) {
                $map[$key] = @{
                    Start    = $row.PeriodStart
                    End      = $row.PeriodEnd
                    Read     = [int64]0
                    Write    = [int64]0
                    Sent     = [int64]0
                    Received = [int64]0
                    NicIn     = [int64]0
                    NicOut    = [int64]0
                    Retrieved = [int64]0
                    Written   = [int64]0
                }
            }
            $map[$key].Read += [int64]$row.ReadBytes
            $map[$key].Write += [int64]$row.WriteBytes
            if ($row.SentBytes) { $map[$key].Sent += [int64]$row.SentBytes }
            if ($row.ReceivedBytes) { $map[$key].Received += [int64]$row.ReceivedBytes }
            if ($row.NicBytesIn) { $map[$key].NicIn += [int64]$row.NicBytesIn }
            if ($row.NicBytesOut) { $map[$key].NicOut += [int64]$row.NicBytesOut }
            if ($row.RetrievedBytes) { $map[$key].Retrieved += [int64]$row.RetrievedBytes }
            elseif ($row.NicBytesOut) { $map[$key].Retrieved += [int64]$row.NicBytesOut }
            if ($row.PSObject.Properties['WrittenBytes'] -and $row.WrittenBytes) {
                $map[$key].Written += [int64]$row.WrittenBytes
            }
            elseif ($row.ReceivedBytes) {
                $map[$key].Written += [int64]$row.ReceivedBytes
            }
        }
    }

    $result = New-Object System.Collections.Generic.List[object]
    foreach ($key in ($map.Keys | Sort-Object)) {
        $b = $map[$key]
        $result.Add([PSCustomObject]@{
            Granularity  = $Granularity
            PeriodStart  = $b.Start
            PeriodEnd    = $b.End
            ReadBytes     = $b.Read
            WriteBytes    = $b.Write
            SentBytes     = $b.Sent
            ReceivedBytes = $b.Received
            NicBytesIn     = $b.NicIn
            NicBytesOut    = $b.NicOut
            RetrievedBytes = $b.Retrieved
            WrittenBytes   = $b.Written
            ReadGB        = (ConvertTo-SMBeatGB -Bytes $b.Read)
            WriteGB       = (ConvertTo-SMBeatGB -Bytes $b.Write)
            SentGB        = (ConvertTo-SMBeatGB -Bytes $b.Sent)
            ReceivedGB    = (ConvertTo-SMBeatGB -Bytes $b.Received)
            NicInGB       = (ConvertTo-SMBeatGB -Bytes $b.NicIn)
            NicOutGB      = (ConvertTo-SMBeatGB -Bytes $b.NicOut)
            RetrievedGB   = (ConvertTo-SMBeatGB -Bytes $b.Retrieved)
            WrittenGB     = (ConvertTo-SMBeatGB -Bytes $b.Written)
        }) | Out-Null
    }
    $result.ToArray()
}

function Merge-SMBeatReports {
    param(
        [Parameter(Mandatory = $true)]
        [object[]]$Reports,
        $PreviousTotals
    )

    if ($null -eq $Reports -or $Reports.Count -eq 0) {
        return $null
    }
    if ($Reports.Count -eq 1) {
        $one = $Reports[0]
        if ($PreviousTotals) {
            $one.PreviousTotals = $PreviousTotals
        }
        return $one
    }

    $first = $Reports[0]
    $read = [int64]0
    $write = [int64]0
    $sent = [int64]0
    $recv = [int64]0
    $nicIn = [int64]0
    $nicOut = [int64]0
    $smbPerfSent = [int64]0
    $smbPerfRecv = [int64]0
    $retrieved = [int64]0
    $written = [int64]0
    $samples = 0
    $covSum = 0.0
    $warnings = New-Object System.Collections.Generic.List[string]
    $serverLists = New-Object System.Collections.Generic.List[object]
    $shareLists = New-Object System.Collections.Generic.List[object]
    $clientLists = New-Object System.Collections.Generic.List[object]
    $userLists = New-Object System.Collections.Generic.List[object]
    $smbPerfLists = New-Object System.Collections.Generic.List[object]
    $hourlyLists = New-Object System.Collections.Generic.List[object]
    $dailyLists = New-Object System.Collections.Generic.List[object]
    $weeklyLists = New-Object System.Collections.Generic.List[object]

    foreach ($r in $Reports) {
        $read += [int64]$r.Totals.ReadBytes
        $write += [int64]$r.Totals.WriteBytes
        $sent += [int64]$r.Totals.SentBytes
        $recv += [int64]$r.Totals.ReceivedBytes
        $nicIn += [int64]$r.Totals.NicBytesIn
        $nicOut += [int64]$r.Totals.NicBytesOut
        if ($r.Totals.PSObject.Properties['SmbPerfSentBytes'] -and $r.Totals.SmbPerfSentBytes) {
            $smbPerfSent += [int64]$r.Totals.SmbPerfSentBytes
        }
        if ($r.Totals.PSObject.Properties['SmbPerfReceivedBytes'] -and $r.Totals.SmbPerfReceivedBytes) {
            $smbPerfRecv += [int64]$r.Totals.SmbPerfReceivedBytes
        }
        if ($r.Totals.RetrievedBytes) {
            $retrieved += [int64]$r.Totals.RetrievedBytes
        }
        else {
            $retrieved += [int64]$r.Totals.NicBytesOut
        }
        if ($r.Totals.PSObject.Properties['WrittenBytes'] -and $r.Totals.WrittenBytes) {
            $written += [int64]$r.Totals.WrittenBytes
        }
        else {
            $written += [int64]$r.Totals.ReceivedBytes
        }
        $samples += [int]$r.SampleCount
        $covSum += [double]$r.CoveragePct
        foreach ($w in @($r.Warnings)) { if ($w) { $warnings.Add([string]$w) | Out-Null } }
        $serverLists.Add(@($r.ByServer)) | Out-Null
        $shareLists.Add(@($r.ByShare)) | Out-Null
        $clientLists.Add(@($r.ByClient)) | Out-Null
        $userLists.Add(@($r.ByUser)) | Out-Null
        if ($r.PSObject.Properties['BySmbPerf']) {
            $smbPerfLists.Add(@($r.BySmbPerf)) | Out-Null
        }
        $hourlyLists.Add(@($r.Hourly)) | Out-Null
        $dailyLists.Add(@($r.Daily)) | Out-Null
        $weeklyLists.Add(@($r.Weekly)) | Out-Null
    }

    $hourlyMerged = @(Merge-SMBeatSeriesLists -Lists $hourlyLists.ToArray() -Granularity Hour)
    $peak = Get-SMBeatPeakStats -Hourly $hourlyMerged -RetrievedBytes $retrieved -WrittenBytes $written -StartUtc $first.StartUtc -EndUtc $first.EndUtc
    $since = $null
    foreach ($r in $Reports) {
        if (-not $r.PSObject.Properties['CollectedSinceUtc'] -or $null -eq $r.CollectedSinceUtc) {
            continue
        }
        $one = [datetime]$r.CollectedSinceUtc
        if ($one -eq [datetime]::MinValue) {
            continue
        }
        if ($null -eq $since -or $one -lt $since) {
            $since = $one
        }
    }

    [PSCustomObject]@{
        PSTypeName                 = 'Merlin.SMBeat.Report'
        StartUtc                   = $first.StartUtc
        EndUtc                     = $first.EndUtc
        TimeZone                   = $first.TimeZone
        CoveragePct                = [math]::Round($covSum / $Reports.Count, 1)
        IntervalSec                = $first.IntervalSec
        Warnings                   = $warnings.ToArray()
        Totals                     = (ConvertTo-SMBeatTotalsObject -Read $read -Write $write -Sent $sent -Received $recv -NicIn $nicIn -NicOut $nicOut -Retrieved $retrieved -Written $written -SmbPerfSent $smbPerfSent -SmbPerfReceived $smbPerfRecv)
        ByServer                   = (Merge-SMBeatNamedTotalLists -Lists $serverLists.ToArray())
        ByShare                    = (Merge-SMBeatNamedTotalLists -Lists $shareLists.ToArray())
        BySmbPerf                  = (Merge-SMBeatNamedTotalLists -Lists $smbPerfLists.ToArray())
        ByClient                   = (Merge-SMBeatNamedTotalLists -Lists $clientLists.ToArray())
        ByUser                     = (Merge-SMBeatNamedTotalLists -Lists $userLists.ToArray())
        Hourly                     = $hourlyMerged
        Daily                      = @(Merge-SMBeatSeriesLists -Lists $dailyLists.ToArray() -Granularity Day)
        Weekly                     = @(Merge-SMBeatSeriesLists -Lists $weeklyLists.ToArray() -Granularity Week)
        PeakStart                  = $peak.PeakStart
        PeakRetrievedGB           = $peak.PeakRetrievedGB
        AverageRetrievedGBPerHour = $peak.AverageRetrievedGBPerHour
        PeakWrittenStart           = $peak.PeakWrittenStart
        PeakWrittenGB             = $peak.PeakWrittenGB
        AverageWrittenGBPerHour   = $peak.AverageWrittenGBPerHour
        PreviousTotals             = $PreviousTotals
        SampleCount                = $samples
        ModuleVersion              = (Get-SMBeatModuleVersion)
        GeneratedUtc               = (Get-SMBeatUtcNow)
        CollectedSinceUtc          = $since
        SpanHour                   = (Merge-SMBeatSpanSnapshots -Reports $Reports -Name 'SpanHour')
        SpanDay                    = (Merge-SMBeatSpanSnapshots -Reports $Reports -Name 'SpanDay')
        SpanWeek                   = (Merge-SMBeatSpanSnapshots -Reports $Reports -Name 'SpanWeek')
    }
}