Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Benchmark golden fixtures are asserted byte-for-byte, so they MUST stay LF on every platform.
# This repository uses core.autocrlf=true, which would otherwise rewrite these files to CRLF on
# checkout and break the byte-stability comparisons. Pin the fixture Markdown/CSV/JSON to LF.
SAM.Analytical.Benchmark.Compare.Tests/Fixtures/** text eol=lf
SAM.Analytical.Benchmark.Tests/Fixtures/** text eol=lf

# Recorded benchmark run artefacts are verbatim tool output (LF, UTF-8 no BOM) and must stay byte-exact.
docs/benchmark/reports/** text eol=lf
45 changes: 45 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,36 @@ jobs:

- name: Setup MSBuild
uses: microsoft/setup-msbuild@v2

- name: Ensure the benchmark comparator references no engine assembly
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$proj = "${{ env.REPO_NAME }}/SAM.Analytical.Benchmark.Compare/SAM.Analytical.Benchmark.Compare.csproj"
[xml]$xml = Get-Content $proj
$allowedProjectReference = 'SAM.Analytical.Benchmark.csproj'
$violations = @()

foreach ($node in $xml.SelectNodes('//ProjectReference')) {
$include = [string]$node.Include
if ($include -notlike "*$allowedProjectReference") { $violations += "ProjectReference '$include'" }
}
# The comparator carries NO binary <Reference> and NO NuGet <PackageReference> of its own; the only
# dependency permitted is the neutral schema project. Anything else (an engine DLL HintPath, the
# OpenStudio/TAS interop packages, ...) would couple the comparator to an engine and must fail here.
foreach ($node in $xml.SelectNodes('//Reference')) {
$violations += "binary Reference '$([string]$node.Include)'"
}
foreach ($node in $xml.SelectNodes('//PackageReference')) {
$violations += "PackageReference '$([string]$node.Include)'"
}

if ($violations.Count -gt 0) {
Write-Error ("The benchmark comparator must reference ONLY $allowedProjectReference. Offending references: " + ($violations -join '; '))
exit 1
}
Write-Host "Comparator references only the neutral schema project."

- name: Compute SAMVersion
id: ver
shell: pwsh
Expand Down Expand Up @@ -164,6 +194,21 @@ jobs:
& msbuild $sln /t:Rebuild @common
}

- name: Test benchmark comparator (dotnet test)
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# Portable, engine-free MSTest suite for the B3 comparator (depends only on the neutral schema).
#
# SAMVersion MUST be passed here too. This test project references the schema and comparator
# projects, both of which output to the shared flat `build\` folder, so `dotnet test` rebuilds
# them into the very folder the msbuild step just stamped. Without the property,
# Directory.Build.props falls back to its local-dev default 1.0.0.0 and the correctly versioned
# assemblies are silently overwritten immediately before `Upload build folders` runs.
dotnet test "${{ env.REPO_NAME }}/SAM.Analytical.Benchmark.Compare.Tests/SAM.Analytical.Benchmark.Compare.Tests.csproj" `
-c Release `
"/p:SAMVersion=${{ steps.ver.outputs.samversion }}"

- name: Upload build folders (optional)
uses: actions/upload-artifact@v4
with:
Expand Down
133 changes: 133 additions & 0 deletions SAM.Analytical.Benchmark.Compare.Tests/AlignSpacesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
// Copyright (c) 2020-2026 Michal Dengusiak & Jakub Ziolkowski and contributors

using System.Collections.Generic;
using System.Linq;
using SAM.Analytical.Benchmark;
using SAM.Analytical.Benchmark.Compare;

namespace SAM.Analytical.Benchmark.Compare.Tests
{
[TestClass]
public sealed class AlignSpacesTests
{
private const string GuidA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
private const string GuidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
private const string GuidC = "cccccccccccccccccccccccccccccccc";

[TestMethod]
public void MatchesByGuidEvenWhenNamesDiffer()
{
SpaceAlignment alignment = Query.AlignSpaces(
new[] { Builders.Space(GuidA, "Office", 40, 120) },
new[] { Builders.Space(GuidA, "Office renamed by OpenStudio", 40, 120) });

Assert.AreEqual(1, alignment.Pairs.Count);
Assert.AreEqual(SpaceMatchKind.Guid, alignment.Pairs[0].MatchKind);
Assert.AreEqual(1, alignment.Diagnostics.MatchedByGuid);
Assert.AreEqual(0, alignment.Diagnostics.MatchedByName);
}

[TestMethod]
public void FallsBackToNormalizedNameWhenNoGuid()
{
SpaceAlignment alignment = Query.AlignSpaces(
new[] { Builders.Space(null, "Office 1", 40, 120) },
new[] { Builders.Space(null, " office 1 ", 40, 120) });

Assert.AreEqual(1, alignment.Pairs.Count);
Assert.AreEqual(SpaceMatchKind.Name, alignment.Pairs[0].MatchKind);
Assert.AreEqual(1, alignment.Diagnostics.MatchedByName);
}

[TestMethod]
public void ReportsSpacesPresentOnOnlyOneSide()
{
SpaceAlignment alignment = Query.AlignSpaces(
new[] { Builders.Space(GuidA, "Shared", 40, 120), Builders.Space(GuidB, "TasOnly", 10, 30) },
new[] { Builders.Space(GuidA, "Shared", 40, 120), Builders.Space(GuidC, "OsOnly", 12, 36) });

CollectionAssert.AreEqual(new[] { GuidB }, alignment.Diagnostics.OnlyInTas.ToArray());
CollectionAssert.AreEqual(new[] { GuidC }, alignment.Diagnostics.OnlyInOpenStudio.ToArray());
Assert.AreEqual(1, alignment.Pairs.Count);
}

[TestMethod]
public void DuplicateGuidExcludesEveryMemberFromMatching()
{
SpaceAlignment alignment = Query.AlignSpaces(
new[] { Builders.Space(GuidA, "First", 40, 120), Builders.Space(GuidA, "DuplicateGuid", 41, 121) },
new[] { Builders.Space(GuidA, "Match", 40, 120) });

CollectionAssert.AreEqual(new[] { GuidA }, alignment.Diagnostics.DuplicateTasGuids.ToArray());
// A duplicated GUID is not uniquely identifiable, so NOTHING is matched — not even the OpenStudio
// side that has a single GuidA — rather than arbitrarily aligning the first duplicate.
Assert.AreEqual(0, alignment.Pairs.Count);
Assert.AreEqual(2, alignment.OnlyInTas.Count);
Assert.AreEqual(1, alignment.OnlyInOpenStudio.Count);
}

[TestMethod]
public void DoesNotNameMatchWhenBothSidesHaveDifferentValidGuids()
{
// Same name but two different valid GUIDs are explicit, conflicting identities: they must not be
// merged by name.
SpaceAlignment alignment = Query.AlignSpaces(
new[] { Builders.Space(GuidB, "Office", 40, 120) },
new[] { Builders.Space(GuidC, "Office", 40, 120) });

Assert.AreEqual(0, alignment.Pairs.Count);
CollectionAssert.Contains(alignment.Diagnostics.AmbiguousNameMatches.ToArray(), "OFFICE");
Assert.AreEqual(1, alignment.OnlyInTas.Count);
Assert.AreEqual(1, alignment.OnlyInOpenStudio.Count);
}

[TestMethod]
public void NameMatchesWhenOneSideLacksAGuid()
{
// One side lacks a usable GUID, so name fallback is allowed and adopts the available GUID.
SpaceAlignment alignment = Query.AlignSpaces(
new[] { Builders.Space(null, "Office", 40, 120) },
new[] { Builders.Space(GuidC, "Office", 40, 120) });

Assert.AreEqual(1, alignment.Pairs.Count);
Assert.AreEqual(SpaceMatchKind.Name, alignment.Pairs[0].MatchKind);
Assert.AreEqual(GuidC, alignment.Pairs[0].Guid);
}

[TestMethod]
public void DiagnosesAmbiguousSplitNameInsteadOfGuessing()
{
SpaceAlignment alignment = Query.AlignSpaces(
new[] { Builders.Space(null, "Room", 40, 120) },
new[] { Builders.Space(null, "Room", 20, 60), Builders.Space(null, "room", 20, 60) });

Assert.AreEqual(0, alignment.Pairs.Count);
CollectionAssert.Contains(alignment.Diagnostics.AmbiguousNameMatches.ToArray(), "ROOM");
CollectionAssert.Contains(alignment.Diagnostics.DuplicateOpenStudioNames.ToArray(), "ROOM");
Assert.AreEqual(1, alignment.OnlyInTas.Count);
Assert.AreEqual(2, alignment.OnlyInOpenStudio.Count);
}

[TestMethod]
public void AlignmentIsDeterministicRegardlessOfInputOrder()
{
List<BenchmarkSpaceResult> tas = new()
{
Builders.Space(GuidB, "B", 10, 30),
Builders.Space(GuidA, "A", 40, 120)
};
List<BenchmarkSpaceResult> openStudio = new()
{
Builders.Space(GuidA, "A", 40, 120),
Builders.Space(GuidB, "B", 10, 30)
};

SpaceAlignment alignment = Query.AlignSpaces(tas, openStudio);

// GUID matches are ordered by ordinal GUID, so A precedes B irrespective of the source order.
Assert.AreEqual(GuidA, alignment.Pairs[0].Guid);
Assert.AreEqual(GuidB, alignment.Pairs[1].Guid);
}
}
}
195 changes: 195 additions & 0 deletions SAM.Analytical.Benchmark.Compare.Tests/BandTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
// Copyright (c) 2020-2026 Michal Dengusiak & Jakub Ziolkowski and contributors

using SAM.Analytical.Benchmark;
using SAM.Analytical.Benchmark.Compare;

namespace SAM.Analytical.Benchmark.Compare.Tests
{
[TestClass]
public sealed class BandTests
{
private static readonly ToleranceProfile Profile = ToleranceProfile.Default;

private static MetricComparison Band(MetricValue? tas, MetricValue? openStudio)
{
return Query.Band("model", "metric", tas, openStudio, Profile);
}

[TestMethod]
public void ExactMatchIsMatchWithZeroDifference()
{
MetricComparison metric = Band(Builders.Value(100, MetricUnit.KilowattHour), Builders.Value(100, MetricUnit.KilowattHour));

Assert.AreEqual(ComparisonBand.Match, metric.Band);
Assert.AreEqual(0d, metric.AbsoluteDifference);
Assert.AreEqual(0d, metric.RelativeDifference);
Assert.AreEqual(ComparisonAvailability.Both, metric.Availability);
}

[TestMethod]
public void WithinWarnBandIsMatch()
{
// 100 -> 104: scale = max(100, 104, floor 1) = 104, so rel = 4/104 = 3.85% < 5% warn => match.
MetricComparison metric = Band(Builders.Value(100, MetricUnit.KilowattHour), Builders.Value(104, MetricUnit.KilowattHour));

Assert.AreEqual(ComparisonBand.Match, metric.Band);
Assert.AreEqual(4d, metric.AbsoluteDifference);
Assert.AreEqual(4d, metric.SignedDifference);
}

[TestMethod]
public void BetweenWarnAndFailIsWarn()
{
// 100 -> 112: scale = 112, so rel = 12/112 = 10.7% => between the 5% warn and 15% fail bands.
MetricComparison metric = Band(Builders.Value(100, MetricUnit.KilowattHour), Builders.Value(112, MetricUnit.KilowattHour));

Assert.AreEqual(ComparisonBand.Warn, metric.Band);
}

[TestMethod]
public void BeyondFailBandIsFail()
{
// 100 -> 140: scale = 140, so rel = 40/140 = 28.6% >= the 15% fail band.
MetricComparison metric = Band(Builders.Value(100, MetricUnit.KilowattHour), Builders.Value(140, MetricUnit.KilowattHour));

Assert.AreEqual(ComparisonBand.Fail, metric.Band);
}

[TestMethod]
public void NearZeroFloorBoundsTheDenominatorWithoutMaskingDisagreement()
{
// The floor is the SCALE lower bound, not an absolute allowance. With the 10 W default floor:
// 0.1 vs 0.3 W is a negligible 0.2 W difference -> 0.2/10 = 2% -> Match (the floor tames what
// would otherwise be a 200% relative difference)...
MetricComparison negligible = Band(Builders.Value(0.1, MetricUnit.Watt), Builders.Value(0.3, MetricUnit.Watt));
Assert.AreEqual(ComparisonBand.Match, negligible.Band);

// ...but 0 W vs 8 W is 8/10 = 80% and must NOT be masked into a match by the floor.
MetricComparison disagreement = Band(Builders.Value(0, MetricUnit.Watt), Builders.Value(8, MetricUnit.Watt));
Assert.AreEqual(ComparisonBand.Fail, disagreement.Band);
}

[DataTestMethod]
[DataRow(0d, 0d, ComparisonBand.Match)]
[DataRow(0d, 4d, ComparisonBand.Match)]
[DataRow(0d, 10d, ComparisonBand.Warn)]
[DataRow(0d, 20d, ComparisonBand.Fail)]
public void NearZeroRuleMatchesToleranceContractExamples(double tas, double openStudio, ComparisonBand expected)
{
// Reproduces the TOLERANCES.md near-zero table for a hypothetical 100 W floor: the effective
// relative difference is measured against the floor, so 4/10/20 W land in Pass/Warn/Fail.
var floors = new Dictionary<MetricUnit, double> { [MetricUnit.Watt] = 100 };
var profile = new ToleranceProfile("contract-example", 0.05, 0.15, 1, 24, floors, 0);

MetricComparison metric = Query.Band("model", "metric", Builders.Value(tas, MetricUnit.Watt), Builders.Value(openStudio, MetricUnit.Watt), profile);

Assert.AreEqual(expected, metric.Band);
}

[TestMethod]
public void UnavailableOnOneSideIsNotApplicableNeverFail()
{
MetricComparison metric = Band(Builders.Value(100, MetricUnit.KilowattHour), MetricValue.Unavailable(MetricUnit.KilowattHour));

Assert.AreEqual(ComparisonBand.NotApplicable, metric.Band);
Assert.AreEqual(NotApplicableReason.Unavailable, metric.NotApplicableReason);
Assert.AreEqual(ComparisonAvailability.TasOnly, metric.Availability);
Assert.IsNull(metric.AbsoluteDifference);
Assert.IsNull(metric.RelativeDifference);
}

[TestMethod]
public void UnavailableOnBothSidesIsNotApplicable()
{
MetricComparison metric = Band(MetricValue.Unavailable(MetricUnit.Watt), MetricValue.Unavailable(MetricUnit.Watt));

Assert.AreEqual(ComparisonBand.NotApplicable, metric.Band);
Assert.AreEqual(NotApplicableReason.Unavailable, metric.NotApplicableReason);
Assert.AreEqual(ComparisonAvailability.Neither, metric.Availability);
}

[TestMethod]
public void UnitMismatchIsNotApplicableEvenWhenBothAvailable()
{
MetricComparison metric = Band(Builders.Value(100, MetricUnit.KilowattHour), Builders.Value(100, MetricUnit.WattHour));

Assert.AreEqual(ComparisonBand.NotApplicable, metric.Band);
Assert.AreEqual(NotApplicableReason.UnitMismatch, metric.NotApplicableReason);
Assert.AreEqual(MetricUnit.KilowattHour, metric.TasUnit);
Assert.AreEqual(MetricUnit.WattHour, metric.OpenStudioUnit);
}

[TestMethod]
public void CircularPeakHourAcrossYearBoundaryMatches()
{
// Hour 8759 (last hour) vs hour 0 (first hour) are one hour apart, not 8759.
MetricComparison metric = Band(Builders.Value(8759, MetricUnit.HourOfYear), Builders.Value(0, MetricUnit.HourOfYear));

Assert.IsTrue(metric.Circular);
Assert.AreEqual(1d, metric.AbsoluteDifference);
Assert.AreEqual(ComparisonBand.Match, metric.Band);
Assert.IsNull(metric.RelativeDifference);
Assert.IsNull(metric.SignedDifference);
}

[TestMethod]
public void PeakHourWithinFailWindowWarns()
{
// 10h apart: beyond the profile's 1h warn threshold, within its 24h fail threshold.
MetricComparison metric = Band(Builders.Value(100, MetricUnit.HourOfYear), Builders.Value(110, MetricUnit.HourOfYear));

Assert.AreEqual(10d, metric.AbsoluteDifference);
Assert.AreEqual(ComparisonBand.Warn, metric.Band);
}

[TestMethod]
public void PeakHourBeyondFailWindowReportsTheFailBand()
{
// TOLERANCES.md requires absolute warn/fail hour thresholds, so a 100h gap must NOT be reported
// identically to a 10h gap. Peak-hour differences stay informational because hour-of-year metrics
// are excluded from the numerical gate (Query.NumericalStatus), not by suppressing this band.
MetricComparison metric = Band(Builders.Value(100, MetricUnit.HourOfYear), Builders.Value(200, MetricUnit.HourOfYear));

Assert.AreEqual(100d, metric.AbsoluteDifference);
Assert.AreEqual(ComparisonBand.Fail, metric.Band);
Assert.AreEqual(GateStatus.Pass, Query.NumericalStatus(new[] { metric }));
}

[DataTestMethod]
// Informational: every hour-of-year metric, plus the two whole-model peak loads.
[DataRow("model", "peakHeatingLoad", MetricUnit.Kilowatt, true)]
[DataRow("model", "peakCoolingLoad", MetricUnit.Kilowatt, true)]
[DataRow("model", "peakHeatingHour", MetricUnit.HourOfYear, true)]
[DataRow("model", "peakCoolingHour", MetricUnit.HourOfYear, true)]
[DataRow("a-space-guid", "heating.peakHour", MetricUnit.HourOfYear, true)]
// Gating: annual energy, per-space peak/design loads, unmet hours, geometry.
[DataRow("model", "consumptionHeating", MetricUnit.KilowattHour, false)]
[DataRow("model", "consumptionCooling", MetricUnit.KilowattHour, false)]
[DataRow("model", "floorArea", MetricUnit.SquareMetre, false)]
[DataRow("model", "volume", MetricUnit.CubicMetre, false)]
[DataRow("a-space-guid", "heating.peakLoad", MetricUnit.Watt, false)]
[DataRow("a-space-guid", "cooling.peakLoad", MetricUnit.Watt, false)]
[DataRow("a-space-guid", "heating.designLoad", MetricUnit.Watt, false)]
[DataRow("a-space-guid", "cooling.unmetHours", MetricUnit.Hour, false)]
// A space that happens to be NAMED "model" is not captured: its keys are the dotted per-space ones.
[DataRow("model", "heating.peakLoad", MetricUnit.Watt, false)]
public void OnlyDesignatedMetricsAreInformationalForTheNumericalGate(string scope, string key, MetricUnit unit, bool expected)
{
MetricComparison metric = Query.Band(scope, key, Builders.Value(10, unit), Builders.Value(11, unit), Profile);

Assert.AreEqual(expected, Query.IsInformationalForNumericalGate(metric));
}

[DataTestMethod]
[DataRow(8759d, 0d, 1d)]
[DataRow(0d, 8759d, 1d)]
[DataRow(10d, 20d, 10d)]
[DataRow(100d, 8750d, 110d)]
[DataRow(0d, 4380d, 4380d)]
public void CircularHourDiffWrapsAtYearBoundary(double a, double b, double expected)
{
Assert.AreEqual(expected, Query.CircularHourDiff(a, b));
}
}
}
Loading
Loading