Private/Data.ps1

# Reading gathered data.
#
# Judges are handed whatever a Gatherer produced on a real machine, and a real machine
# produces absent counters, unreadable devices and empty collections. These helpers let a
# Judge ask for a value without first asking whether it is there, so a Judge's logic
# stays about thresholds rather than about null.

function Get-DataProperty {
    <#
    .SYNOPSIS
        Reads a property off gathered data, returning $null when it is not there.
    .DESCRIPTION
        Gathered data reaches a Judge either as the object a Gatherer built or, for the
        Elevated Part, as its CliXML round trip. Neither is guaranteed to carry every
        property: a counter class that would not load leaves its properties absent
        altogether rather than null.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$InputObject,
        [Parameter(Mandatory)][string]$Name
    )
    if ($null -eq $InputObject) { return $null }
    if ($InputObject -is [hashtable]) {
        if ($InputObject.ContainsKey($Name)) { return $InputObject[$Name] }
        return $null
    }
    $property = $InputObject.PSObject.Properties[$Name]
    if ($property) { return $property.Value }
    $null
}

function ConvertTo-Number {
    <#
    .SYNOPSIS
        Returns the value as a double, or $null when it is not a number.
    .DESCRIPTION
        Zero is a number and must survive; an empty string, a null and a counter that
        reported text are not, and a Judge must be able to tell those apart from a
        genuine reading of zero.
    #>

    [CmdletBinding()]
    [OutputType([double])]
    param([AllowNull()]$Value)

    if ($null -eq $Value) { return $null }

    # A bool is not a reading, and neither is a date.
    if ($Value -is [bool] -or $Value -is [datetime]) { return $null }

    # Already a number: convert it as one, never through its text. PowerShell keeps the
    # source text of a suffixed numeric literal, so a value holding 2000GB can stringify
    # to "2000GB", which parses as nothing - and a Judge reaching for that text would
    # report a Check unavailable on a machine that answered perfectly well. ( -is unwraps
    # a PSObject on its own, so a wrapped number lands here too.)
    if ($Value -is [byte]   -or $Value -is [sbyte]  -or
        $Value -is [int16]  -or $Value -is [uint16] -or
        $Value -is [int32]  -or $Value -is [uint32] -or
        $Value -is [int64]  -or $Value -is [uint64] -or
        $Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) {
        return [double]$Value
    }

    $number = 0.0
    if ([double]::TryParse(
            [string]$Value, [Globalization.NumberStyles]::Float,
            [Globalization.CultureInfo]::InvariantCulture, [ref]$number)) {
        return $number
    }
    $null
}

function Get-SampleAverage {
    <#
    .SYNOPSIS
        The average of the readings in a sample window, or $null when none is a number.
    .DESCRIPTION
        Several Checks average a counter across a window rather than trusting one reading.
        A real machine returns windows with holes in them - a counter that would not load
        leaves a null, an unreadable one leaves text - so the readable readings are judged
        and the rest ignored, and a window with nothing readable in it is not a zero.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()]$Sample)

    $readings = @(@($Sample) | ForEach-Object { ConvertTo-Number $_ } | Where-Object { $null -ne $_ })
    if (-not $readings.Count) { return $null }
    ($readings | Measure-Object -Average).Average
}

function Get-Severity {
    <#
    .SYNOPSIS
        The Severity a value earns against a WARN and a FAIL threshold.
    .DESCRIPTION
        Thresholds are exclusive: a value sitting exactly on the WARN threshold is still
        OK, which is what the Gutcheck script has always done and what the Report's
        documented boundaries mean.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][double]$Value,
        [Parameter(Mandatory)][double]$WarnAbove,
        [Parameter(Mandatory)][double]$FailAbove
    )
    if ($Value -gt $FailAbove) { return 'FAIL' }
    if ($Value -gt $WarnAbove) { return 'WARN' }
    'OK'
}

function Get-SeverityBelow {
    <#
    .SYNOPSIS
        The Severity a value earns when less of it is worse.
    .DESCRIPTION
        Installed memory and a processor's performance limit are graded downwards: it is
        the small number that is the problem. Thresholds stay exclusive in the same sense
        as Get-Severity, so a value sitting exactly on the WARN threshold is still OK.
 
        A Check that only ever warned in the Gutcheck script keeps only its WARN
        threshold, and its FAIL threshold defaults to a value no reading can go below -
        still a named parameter a Customer could set, but inert until they do.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][double]$Value,
        [Parameter(Mandatory)][double]$WarnBelow,
        [Parameter(Mandatory)][double]$FailBelow
    )
    if ($Value -lt $FailBelow) { return 'FAIL' }
    if ($Value -lt $WarnBelow) { return 'WARN' }
    'OK'
}

function Get-SamplePercentile {
    <#
    .SYNOPSIS
        The reading at the given percentile of a sample window, or $null when none is a number.
    .DESCRIPTION
        An average hides the tail, and the tail is what a Technician feels: a disk whose
        typical write takes 0.2 ms and whose worst takes 90 ms is a disk that stutters.
        Unreadable readings are ignored the same way Get-SampleAverage ignores them, so a
        window with holes in it still yields a percentile rather than nothing.
 
        Nearest-rank, which is what a percentile over a few hundred readings should be: the
        value returned is one that was actually measured rather than an interpolation
        between two that were.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][AllowEmptyCollection()]$Sample,
        [Parameter(Mandatory)][ValidateRange(0, 100)][double]$Percentile
    )

    $readings = @(@($Sample) | ForEach-Object { ConvertTo-Number $_ } | Where-Object { $null -ne $_ } | Sort-Object)
    if (-not $readings.Count) { return $null }

    $rank = [int][math]::Ceiling($Percentile / 100 * $readings.Count)
    if ($rank -lt 1) { $rank = 1 }
    if ($rank -gt $readings.Count) { $rank = $readings.Count }
    $readings[$rank - 1]
}

function Get-WorstSeverity {
    <#
    .SYNOPSIS
        The worst of the Severities handed to it, or OK when none was.
    .DESCRIPTION
        Several Checks grade one Finding against two independent measurements - a link that
        loses packets and a link that is slow are both bad links, and a gateway doing both
        is not excused by either. Whichever reads worse wins, which is how the Gutcheck
        script resolved the same disagreement.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(ValueFromRemainingArguments)][AllowNull()][AllowEmptyCollection()]$Severity)

    $worst = 'OK'
    $rank  = $script:SeverityValues.IndexOf('OK')
    foreach ($value in @($Severity | Where-Object { $_ })) {
        $index = $script:SeverityValues.IndexOf([string]$value)
        # $script:SeverityValues is ordered worst first, so a lower index is worse. An
        # unrecognised Severity is ignored rather than allowed to rank as the worst.
        if ($index -ge 0 -and $index -lt $rank) { $rank = $index; $worst = [string]$value }
    }
    $worst
}

function Get-DataCollection {
    <#
    .SYNOPSIS
        A property that should hold a list, as a real array with the holes taken out.
    .DESCRIPTION
        @($null) has one element, not none. So a Judge writing @(Get-DataProperty $Data
        'Crashes').Count against data whose Crashes property is absent - a Gatherer that
        could not read that log, or a CliXML round trip that dropped an empty collection -
        counts one crash that never happened, and reports a machine as unstable because a
        Check failed to run. Every list a Judge reads comes through here.
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [AllowNull()]$InputObject,
        [Parameter(Mandatory)][string]$Name
    )
    , @(@(Get-DataProperty -InputObject $InputObject -Name $Name) | Where-Object { $null -ne $_ })
}