diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index d876d08..d223d13 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -295,6 +295,29 @@ jobs:
if ($LASTEXITCODE -ne 0) { throw "Rebuild failed: $($it.Repo)" }
}
+ - name: Check S11 results read-back projects are built
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+
+ $repo = '${{ env.REPO_NAME }}'
+
+ # S11: the reference library and its CLI are members of SAM_OpenStudio.sln and were rebuilt with it above.
+ # Fail if either is missing from the solution or produced no output.
+ $listed = @(& dotnet sln (Join-Path $repo 'SAM_OpenStudio.sln') list)
+ if ($LASTEXITCODE -ne 0) { throw "dotnet sln list failed" }
+ foreach ($p in @('SAM.Analytical.OpenStudio.ResultsReadBack', 'SAM.Analytical.OpenStudio.ResultsReadBack.Cli')) {
+ if (-not ($listed | Where-Object { $_.Trim() -eq "benchmark\$p\$p.csproj" })) { throw "$p is not in SAM_OpenStudio.sln" }
+ }
+ foreach ($f in @(
+ 'benchmark/SAM.Analytical.OpenStudio.ResultsReadBack/bin/x64/Release/net8.0/SAM.Analytical.OpenStudio.ResultsReadBack.dll',
+ 'benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/bin/x64/Release/net8.0/os-results-readback.dll'
+ )) {
+ $path = Join-Path $repo $f
+ if (-not (Test-Path $path)) { throw "Expected S11 build output missing: $path" }
+ Write-Host "S11 build output present: $path"
+ }
+
- name: Run benchmark producer tests (offline)
shell: pwsh
run: |
@@ -316,7 +339,7 @@ jobs:
--logger 'console;verbosity=normal'
if ($LASTEXITCODE -ne 0) { throw "Benchmark producer tests failed" }
- - name: Run S09 OpenStudio native-input read-back tests (offline)
+ - name: Run S09 native-input and S11 results read-back tests (offline)
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
@@ -325,6 +348,7 @@ jobs:
# S09: the writer's ventilation policy and the fresh-process OSM/IDF read-back on the S08 signed-off fixtures with
# the SAM#212 Budapest EPW (from the SAM sibling). OpenStudio SDK NuGet only: no CLI, no EnergyPlus run.
+ # S11: the results round-trip reference on synthetic SQL/ESO files keyed by the S09 exports (no EnergyPlus run).
$testProject = Join-Path $repo 'benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/SAM.Analytical.OpenStudio.InputReadBack.Tests.csproj'
Write-Host "==> Testing: $testProject"
& dotnet test $testProject `
@@ -332,7 +356,7 @@ jobs:
'/p:Platform=x64' `
'/p:SAMVersion=${{ steps.ver.outputs.samversion }}' `
--logger 'console;verbosity=normal'
- if ($LASTEXITCODE -ne 0) { throw "S09 read-back tests failed" }
+ if ($LASTEXITCODE -ne 0) { throw "S09/S11 read-back tests failed" }
- name: Upload S09 failure diagnostics
# Only when a step failed: the bounded text evidence (exit code, stdout/stderr, process identities, file list) the
diff --git a/SAM_OpenStudio.sln b/SAM_OpenStudio.sln
index 6091aa3..9bccb8b 100644
--- a/SAM_OpenStudio.sln
+++ b/SAM_OpenStudio.sln
@@ -38,6 +38,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SAM.Analytical.OpenStudio.I
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SAM.Analytical.OpenStudio.InputReadBack.Tests", "benchmark\SAM.Analytical.OpenStudio.InputReadBack.Tests\SAM.Analytical.OpenStudio.InputReadBack.Tests.csproj", "{3F2CC081-C850-4FE7-BEF7-E7BFC5699603}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SAM.Analytical.OpenStudio.ResultsReadBack", "benchmark\SAM.Analytical.OpenStudio.ResultsReadBack\SAM.Analytical.OpenStudio.ResultsReadBack.csproj", "{7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C301}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SAM.Analytical.OpenStudio.ResultsReadBack.Cli", "benchmark\SAM.Analytical.OpenStudio.ResultsReadBack.Cli\SAM.Analytical.OpenStudio.ResultsReadBack.Cli.csproj", "{7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C302}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -180,6 +184,30 @@ Global
{3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Release|x64.Build.0 = Release|x64
{3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Release|x86.ActiveCfg = Release|x64
{3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Release|x86.Build.0 = Release|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C301}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C301}.Debug|Any CPU.Build.0 = Debug|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C301}.Debug|x64.ActiveCfg = Debug|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C301}.Debug|x64.Build.0 = Debug|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C301}.Debug|x86.ActiveCfg = Debug|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C301}.Debug|x86.Build.0 = Debug|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C301}.Release|Any CPU.ActiveCfg = Release|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C301}.Release|Any CPU.Build.0 = Release|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C301}.Release|x64.ActiveCfg = Release|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C301}.Release|x64.Build.0 = Release|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C301}.Release|x86.ActiveCfg = Release|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C301}.Release|x86.Build.0 = Release|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C302}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C302}.Debug|Any CPU.Build.0 = Debug|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C302}.Debug|x64.ActiveCfg = Debug|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C302}.Debug|x64.Build.0 = Debug|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C302}.Debug|x86.ActiveCfg = Debug|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C302}.Debug|x86.Build.0 = Debug|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C302}.Release|Any CPU.ActiveCfg = Release|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C302}.Release|Any CPU.Build.0 = Release|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C302}.Release|x64.ActiveCfg = Release|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C302}.Release|x64.Build.0 = Release|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C302}.Release|x86.ActiveCfg = Release|x64
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C302}.Release|x86.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -196,6 +224,8 @@ Global
{EBF33B94-81C3-4D70-BD25-F10C0E119B6F} = {09EED85C-BE3C-7566-DC0E-2E8E43466740}
{850D2D75-52C9-4322-B233-234F72981C39} = {09EED85C-BE3C-7566-DC0E-2E8E43466740}
{3F2CC081-C850-4FE7-BEF7-E7BFC5699603} = {09EED85C-BE3C-7566-DC0E-2E8E43466740}
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C301} = {09EED85C-BE3C-7566-DC0E-2E8E43466740}
+ {7A1F3C52-0B6E-4D8A-9C21-5E11A0B7C302} = {09EED85C-BE3C-7566-DC0E-2E8E43466740}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {4E0308FF-C8D5-4700-951A-859CB9D4FD09}
diff --git a/benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/DiagnosticPropagationTests.cs b/benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/DiagnosticPropagationTests.cs
index c56a3e3..975fad2 100644
--- a/benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/DiagnosticPropagationTests.cs
+++ b/benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/DiagnosticPropagationTests.cs
@@ -234,7 +234,8 @@ public void NullAndEmptyDiagnostics_LeaveValidEmptyArrays()
BenchmarkDocument document = model.ToBenchmark(emptyDiagnostics);
Assert.That(document.Provenance.Warnings, Is.Not.Null.And.Empty, "Empty arrays still serialize as valid empty arrays");
- Assert.That(document.Provenance.Notes, Is.Not.Null.And.Empty);
+ // S11 F3: the only note on a successful document with no diagnostics is the Total-basis label.
+ Assert.That(document.Provenance.Notes, Is.EqualTo(new[] { Modify.TotalBasisNote }));
Assert.That(BenchmarkValidator.Validate(document).IsValid, Is.True, "An empty-diagnostic document stays schema-valid");
}
diff --git a/benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/Fixtures/golden-openstudio-benchmark.json b/benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/Fixtures/golden-openstudio-benchmark.json
index 2140dd8..594d770 100644
--- a/benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/Fixtures/golden-openstudio-benchmark.json
+++ b/benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/Fixtures/golden-openstudio-benchmark.json
@@ -27,7 +27,9 @@
"SAM.Analytical.OpenStudio"
],
"warnings": [],
- "notes": []
+ "notes": [
+ "Cooling metrics are EnergyPlus Ideal Loads Supply Air Total cooling (sensible \u002B latent); heating is Total heating."
+ ]
},
"model": {
"consumptionHeating": {
diff --git a/benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/S11ProducerTests.cs b/benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/S11ProducerTests.cs
new file mode 100644
index 0000000..76df9b5
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/S11ProducerTests.cs
@@ -0,0 +1,161 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020-2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json.Nodes;
+using NUnit.Framework;
+using SAM.Analytical.Benchmark;
+
+namespace SAM.Analytical.OpenStudio.Benchmark.Tests
+{
+ ///
+ /// S11 producer rules: the all-zero peak rule, unavailable consumption without zone keys, the F3 Total-basis note, the
+ /// three-argument mapping of exactly the captured instances, the closure of the --sam-results file, and its CLI guard.
+ ///
+ [TestFixture]
+ public class S11ProducerTests
+ {
+ private static OpenStudioSimulationResultSet ResultSet(double coolingPeakKw, int coolingHour, double coolingKwh, bool coolingKeyed = true, bool heatingKeyed = true)
+ {
+ string key = BenchmarkFixture.IdealLoadsKey();
+ Dictionary empty = new Dictionary();
+ return new OpenStudioSimulationResultSet(
+ heatingKeyed ? new Dictionary { { key, 10.5 } } : empty,
+ coolingKeyed ? new Dictionary { { key, coolingKwh } } : empty,
+ new Dictionary { { key, 3.25 } },
+ new Dictionary { { key, coolingPeakKw } },
+ new Dictionary { { key, 100 } },
+ new Dictionary { { key, coolingHour } },
+ 3.25, 100, coolingPeakKw, coolingHour,
+ new Dictionary { { key, 2.0 } },
+ new Dictionary { { key, 0.0 } },
+ empty, empty, empty, empty, empty, empty, empty,
+ null, null, null, 1.0, 0, 0, 0);
+ }
+
+ private static BenchmarkDocument Document(OpenStudioSimulationResultSet resultSet, RunState state = RunState.Success)
+ {
+ AnalyticalModel model = BenchmarkFixture.SingleSpaceModel();
+ OpenStudioBenchmarkContext context = BenchmarkFixture.Context(model, resultSet);
+ context.State = state;
+ return model.ToBenchmark(context);
+ }
+
+ [Test]
+ public void AllZeroPeak_LoadIsAMeasuredZero_HourIsUnavailable()
+ {
+ BenchmarkDocument document = Document(ResultSet(0, 0, 0));
+ BenchmarkConditionResult cooling = document.Spaces.Single().Cooling;
+
+ Assert.That(cooling.PeakLoad.Available, Is.True);
+ Assert.That(cooling.PeakLoad.Value, Is.EqualTo(0));
+ Assert.That(cooling.PeakHour.Available, Is.False, "an all-zero year ties at every hour: no peak hour");
+ Assert.That(document.Model.PeakCoolingLoad.Value, Is.EqualTo(0));
+ Assert.That(document.Model.PeakCoolingHour.Available, Is.False);
+ Assert.That(document.Model.ConsumptionCooling.Available, Is.True, "a keyed zero consumption is a measured 0 kWh");
+ Assert.That(document.Model.ConsumptionCooling.Value, Is.EqualTo(0));
+ Assert.That(document.Spaces.Single().Heating.PeakHour.Value, Is.EqualTo(100), "a non-zero peak keeps its hour");
+ Assert.That(BenchmarkValidator.Validate(document).IsValid, Is.True);
+ }
+
+ [Test]
+ public void NoZoneKey_ConsumptionIsUnavailable_NotZero()
+ {
+ BenchmarkDocument document = Document(ResultSet(2.4, 4602, 0, coolingKeyed: false, heatingKeyed: false));
+
+ Assert.That(document.Model.ConsumptionCooling.Available, Is.False);
+ Assert.That(document.Model.ConsumptionCooling.Value, Is.Null);
+ Assert.That(document.Model.ConsumptionHeating.Available, Is.False);
+ Assert.That(BenchmarkValidator.Validate(document).IsValid, Is.True);
+ }
+
+ [Test]
+ public void TotalBasisNote_OnSuccessfulDocumentsOnly_ExactlyOnce()
+ {
+ BenchmarkDocument success = Document(BenchmarkFixture.ResultSet());
+ Assert.That(success.Provenance.Notes.Count(x => x == Modify.TotalBasisNote), Is.EqualTo(1));
+ Assert.That(Modify.TotalBasisNote, Is.EqualTo("Cooling metrics are EnergyPlus Ideal Loads Supply Air Total cooling (sensible + latent); heating is Total heating."));
+
+ AnalyticalModel model = BenchmarkFixture.SingleSpaceModel();
+ OpenStudioBenchmarkContext again = BenchmarkFixture.Context(model, BenchmarkFixture.ResultSet());
+ again.Notes.Add(Modify.TotalBasisNote);
+ Assert.That(model.ToBenchmark(again).Provenance.Notes.Count(x => x == Modify.TotalBasisNote), Is.EqualTo(1), "a context already carrying the note keeps one");
+
+ BenchmarkDocument failure = Document(null, RunState.Failure);
+ Assert.That(failure.Provenance.Notes, Has.None.EqualTo(Modify.TotalBasisNote));
+ }
+
+ [Test]
+ public void ThreeArgumentToBenchmark_MapsExactlyTheGivenInstances()
+ {
+ AnalyticalModel model = BenchmarkFixture.SingleSpaceModel();
+ OpenStudioBenchmarkContext context = BenchmarkFixture.Context(model, BenchmarkFixture.ResultSet());
+ OpenStudioBenchmarkInputs inputs = model.BenchmarkInputs(context);
+
+ Assert.That(BenchmarkSerializer.Serialize(model.ToBenchmark(context, inputs)), Is.EqualTo(BenchmarkSerializer.Serialize(model.ToBenchmark(context))), "the two-argument form is the three-argument form on fresh inputs");
+
+ inputs.SpaceResults.First(x => x.TryGetValue(Analytical.SpaceSimulationResultParameter.LoadType, out string t) && t == "Cooling").SetValue(Analytical.SpaceSimulationResultParameter.Load, 1111.0);
+ inputs.ModelResult.SetValue(AnalyticalModelSimulationResultParameter.ConsumptionHeating, 77.0);
+ BenchmarkDocument document = model.ToBenchmark(context, inputs);
+ Assert.That(document.Spaces.Single().Cooling.PeakLoad.Value, Is.EqualTo(1111.0), "the document follows the instances it is given, not the context's result set");
+ Assert.That(document.Model.ConsumptionHeating.Value, Is.EqualTo(77.0));
+ }
+
+ [Test]
+ public void SamResultsFile_RoundTrips_AndReMapsToByteIdenticalModelAndSpaces()
+ {
+ AnalyticalModel model = BenchmarkFixture.SingleSpaceModel();
+ OpenStudioBenchmarkContext context = BenchmarkFixture.Context(model, BenchmarkFixture.ResultSet());
+ OpenStudioBenchmarkInputs inputs = model.BenchmarkInputs(context);
+ BenchmarkDocument produced = model.ToBenchmark(context, inputs);
+
+ string path = Path.Combine(TestContext.CurrentContext.WorkDirectory, "s11-sam-results-" + Guid.NewGuid().ToString("N").Substring(0, 6) + ".json");
+ StringWriter output = new StringWriter();
+ Program.WriteSamResults(inputs, path, output);
+ Assert.That(output.ToString(), Does.Contain(BenchmarkHash.ComputeSha256(File.ReadAllBytes(path))));
+ byte[] bytes = File.ReadAllBytes(path);
+ Assert.That(bytes.Take(3), Is.Not.EqualTo(new byte[] { 0xEF, 0xBB, 0xBF }), "UTF-8 without BOM");
+ Assert.That(System.Text.Encoding.UTF8.GetString(bytes), Does.Not.Contain("\r\n"));
+
+ OpenStudioBenchmarkInputs read = OpenStudioBenchmarkInputs.Read(path);
+ BenchmarkDocument remapped = model.ToBenchmark(context, read);
+ JsonObject a = JsonNode.Parse(BenchmarkSerializer.Serialize(produced)) as JsonObject;
+ JsonObject b = JsonNode.Parse(BenchmarkSerializer.Serialize(remapped)) as JsonObject;
+ Assert.That(b["model"].ToJsonString(), Is.EqualTo(a["model"].ToJsonString()));
+ Assert.That(b["spaces"].ToJsonString(), Is.EqualTo(a["spaces"].ToJsonString()));
+ Assert.That(read.HeatingConsumptionKeyed && read.CoolingConsumptionKeyed, Is.True);
+
+ read.SpaceResults[0].SetValue(Analytical.SpaceSimulationResultParameter.Load, 1.0);
+ JsonObject altered = JsonNode.Parse(BenchmarkSerializer.Serialize(model.ToBenchmark(context, read))) as JsonObject;
+ Assert.That(altered["spaces"].ToJsonString(), Is.Not.EqualTo(a["spaces"].ToJsonString()), "an altered object no longer closes");
+ }
+
+ [Test]
+ public void SamResultsFile_ForeignSchema_IsRejected()
+ {
+ Assert.Throws(() => OpenStudioBenchmarkInputs.FromJsonObject(new JsonObject { ["schema"] = "other/1" }));
+ }
+
+ [Test]
+ public void SamResultsSameAsOut_IsAUsageError_BeforeAnyRun()
+ {
+ string directory = Path.Combine(TestContext.CurrentContext.WorkDirectory, "s11-cli-" + Guid.NewGuid().ToString("N").Substring(0, 6));
+ Directory.CreateDirectory(directory);
+ string model = Path.Combine(directory, "model.json");
+ string weather = Path.Combine(directory, "weather.epw");
+ File.WriteAllText(model, "{}");
+ File.WriteAllText(weather, "x");
+ string output = Path.Combine(directory, "b1a.json");
+
+ StringWriter standardOutput = new StringWriter();
+ StringWriter standardError = new StringWriter();
+ int exit = Program.Run(new[] { "--model", model, "--weather", weather, "--out", output, "--sam-results", output }, standardOutput, standardError);
+
+ Assert.That(exit, Is.EqualTo((int)BenchmarkExitCode.InvalidUsage), standardError.ToString());
+ Assert.That(File.Exists(output), Is.False);
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.Benchmark/Classes/OpenStudioBenchmarkInputs.cs b/benchmark/SAM.Analytical.OpenStudio.Benchmark/Classes/OpenStudioBenchmarkInputs.cs
new file mode 100644
index 0000000..41dbad7
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.Benchmark/Classes/OpenStudioBenchmarkInputs.cs
@@ -0,0 +1,107 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020-2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace SAM.Analytical.OpenStudio.Benchmark
+{
+ ///
+ /// The SAM result objects a B1a document is mapped from: the model result and the per-space results the established
+ /// SAM mappings produced from the run's result set, the per-space design-load results, and whether the result set
+ /// carried any zone key for each annual consumption. builds them once and
+ /// maps exactly
+ /// these instances, so the objects written with --sam-results are the ones that produced the document (S11).
+ ///
+ public sealed class OpenStudioBenchmarkInputs
+ {
+ /// Schema of the --sam-results file.
+ public const string Schema = "s11-sam-results/1";
+
+ /// The model result (consumption, coincident peaks, floor area, volume); null for a failed run.
+ public AnalyticalModelSimulationResult ModelResult { get; set; }
+
+ /// Per-space, per-load-type annual results (peak load, its hour, unmet hours).
+ public List SpaceResults { get; set; } = new List();
+
+ /// Per-space design-day (ZoneSizes) results; empty when no sizing ran.
+ public List DesignLoadResults { get; set; } = new List();
+
+ ///
+ /// True when the result set carried at least one zone key for annual heating. Without one the model's heating
+ /// consumption is missing, never a measured 0 kWh (the result set's empty sum).
+ ///
+ public bool HeatingConsumptionKeyed { get; set; }
+
+ /// As , for annual cooling.
+ public bool CoolingConsumptionKeyed { get; set; }
+
+ /// The objects as SAM JSON inside a small envelope (the --sam-results file).
+ public JsonObject ToJsonObject()
+ {
+ JsonArray spaceResults = new JsonArray();
+ foreach (SpaceSimulationResult spaceResult in SpaceResults ?? new List())
+ {
+ spaceResults.Add(spaceResult?.ToJsonObject());
+ }
+
+ JsonArray designLoadResults = new JsonArray();
+ foreach (SpaceSimulationResult designLoadResult in DesignLoadResults ?? new List())
+ {
+ designLoadResults.Add(designLoadResult?.ToJsonObject());
+ }
+
+ return new JsonObject
+ {
+ ["schema"] = Schema,
+ ["heatingConsumptionKeyed"] = HeatingConsumptionKeyed,
+ ["coolingConsumptionKeyed"] = CoolingConsumptionKeyed,
+ ["modelResult"] = ModelResult?.ToJsonObject(),
+ ["spaceResults"] = spaceResults,
+ ["designLoadResults"] = designLoadResults,
+ };
+ }
+
+ /// Reads an envelope written by ; throws for another schema.
+ public static OpenStudioBenchmarkInputs FromJsonObject(JsonObject jsonObject)
+ {
+ if (jsonObject == null || (string)jsonObject["schema"] != Schema)
+ {
+ throw new JsonException("Not a " + Schema + " document.");
+ }
+
+ return new OpenStudioBenchmarkInputs
+ {
+ HeatingConsumptionKeyed = (bool)jsonObject["heatingConsumptionKeyed"],
+ CoolingConsumptionKeyed = (bool)jsonObject["coolingConsumptionKeyed"],
+ ModelResult = jsonObject["modelResult"] is JsonObject model ? Core.Create.IJSAMObject(model) : null,
+ SpaceResults = Results(jsonObject["spaceResults"] as JsonArray),
+ DesignLoadResults = Results(jsonObject["designLoadResults"] as JsonArray),
+ };
+ }
+
+ /// Writes the envelope as indented UTF-8 (no BOM) with LF line ends.
+ public void Write(string path)
+ {
+ string json = ToJsonObject().ToJsonString(new JsonSerializerOptions { WriteIndented = true }).Replace("\r\n", "\n") + "\n";
+ File.WriteAllBytes(path, new UTF8Encoding(false).GetBytes(json));
+ }
+
+ /// Reads a file written by .
+ public static OpenStudioBenchmarkInputs Read(string path)
+ {
+ return FromJsonObject(JsonNode.Parse(File.ReadAllText(path)) as JsonObject);
+ }
+
+ private static List Results(JsonArray jsonArray)
+ {
+ return jsonArray == null
+ ? new List()
+ : jsonArray.OfType().Select(x => Core.Create.IJSAMObject(x)).ToList();
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.Benchmark/Modify/ToBenchmark.cs b/benchmark/SAM.Analytical.OpenStudio.Benchmark/Modify/ToBenchmark.cs
index 31a97c2..99016a6 100644
--- a/benchmark/SAM.Analytical.OpenStudio.Benchmark/Modify/ToBenchmark.cs
+++ b/benchmark/SAM.Analytical.OpenStudio.Benchmark/Modify/ToBenchmark.cs
@@ -26,11 +26,34 @@ public static partial class Modify
/// missing or out of range is available:false with a null value. A measured zero
/// stays a value of 0. Peak load and its hour are coupled: a peak with no valid hour-of-year
/// is emitted as unavailable rather than a misleading zero.
+ ///
+ /// S11 rules: an all-zero peak (load exactly 0) is a measured load of 0 with NO hour — every
+ /// hour of the year ties, so no hour is the peak hour (the SAM result's hour 0 is the earliest
+ /// of that full-year tie and is not published). An annual consumption whose result set carried
+ /// no zone key is unavailable, not 0 kWh. A successful document carries the
+ /// (F3): cooling and heating are the EnergyPlus Ideal Loads
+ /// supply-air Total energies, cooling including latent.
+ ///
///
/// The neutral source model (provides GUIDs, names, areas and volumes).
/// Run context: result set, hashes and provenance.
/// The benchmark document, or null when either argument is null.
public static BenchmarkDocument ToBenchmark(this AnalyticalModel analyticalModel, OpenStudioBenchmarkContext openStudioBenchmarkContext)
+ {
+ return ToBenchmark(analyticalModel, openStudioBenchmarkContext, BenchmarkInputs(analyticalModel, openStudioBenchmarkContext));
+ }
+
+ ///
+ /// The F3 basis label on every successful OpenStudio B1a document. The values are not changed:
+ /// the route always reported the Ideal Loads Total energies; this states it.
+ ///
+ public const string TotalBasisNote = "Cooling metrics are EnergyPlus Ideal Loads Supply Air Total cooling (sensible + latent); heating is Total heating.";
+
+ ///
+ /// The SAM result objects the document is mapped from, built once from the context's result set
+ /// through the established SAM mappings. Null when either argument is null.
+ ///
+ public static OpenStudioBenchmarkInputs BenchmarkInputs(this AnalyticalModel analyticalModel, OpenStudioBenchmarkContext openStudioBenchmarkContext)
{
if (analyticalModel == null || openStudioBenchmarkContext == null)
{
@@ -38,20 +61,49 @@ public static BenchmarkDocument ToBenchmark(this AnalyticalModel analyticalModel
}
OpenStudioSimulationResultSet resultSet = openStudioBenchmarkContext.ResultSet;
- AnalyticalModelSimulationResult modelResult = resultSet?.ToSAM(analyticalModel);
- List spaceResults = resultSet?.ToSAM_SpaceSimulationResults(analyticalModel) ?? new List();
+ return new OpenStudioBenchmarkInputs
+ {
+ ModelResult = resultSet?.ToSAM(analyticalModel),
+ SpaceResults = resultSet?.ToSAM_SpaceSimulationResults(analyticalModel) ?? new List(),
+ DesignLoadResults = openStudioBenchmarkContext.SpaceDesignLoadResults?.ToList() ?? new List(),
+ HeatingConsumptionKeyed = resultSet?.AnnualHeatingEnergy.Count > 0,
+ CoolingConsumptionKeyed = resultSet?.AnnualCoolingEnergy.Count > 0,
+ };
+ }
+
+ ///
+ /// Maps exactly the given instances (S11: the objects
+ /// that produce the document are the ones captured beside it). The context supplies provenance only.
+ ///
+ /// The benchmark document, or null when any argument is null.
+ public static BenchmarkDocument ToBenchmark(this AnalyticalModel analyticalModel, OpenStudioBenchmarkContext openStudioBenchmarkContext, OpenStudioBenchmarkInputs openStudioBenchmarkInputs)
+ {
+ if (analyticalModel == null || openStudioBenchmarkContext == null || openStudioBenchmarkInputs == null)
+ {
+ return null;
+ }
+
+ AnalyticalModelSimulationResult modelResult = openStudioBenchmarkInputs.ModelResult;
+ List spaceResults = openStudioBenchmarkInputs.SpaceResults ?? new List();
return new BenchmarkDocument
{
SchemaVersion = BenchmarkSchema.CurrentVersion,
Provenance = Provenance(openStudioBenchmarkContext, modelResult, spaceResults),
- Model = Model(modelResult),
- Spaces = Spaces(analyticalModel, spaceResults, openStudioBenchmarkContext.SpaceDesignLoadResults),
+ Model = Model(modelResult, openStudioBenchmarkInputs),
+ Spaces = Spaces(analyticalModel, spaceResults, openStudioBenchmarkInputs.DesignLoadResults),
};
}
private static BenchmarkProvenance Provenance(OpenStudioBenchmarkContext context, AnalyticalModelSimulationResult modelResult, IEnumerable spaceResults)
{
+ List notes = new List(context.Notes ?? new List());
+ if (context.State == RunState.Success && !notes.Contains(TotalBasisNote))
+ {
+ notes.Add(TotalBasisNote);
+ notes.Sort(StringComparer.Ordinal);
+ }
+
return new BenchmarkProvenance
{
SourceModelName = context.SourceModelName,
@@ -76,7 +128,7 @@ private static BenchmarkProvenance Provenance(OpenStudioBenchmarkContext context
State = context.State,
ResultSources = ResultSources(modelResult, spaceResults),
Warnings = new List(context.Warnings ?? new List()),
- Notes = new List(context.Notes ?? new List()),
+ Notes = notes,
};
}
@@ -103,7 +155,7 @@ private static List ResultSources(AnalyticalModelSimulationResult modelR
return sources.ToList();
}
- private static BenchmarkModelResult Model(AnalyticalModelSimulationResult modelResult)
+ private static BenchmarkModelResult Model(AnalyticalModelSimulationResult modelResult, OpenStudioBenchmarkInputs inputs)
{
(MetricValue heatingLoad, MetricValue heatingHour) = Peak(
Double(modelResult, AnalyticalModelSimulationResultParameter.PeakHeatingLoad),
@@ -117,8 +169,13 @@ private static BenchmarkModelResult Model(AnalyticalModelSimulationResult modelR
return new BenchmarkModelResult
{
- ConsumptionHeating = NonNegative(Double(modelResult, AnalyticalModelSimulationResultParameter.ConsumptionHeating), MetricUnit.KilowattHour),
- ConsumptionCooling = NonNegative(Double(modelResult, AnalyticalModelSimulationResultParameter.ConsumptionCooling), MetricUnit.KilowattHour),
+ // No zone key: the result set's empty sum is not a measured 0 kWh.
+ ConsumptionHeating = inputs.HeatingConsumptionKeyed
+ ? NonNegative(Double(modelResult, AnalyticalModelSimulationResultParameter.ConsumptionHeating), MetricUnit.KilowattHour)
+ : MetricValue.Unavailable(MetricUnit.KilowattHour),
+ ConsumptionCooling = inputs.CoolingConsumptionKeyed
+ ? NonNegative(Double(modelResult, AnalyticalModelSimulationResultParameter.ConsumptionCooling), MetricUnit.KilowattHour)
+ : MetricValue.Unavailable(MetricUnit.KilowattHour),
PeakHeatingLoad = heatingLoad,
PeakHeatingHour = heatingHour,
PeakCoolingLoad = coolingLoad,
@@ -203,12 +260,19 @@ private static MetricValue NonNegative(double? value, MetricUnit unit)
///
/// A peak load and its hour-of-year, coupled: both are available only when the load is a
/// finite non-negative number and the hour is an integer in 0..8759; otherwise both are
- /// unavailable (a peak without a valid hour is missing, never a measured zero).
+ /// unavailable (a peak without a valid hour is missing, never a measured zero). A load of
+ /// exactly 0 is available with its hour unavailable: an all-zero series ties at every hour,
+ /// so it has no peak hour (S11 zero rule, symmetric with S10).
///
private static (MetricValue load, MetricValue hour) Peak(double? loadValue, int? hourValue, MetricUnit loadUnit)
{
bool hourValid = hourValue.HasValue && hourValue.Value >= 0 && hourValue.Value <= 8759;
bool loadValid = loadValue.HasValue && IsFinite(loadValue.Value) && loadValue.Value >= 0;
+ if (hourValid && loadValid && loadValue.Value == 0)
+ {
+ return (MetricValue.AvailableValue(0, loadUnit), MetricValue.Unavailable(MetricUnit.HourOfYear));
+ }
+
if (hourValid && loadValid)
{
return (MetricValue.AvailableValue(loadValue.Value, loadUnit), MetricValue.AvailableValue(hourValue.Value, MetricUnit.HourOfYear));
diff --git a/benchmark/SAM.Analytical.OpenStudio.Benchmark/Program.cs b/benchmark/SAM.Analytical.OpenStudio.Benchmark/Program.cs
index 5873efc..82297ee 100644
--- a/benchmark/SAM.Analytical.OpenStudio.Benchmark/Program.cs
+++ b/benchmark/SAM.Analytical.OpenStudio.Benchmark/Program.cs
@@ -59,6 +59,11 @@ private static int Execute(BenchmarkArguments arguments, TextWriter standardOutp
string modelPath = BenchmarkCliPaths.ValidateInputFile(arguments.RequireOption("model"));
string weatherPath = BenchmarkCliPaths.ValidateInputFile(arguments.RequireOption("weather"));
string outputPath = BenchmarkCliPaths.ValidateOutputFile(arguments.RequireOption("out"));
+ string samResultsPath = arguments.GetOption("sam-results") == null ? null : BenchmarkCliPaths.ValidateOutputFile(arguments.GetOption("sam-results"));
+ if (samResultsPath != null && string.Equals(Path.GetFullPath(samResultsPath), Path.GetFullPath(outputPath), StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException("--sam-results must not be the --out benchmark file.");
+ }
// Provenance hashes (B1a helpers, exactly per SCHEMA.md "Canonical model hashing").
string sourceFileHash = BenchmarkHash.ComputeSha256(File.ReadAllBytes(modelPath));
@@ -152,11 +157,30 @@ private static int Execute(BenchmarkArguments arguments, TextWriter standardOutp
// producer-generated notes added above, so both arrays are deterministic.
Modify.ApplyConversionDiagnostics(context, Concat(conversionResult?.Diagnostics, designLoadDiagnostics));
- BenchmarkDocument document = model.ToBenchmark(context);
- return Emit(document, outputPath, success, standardOutput);
+ // The SAM result objects are built once and the document is mapped from exactly these
+ // instances; --sam-results writes the same instances after the document (S11 subject capture).
+ OpenStudioBenchmarkInputs inputs = model.BenchmarkInputs(context);
+ BenchmarkDocument document = model.ToBenchmark(context, inputs);
+ int exitCode = Emit(document, outputPath, success, standardOutput);
+ if (samResultsPath != null)
+ {
+ WriteSamResults(inputs, samResultsPath, standardOutput);
+ }
+
+ return exitCode;
}
}
+ ///
+ /// Writes the SAM result objects that produced the document (,
+ /// SAM JSON) and reports their SHA-256, which the S11 run manifest records.
+ ///
+ internal static void WriteSamResults(OpenStudioBenchmarkInputs inputs, string path, TextWriter standardOutput)
+ {
+ inputs.Write(path);
+ standardOutput.WriteLine("Wrote " + path + " (" + BenchmarkHash.ComputeSha256(File.ReadAllBytes(path)) + ", the SAM result objects of the document).");
+ }
+
///
/// Validates, writes and reports a produced document, returning the shared exit code: a
/// document that fails schema validation throws
@@ -385,6 +409,7 @@ private static string GitHead(string directory)
private const string Usage =
"Usage: benchmark-openstudio --model --weather --out \n" +
" [--work ] [--sam-commit ] [--runner-commit ]\n" +
+ " [--sam-results ]\n" +
"\n" +
"Exit codes: 0 success, 2 usage error, 3 input/IO/serialization error, 4 validation failure, 5 producer failure.";
}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11CompletenessTests.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11CompletenessTests.cs
new file mode 100644
index 0000000..0a46664
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11CompletenessTests.cs
@@ -0,0 +1,265 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using NUnit.Framework;
+using SAM.Analytical.OpenStudio.ResultsReadBack;
+using System;
+using System.Collections.Generic;
+using System.Data.SQLite;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Tests
+{
+ ///
+ /// S11-C1/C1b (revision-2 change 1): every (zone x required quantity x file) must hold each of the 8760 hours of the
+ /// in.idf RunPeriod calendar exactly once, judged per file. An error shared by SQL and ESO, a one-sided error and a
+ /// count-preserving shift all BLOCK. For the shared errors the production reader still returns values for the same
+ /// files, so the detection comes from the reference, not from the importer.
+ ///
+ [TestFixture]
+ public class S11CompletenessTests
+ {
+ public enum Defect
+ {
+ MissingHourBoth,
+ DuplicatedHourBothSameValue,
+ DuplicatedHourBothDifferentValue,
+ CountPreservingShiftBoth,
+ MissingHourSqlOnly,
+ MissingHourEsoOnly,
+ DuplicatedHourSqlOnly,
+ DuplicatedHourEsoOnly,
+ SeriesInSqlAbsentFromEso,
+ SeriesInEsoAbsentFromSql,
+ ZoneMissingFromBoth,
+ EsoIdRepeatedInsideOneHourlyBlock,
+ SqlSecondHourlyDictionaryEntry,
+ EsoSecondHourlyDictionaryEntry,
+ ExtraHourBoth,
+ February29Both,
+ WarmupRowsInRunPeriodSql,
+ DesignDayRowsMergedIntoRunPeriodBoth,
+ NonAnnualRunPeriod,
+ LeapRunPeriod,
+ SecondRunPeriodEnvironmentEso,
+ DaylightSavingStampBoth,
+ SqlExtraTimestepFrequencyZeroValue,
+ EsoExtraTimestepFrequencyZeroValue,
+ SqlSecondWeatherRunEnvironmentOtherName,
+ }
+
+ /// The defects present identically in both files (the paired production-reader check applies to them).
+ private static readonly Defect[] Shared =
+ {
+ Defect.MissingHourBoth, Defect.DuplicatedHourBothSameValue, Defect.DuplicatedHourBothDifferentValue, Defect.CountPreservingShiftBoth,
+ Defect.ExtraHourBoth, Defect.February29Both, Defect.DesignDayRowsMergedIntoRunPeriodBoth, Defect.SqlSecondWeatherRunEnvironmentOtherName,
+ };
+
+ /// A second weather-run (EnvironmentType 3) environment, named otherwise; the production reader reads every type-3 environment.
+ public const string OtherWeatherRun = "WEATHER FILE RUN PERIOD 2";
+
+ private static void Break(SyntheticRun run, Defect defect)
+ {
+ string firstKey = SyntheticRun.Key(S11Names.TotalCooling, run.Zones[0].IdealLoads);
+ Func one = x => x == firstKey;
+ switch (defect)
+ {
+ case Defect.MissingHourBoth:
+ run.EditSeries(true, true, s => s.RemoveAll(p => p.Month == 7 && p.Day == 7 && p.Hour == 13));
+ break;
+ case Defect.DuplicatedHourBothSameValue:
+ run.EditSeries(true, true, s => s.Add(s.First(p => p.Month == 7 && p.Day == 7 && p.Hour == 13).Copy()));
+ break;
+ case Defect.DuplicatedHourBothDifferentValue:
+ run.EditSeries(true, true, s => { SyntheticPoint copy = s.First(p => p.Month == 7 && p.Day == 7 && p.Hour == 13).Copy(); copy.Value += 1; s.Add(copy); });
+ break;
+ case Defect.CountPreservingShiftBoth:
+ run.EditSeries(true, true, s =>
+ {
+ SyntheticPoint next = s.First(p => p.Month == 7 && p.Day == 7 && p.Hour == 14);
+ next.Hour = 13;
+ Assert.That(s.Count, Is.EqualTo(8760), "the shift keeps the count");
+ });
+ break;
+ case Defect.MissingHourSqlOnly:
+ run.EditSeries(true, false, s => s.RemoveAll(p => p.Month == 7 && p.Day == 7 && p.Hour == 13), one);
+ break;
+ case Defect.MissingHourEsoOnly:
+ run.EditSeries(false, true, s => s.RemoveAll(p => p.Month == 7 && p.Day == 7 && p.Hour == 13), one);
+ break;
+ case Defect.DuplicatedHourSqlOnly:
+ run.EditSeries(true, false, s => s.Add(s.First(p => p.Month == 7 && p.Day == 7 && p.Hour == 13).Copy()), one);
+ break;
+ case Defect.DuplicatedHourEsoOnly:
+ run.EditSeries(false, true, s => s.Add(s.First(p => p.Month == 7 && p.Day == 7 && p.Hour == 13).Copy()), one);
+ break;
+ case Defect.SeriesInSqlAbsentFromEso:
+ run.Eso.Remove(firstKey);
+ break;
+ case Defect.SeriesInEsoAbsentFromSql:
+ run.Sql.Remove(firstKey);
+ break;
+ case Defect.ZoneMissingFromBoth:
+ foreach (string key in run.Sql.Keys.Where(x => x.EndsWith("|" + run.Zones[1].IdealLoads, StringComparison.Ordinal) || x.EndsWith("|" + run.Zones[1].Zone, StringComparison.Ordinal)).ToList())
+ {
+ run.Sql.Remove(key);
+ run.Eso.Remove(key);
+ }
+
+ break;
+ case Defect.EsoIdRepeatedInsideOneHourlyBlock:
+ run.EsoEdit = lines =>
+ {
+ int stamp = lines.FindIndex(x => Regex.IsMatch(x, @"^2,\d+, 7, 7, 0,13, 0\.00,60\.00"));
+ lines.Insert(stamp + 2, lines[stamp + 1]);
+ return lines;
+ };
+ break;
+ case Defect.SqlSecondHourlyDictionaryEntry:
+ run.SqlEdit = connection =>
+ {
+ using (SQLiteCommand command = connection.CreateCommand())
+ {
+ command.CommandText = "INSERT INTO ReportDataDictionary (IsMeter, Type, IndexGroup, TimestepType, KeyValue, Name, ReportingFrequency, Units) VALUES (0, 'Sum', 'System', 'HVAC System', @k, @n, 'Hourly', 'J')";
+ command.Parameters.AddWithValue("@k", run.Zones[0].IdealLoads);
+ command.Parameters.AddWithValue("@n", S11Names.TotalCooling);
+ command.ExecuteNonQuery();
+ }
+ };
+ break;
+ case Defect.EsoSecondHourlyDictionaryEntry:
+ run.EsoEdit = lines =>
+ {
+ lines.Insert(lines.IndexOf("End of Data Dictionary"), "900,1," + run.Zones[0].IdealLoads + "," + S11Names.TotalCooling + " [J] !Hourly");
+ return lines;
+ };
+ break;
+ case Defect.ExtraHourBoth:
+ run.EditSeries(true, true, s => s.Add(new SyntheticPoint { Month = 12, Day = 31, Hour = 25, Value = 1, Environment = run.RunPeriodName }));
+ break;
+ case Defect.February29Both:
+ run.EditSeries(true, true, s => s.AddRange(Enumerable.Range(1, 24).Select(h => new SyntheticPoint { Month = 2, Day = 29, Hour = h, Value = 2, Environment = run.RunPeriodName })));
+ break;
+ case Defect.WarmupRowsInRunPeriodSql:
+ run.EditSeries(true, false, s => s.AddRange(Enumerable.Range(1, 24).Select(h => new SyntheticPoint { Month = 1, Day = 1, Hour = h, Value = 3, Environment = run.RunPeriodName, Warmup = true })));
+ break;
+ case Defect.DesignDayRowsMergedIntoRunPeriodBoth:
+ run.EditSeries(true, true, s => s.AddRange(Enumerable.Range(1, 24).Select(h => new SyntheticPoint { Month = 7, Day = 21, Hour = h, Value = 4, Environment = run.RunPeriodName })));
+ break;
+ case Defect.NonAnnualRunPeriod:
+ run.Idf = EditRunPeriod(run.Idf, "End Month", "6");
+ break;
+ case Defect.LeapRunPeriod:
+ run.Idf = EditRunPeriod(EditRunPeriod(run.Idf, "Begin Year", "2024"), "End Year", "2024");
+ break;
+ case Defect.SecondRunPeriodEnvironmentEso:
+ run.EsoEnvironments.Add(run.RunPeriodName);
+ break;
+ case Defect.DaylightSavingStampBoth:
+ run.EditSeries(true, true, s => s.First(p => p.Month == 7 && p.Day == 7 && p.Hour == 13).Dst = 1);
+ break;
+ case Defect.SqlExtraTimestepFrequencyZeroValue:
+ // The hourly series stays complete; the additional frequency alone (with a zero value) must block it.
+ run.SqlEdit = connection =>
+ {
+ using (SQLiteCommand command = connection.CreateCommand())
+ {
+ command.CommandText = "INSERT INTO ReportDataDictionary (IsMeter, Type, IndexGroup, TimestepType, KeyValue, Name, ReportingFrequency, Units) VALUES (0, 'Sum', 'System', 'HVAC System', @k, @n, 'HVAC System Timestep', 'J')";
+ command.Parameters.AddWithValue("@k", run.Zones[0].IdealLoads);
+ command.Parameters.AddWithValue("@n", S11Names.TotalCooling);
+ command.ExecuteNonQuery();
+ command.CommandText = "INSERT INTO ReportData (TimeIndex, ReportDataDictionaryIndex, Value) VALUES ((SELECT MIN(TimeIndex) FROM Time), (SELECT MAX(ReportDataDictionaryIndex) FROM ReportDataDictionary), 0)";
+ command.ExecuteNonQuery();
+ }
+ };
+ break;
+ case Defect.EsoExtraTimestepFrequencyZeroValue:
+ // As the review reproduction: a second dictionary entry at TimeStep frequency and one zero record.
+ run.EsoEdit = lines =>
+ {
+ lines.Insert(lines.IndexOf("End of Data Dictionary"), "999999,1," + run.Zones[0].IdealLoads + "," + S11Names.TotalCooling + " [J] !TimeStep");
+ int stamp = lines.FindIndex(x => Regex.IsMatch(x, @"^2,\d+, 7, 7, 0,13, 0\.00,60\.00"));
+ lines.Insert(stamp + 1, "999999,0");
+ return lines;
+ };
+ break;
+ case Defect.SqlSecondWeatherRunEnvironmentOtherName:
+ run.SqlEnvironments.Add(new KeyValuePair(OtherWeatherRun, 3));
+ run.EsoEnvironments.Add(OtherWeatherRun);
+ run.EditSeries(true, true, s => s.AddRange(Enumerable.Range(1, 24).Select(h => new SyntheticPoint { Month = 1, Day = 1, Hour = h, Value = 5, Environment = OtherWeatherRun })));
+ break;
+ }
+ }
+
+ /// Replaces the value of one labelled RunPeriod field in the IDF text.
+ public static string EditRunPeriod(string idf, string label, string value)
+ {
+ int start = idf.IndexOf("RunPeriod,", StringComparison.Ordinal);
+ Assert.That(start, Is.GreaterThanOrEqualTo(0));
+ int end = idf.IndexOf(';', start);
+ string block = idf.Substring(start, end - start);
+ string edited = Regex.Replace(block, @"(\n\s*)[^,\n]*(,\s*!- " + Regex.Escape(label) + @"\b)", "${1}" + value + "${2}");
+ Assert.That(edited, Is.Not.EqualTo(block), "RunPeriod field " + label + " not found");
+ return idf.Substring(0, start) + edited + idf.Substring(end);
+ }
+
+ [Test]
+ public void EveryCompletenessDefect_Blocks([Values] Defect defect)
+ {
+ string model = S11Support.Two();
+ SyntheticRun synthetic = SyntheticRun.For(model, "two");
+ Break(synthetic, defect);
+ S11Case run = S11Support.Evaluate(synthetic, model, "c1-" + defect);
+
+ Assert.That(run.Compare.Verdict, Is.EqualTo("BLOCKED"), run.Summary());
+ Assert.That(run.Compare.Failed, Is.Zero, "a completeness defect blocks, it is not a value failure: " + run.Summary());
+ string reasons = string.Join(" ", run.Compare.Rows.Where(x => x.Status == "BLOCKED").Select(x => x.Reason)) + " " + string.Join(" ", run.Reference.GlobalBlocks);
+ Assert.That(reasons, Does.Contain("S11-C1").Or.Contain("calendar").Or.Contain("eso:").Or.Contain("sql:"), reasons);
+ if (defect == Defect.SqlExtraTimestepFrequencyZeroValue || defect == Defect.EsoExtraTimestepFrequencyZeroValue)
+ {
+ Assert.That(reasons, Does.Contain("unsupported reporting frequency"), reasons);
+ Assert.That(run.Row("b1a|" + run.Space(1) + "|Cooling|peakLoad").Status, Is.EqualTo("PASS"), "only the key with the extra frequency blocks");
+ }
+
+ if (defect == Defect.SqlSecondWeatherRunEnvironmentOtherName)
+ {
+ Assert.That(string.Join(" ", run.Reference.GlobalBlocks), Does.Contain("2 SQL environment(s) of type 3"));
+ }
+
+ // Rows that do not depend on the broken series keep their verdict (each check blocks only dependent rows).
+ if (defect == Defect.MissingHourSqlOnly || defect == Defect.SeriesInSqlAbsentFromEso)
+ {
+ Assert.That(run.Row("b1a|" + run.Space(0) + "|Heating|peakLoad").Status, Is.EqualTo("PASS"));
+ Assert.That(run.Row("b1a|" + run.Space(1) + "|Cooling|peakLoad").Status, Is.EqualTo("PASS"));
+ Assert.That(run.Row("b1a|" + run.Space(0) + "|Cooling|peakLoad").Status, Is.EqualTo("BLOCKED"));
+ Assert.That(run.Row("b1a|model|Cooling|consumption").Status, Is.EqualTo("BLOCKED"));
+ }
+
+ if (Shared.Contains(defect))
+ {
+ OpenStudioSimulationResultSet production = SyntheticProducer.ResultSet(System.IO.Path.Combine(run.Run, "work", "run", "eplusout.sql"));
+ Assert.That(production.AnnualCoolingEnergy.Count, Is.EqualTo(2), "the production reader still returns values for the defective files");
+ Assert.That(production.PeakCoolingLoad.Values.All(x => x > 0), Is.True);
+ }
+ }
+
+ [Test]
+ public void CountPreservingShift_IsReportedAsOneDuplicateAndOneMissingHourPerSeriesAndFile()
+ {
+ string model = S11Support.Model(TestSupport.Box);
+ SyntheticRun synthetic = SyntheticRun.For(model, "box");
+ Break(synthetic, Defect.CountPreservingShiftBoth);
+ S11Case run = S11Support.Evaluate(synthetic, model, "c1-shift-detail");
+
+ Assert.That(run.Reference.Completeness, Has.Count.EqualTo(12), "6 quantities x 2 files");
+ foreach (CompletenessResult result in run.Reference.Completeness)
+ {
+ Assert.That(result.Found, Is.EqualTo(8760));
+ Assert.That(result.Missing, Is.EqualTo(1), result.Channel + " " + result.Variable);
+ Assert.That(result.Duplicated, Is.EqualTo(1));
+ Assert.That(result.Complete, Is.False);
+ }
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11DriverStandIn.ps1 b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11DriverStandIn.ps1
new file mode 100644
index 0000000..b23fbe0
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11DriverStandIn.ps1
@@ -0,0 +1,99 @@
+# SPDX-License-Identifier: LGPL-3.0-or-later
+# Copyright (c) 2020-2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+#
+# Offline stand-in for os-results-readback, used only by S11DriverTests: Run-S11-Results.ps1 starts it in place of the
+# harness. It starts no engine, loads no model and reads no SQL/ESO; it writes the files the real steps write, with the
+# shapes the driver checks. scenario.json beside it sets, per "|", what a step does:
+# exit (int) the exit code (default: 0; compare: the verdict the case requires, 0 for a run)
+# write (bool) false: write nothing (default true)
+# verdict (string) the report verdict (default: from the exit)
+# wrongInputs (bool) the report records another SQL hash
+# od7 (bool) F3.MeetsOd7 (default true)
+# touch (string) a file to append to (e.g. an original the step must not change)
+# mutate (bool) negative: false copies without mutating (default true)
+# expectedExit (int) negative: the exit negative.json records (default: the case's)
+# Every call is appended to calls.txt beside it.
+param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Items)
+
+$step = $Items[0]
+$options = @{}
+for ($i = 1; $i + 1 -lt $Items.Count; $i += 2) { $options[$Items[$i].TrimStart('-')] = $Items[$i + 1] }
+$directory = $(if ($step -eq 'negative') { $options['out'] } else { $options['run'] })
+$name = Split-Path $directory -Leaf
+Add-Content -LiteralPath (Join-Path $PSScriptRoot 'calls.txt') -Value "$name|$step"
+$scenario = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'scenario.json') -Raw | ConvertFrom-Json
+$entry = $scenario."$name|$step"
+
+function Sha([string]$Path) {
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
+ $bytes = [IO.File]::ReadAllBytes($Path)
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try { return ([BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-', '').ToLowerInvariant() } finally { $sha.Dispose() }
+}
+
+function Option($Name, $Default) {
+ if ($entry -and $null -ne $entry.$Name) { return $entry.$Name }
+ return $Default
+}
+
+$cases = @{ 'ctl-unmutated-copy' = 0; 'neg-delete-hour-both' = 11; 'neg-duplicate-hour-both' = 11; 'neg-sensible-cooling-substitution' = 10 }
+$case = @($cases.Keys | Where-Object { $name.EndsWith('-' + $_) })[0]
+$defaultExit = 0
+if ($step -eq 'compare' -and $case) { $defaultExit = $cases[$case] }
+$exit = [int](Option 'exit' $defaultExit)
+$touch = Option 'touch' $null
+if ($touch) { Add-Content -LiteralPath $touch -Value 'changed by a step' }
+
+if (Option 'write' $true) {
+ $stamp = [Guid]::NewGuid().ToString('N')
+ switch ($step) {
+ 'read' { [IO.File]::WriteAllText((Join-Path $directory 'reference.json'), "{ ""stand-in"": ""read $stamp"" }") }
+ 'subject' { [IO.File]::WriteAllText((Join-Path $directory 'subject.json'), "{ ""stand-in"": ""subject $stamp"" }") }
+ 'negative' {
+ $source = $options['run']
+ New-Item -ItemType Directory -Force -Path (Join-Path $directory 'work\run') | Out-Null
+ foreach ($file in @('b1a.json', 'sam.json', 'work\run\in.idf', 'work\run\eplusout.sql', 'work\run\eplusout.eso')) {
+ Copy-Item -LiteralPath (Join-Path $source $file) -Destination (Join-Path $directory $file)
+ }
+ $mutation = 'none (control)'
+ if (Option 'mutate' $true) {
+ if ($case -eq 'neg-delete-hour-both' -or $case -eq 'neg-duplicate-hour-both') {
+ Add-Content -LiteralPath (Join-Path $directory 'work\run\eplusout.sql') -Value $case
+ Add-Content -LiteralPath (Join-Path $directory 'work\run\eplusout.eso') -Value $case
+ $mutation = "$case on the SQL and ESO copies"
+ }
+ elseif ($case -eq 'neg-sensible-cooling-substitution') {
+ Add-Content -LiteralPath (Join-Path $directory 'b1a.json') -Value $case
+ Add-Content -LiteralPath (Join-Path $directory 'sam.json') -Value $case
+ [IO.File]::WriteAllText((Join-Path $directory 'subject.json'), "{ ""stand-in"": ""captured subject $stamp"" }")
+ $mutation = "$case on the b1a.json and sam.json copies"
+ }
+ }
+ $record = [ordered]@{ Case = $case; Source = $source; ExpectedExit = [int](Option 'expectedExit' $cases[$case]); Mutation = $mutation }
+ [IO.File]::WriteAllText((Join-Path $directory 'negative.json'), ($record | ConvertTo-Json))
+ }
+ 'compare' {
+ $verdict = Option 'verdict' $(switch ($exit) { 0 { 'PASS' } 10 { 'FAIL' } 11 { 'BLOCKED' } default { 'PASS' } })
+ $inputs = [ordered]@{
+ 'model' = Sha $options['model']
+ 'in.idf' = Sha (Join-Path $directory 'work\run\in.idf')
+ 'eplusout.sql' = $(if (Option 'wrongInputs' $false) { 'feedface' } else { Sha (Join-Path $directory 'work\run\eplusout.sql') })
+ 'eplusout.eso' = Sha (Join-Path $directory 'work\run\eplusout.eso')
+ 'b1a.json' = Sha (Join-Path $directory 'b1a.json')
+ 'sam.json' = Sha (Join-Path $directory 'sam.json')
+ 'reference.json' = Sha (Join-Path $directory 'reference.json')
+ 'subject.json' = Sha (Join-Path $directory 'subject.json')
+ }
+ $failed = $(if ($verdict -eq 'FAIL') { 1 } else { 0 })
+ $blocked = $(if ($verdict -eq 'BLOCKED') { 1 } else { 0 })
+ $report = [ordered]@{
+ Schema = 's11-compare/1'; Verdict = $verdict; ExitCode = $exit; Required = 85; Passed = 85 - $failed - $blocked; Failed = $failed; Blocked = $blocked
+ Inputs = $inputs; F3 = [ordered]@{ MeetsOd7 = [bool](Option 'od7' $true) }
+ }
+ New-Item -ItemType Directory -Force -Path (Join-Path $directory 'compare') | Out-Null
+ [IO.File]::WriteAllText((Join-Path $directory 'compare\compare.json'), ($report | ConvertTo-Json -Depth 4))
+ }
+ }
+}
+
+exit $exit
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11DriverTests.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11DriverTests.cs
new file mode 100644
index 0000000..2452c5c
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11DriverTests.cs
@@ -0,0 +1,433 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using NUnit.Framework;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Tests
+{
+ ///
+ /// The acceptance driver Run-S11-Results.ps1 (readback and negative phases) under Windows PowerShell 5.1 with
+ /// stand-in commands (S11DriverStandIn.ps1 for the harness, an inert file for the producer): no engine starts, no
+ /// model is loaded, nothing is built or deployed. The run directories are stand-in files described by their own
+ /// run-manifest.json and a stand-in build receipt. The driver must stop (70) on any infrastructure or integrity
+ /// failure without reading an earlier report, collect numerical FAIL/BLOCKED for both fixtures and finish nonzero, and
+ /// fail on an unexpected negative outcome or a changed original.
+ ///
+ [TestFixture]
+ public class S11DriverTests
+ {
+ private static readonly (string Fixture, string Label)[] Runs = { ("s08-fix-box", "box"), ("s08-fix-two", "two") };
+
+ private sealed class Driver
+ {
+ public string Directory;
+ public string Root;
+ public string Bin;
+ public string Receipt;
+ public string Harness;
+ public string Producer;
+ public JsonObject Scenario = new JsonObject();
+ public int Exit;
+ public string Output;
+
+ public string Run(string label) => Path.Combine(Root, label);
+
+ public string RunFile(string label, string file) => Path.Combine(Run(label), file);
+
+ public List Calls => File.Exists(Path.Combine(Bin, "calls.txt")) ? File.ReadAllLines(Path.Combine(Bin, "calls.txt")).ToList() : new List();
+
+ public JsonNode Summary(string phase) => JsonNode.Parse(File.ReadAllText(Path.Combine(Root, "summary-" + phase + ".json")));
+
+ public void Set(string key, object values)
+ {
+ Scenario[key] = JsonSerializer.SerializeToNode(values);
+ }
+
+ public string Describe() => "exit " + Exit + Environment.NewLine + Output + Environment.NewLine + "calls: " + string.Join(", ", Calls);
+ }
+
+ private static string Script()
+ {
+ DirectoryInfo directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
+ while (directory != null)
+ {
+ string candidate = Path.Combine(directory.FullName, "benchmark", "SAM.Analytical.OpenStudio.ResultsReadBack", "Run-S11-Results.ps1");
+ if (File.Exists(candidate)) return candidate;
+ directory = directory.Parent;
+ }
+
+ throw new FileNotFoundException("Run-S11-Results.ps1 not found above " + TestContext.CurrentContext.TestDirectory);
+ }
+
+ private static string Fixture(string fixtureId) => Path.Combine(Path.GetDirectoryName(Script()), "..", "SAM.Analytical.OpenStudio.InputReadBack", "Fixtures", fixtureId + ".json");
+
+ private static string Sha(string path)
+ {
+ using (FileStream stream = File.OpenRead(path))
+ using (SHA256 sha = SHA256.Create())
+ {
+ return string.Concat(sha.ComputeHash(stream).Select(x => x.ToString("x2")));
+ }
+ }
+
+ /// Two produced runs (stand-in files), their manifests, the stand-in harness and producer, and a receipt listing them.
+ private static Driver Arrange(string label)
+ {
+ Driver driver = new Driver { Directory = S11Support.NewRun("driver-" + label) };
+ driver.Root = Path.Combine(driver.Directory, "acc");
+ driver.Bin = Path.Combine(driver.Directory, "bin");
+ System.IO.Directory.CreateDirectory(driver.Bin);
+ driver.Harness = Path.Combine(driver.Bin, "os-results-readback.ps1");
+ File.Copy(Path.Combine(TestContext.CurrentContext.TestDirectory, "S11DriverStandIn.ps1"), driver.Harness);
+ driver.Producer = Path.Combine(driver.Bin, "benchmark-openstudio.ps1");
+ File.WriteAllText(driver.Producer, "Add-Content -LiteralPath (Join-Path $PSScriptRoot 'calls.txt') -Value 'PRODUCER STARTED'\r\nexit 99\r\n");
+ File.WriteAllText(Path.Combine(driver.Bin, "os-results-readback.dll"), "stand-in harness assembly");
+ File.WriteAllText(Path.Combine(driver.Bin, "benchmark-openstudio.dll"), "stand-in producer assembly");
+
+ driver.Receipt = Path.Combine(driver.Bin, "s11-build-receipt.json");
+ JsonArray files = new JsonArray();
+ foreach (string file in new[] { "benchmark-openstudio.ps1", "benchmark-openstudio.dll", "os-results-readback.ps1", "os-results-readback.dll" })
+ {
+ files.Add(new JsonObject { ["path"] = Path.Combine(driver.Bin, file), ["sha256"] = Sha(Path.Combine(driver.Bin, file)) });
+ }
+
+ File.WriteAllText(driver.Receipt, new JsonObject { ["schema"] = "s11-build-receipt/1", ["commits"] = new JsonArray(), ["files"] = files }.ToJsonString());
+
+ foreach ((string fixture, string run) in Runs)
+ {
+ string folder = Path.Combine(driver.Run(run), "work", "run");
+ System.IO.Directory.CreateDirectory(folder);
+ File.WriteAllText(Path.Combine(folder, "in.idf"), "stand-in in.idf " + run);
+ File.WriteAllText(Path.Combine(folder, "eplusout.sql"), "stand-in eplusout.sql " + run);
+ File.WriteAllText(Path.Combine(folder, "eplusout.eso"), "stand-in eplusout.eso " + run);
+ File.WriteAllText(driver.RunFile(run, "b1a.json"), "{ \"stand-in\": \"b1a " + run + "\" }");
+ File.WriteAllText(driver.RunFile(run, "sam.json"), "{ \"stand-in\": \"sam " + run + "\" }");
+ JsonObject manifest = new JsonObject
+ {
+ ["schema"] = "s11-run-manifest/1", ["label"] = run, ["fixture"] = fixture, ["fixtureSha256"] = Sha(Fixture(fixture)),
+ ["receiptSha256"] = Sha(driver.Receipt), ["benchmarkDll"] = Sha(Path.Combine(driver.Bin, "benchmark-openstudio.dll")), ["harnessDll"] = Sha(Path.Combine(driver.Bin, "os-results-readback.dll")),
+ ["idf"] = Path.Combine(folder, "in.idf"), ["idfSha256"] = Sha(Path.Combine(folder, "in.idf")),
+ ["sql"] = Path.Combine(folder, "eplusout.sql"), ["sqlSha256"] = Sha(Path.Combine(folder, "eplusout.sql")),
+ ["eso"] = Path.Combine(folder, "eplusout.eso"), ["esoSha256"] = Sha(Path.Combine(folder, "eplusout.eso")),
+ ["b1aSha256"] = Sha(driver.RunFile(run, "b1a.json")), ["samResultsSha256"] = Sha(driver.RunFile(run, "sam.json")),
+ };
+ File.WriteAllText(driver.RunFile(run, "run-manifest.json"), manifest.ToJsonString());
+
+ // An earlier attempt's PASS report: never to be read as the result of a later step.
+ System.IO.Directory.CreateDirectory(Path.Combine(driver.Run(run), "compare"));
+ string old = Path.Combine(driver.Run(run), "compare", "compare.json");
+ File.WriteAllText(old, new JsonObject { ["Schema"] = "s11-compare/1", ["Verdict"] = "PASS", ["ExitCode"] = 0, ["Required"] = 85, ["Passed"] = 85, ["Failed"] = 0, ["Blocked"] = 0, ["F3"] = new JsonObject { ["MeetsOd7"] = true } }.ToJsonString());
+ File.SetLastWriteTimeUtc(old, DateTime.UtcNow.AddDays(-1));
+ }
+
+ return driver;
+ }
+
+ private static Driver Act(Driver driver, string phase, params string[] extra)
+ {
+ File.WriteAllText(Path.Combine(driver.Bin, "scenario.json"), driver.Scenario.ToJsonString());
+ string powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe");
+ ProcessStartInfo info = new ProcessStartInfo(powershell) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true };
+ foreach (string argument in new[] { "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", Script(), "-Phase", phase, "-Root", driver.Root, "-BuildReceipt", driver.Receipt, "-HarnessExe", driver.Harness, "-BenchmarkExe", driver.Producer }.Concat(extra))
+ {
+ info.ArgumentList.Add(argument);
+ }
+
+ using (Process process = Process.Start(info))
+ {
+ System.Threading.Tasks.Task error = process.StandardError.ReadToEndAsync();
+ string output = process.StandardOutput.ReadToEnd();
+ Assert.That(process.WaitForExit(120000), Is.True, "the driver did not finish");
+ driver.Exit = process.ExitCode;
+ driver.Output = output + error.Result;
+ }
+
+ Assert.That(driver.Calls, Has.None.EqualTo("PRODUCER STARTED"), "the producer (engine) must never start in these phases");
+ return driver;
+ }
+
+ // ---- readback ----------------------------------------------------------------------------------------------------
+
+ [Test]
+ public void Readback_FullyPassingControl_Exits0()
+ {
+ Driver driver = Act(Arrange("rb-pass"), "readback");
+
+ Assert.That(driver.Exit, Is.EqualTo(0), driver.Describe());
+ Assert.That(driver.Calls, Is.EqualTo(new[] { "box|read", "box|subject", "box|compare", "two|read", "two|subject", "two|compare" }));
+ JsonNode summary = driver.Summary("readback");
+ Assert.That((string)summary["result"], Is.EqualTo("PASS"));
+ Assert.That(summary["runs"].AsArray().Select(x => (string)x["verdict"]), Is.EqualTo(new[] { "PASS", "PASS" }));
+ Assert.That((bool)summary["original-unchanged:box"] && (bool)summary["original-unchanged:two"], Is.True);
+ }
+
+ /// The review reproduction: both comparator calls return 70 while old PASS reports exist.
+ [Test]
+ public void Readback_InfrastructureErrorWithOldPassReport_StopsAt70AndReadsNoReport()
+ {
+ Driver driver = Arrange("rb-infra");
+ driver.Set("box|compare", new { exit = 70, write = false });
+ driver.Set("two|compare", new { exit = 70, write = false });
+ Act(driver, "readback");
+
+ Assert.That(driver.Exit, Is.EqualTo(70), driver.Describe());
+ Assert.That(driver.Output, Does.Contain("STOPPED: compare box exit 70"));
+ Assert.That(driver.Output, Does.Not.Contain("PASS"), "no old report may be presented as a result");
+ Assert.That(driver.Calls, Has.None.StartsWith("two|"), "the phase stops at the first infrastructure failure");
+ Assert.That((string)driver.Summary("readback")["result"], Is.EqualTo("STOPPED"));
+ Assert.That(driver.Summary("readback")["runs"].AsArray(), Is.Empty);
+ }
+
+ public static IEnumerable StaleOrInconsistentReports()
+ {
+ yield return new TestCaseData("box|read", new { exit = 70 }, "read box exit 70").SetName("Readback_ReadInfrastructureError_Stops");
+ yield return new TestCaseData("box|subject", new { exit = 64 }, "subject box exit 64").SetName("Readback_SubjectUsageError_Stops");
+ yield return new TestCaseData("box|subject", new { write = false }, "subject box wrote no").SetName("Readback_SubjectExit0WithoutOutput_Stops");
+ yield return new TestCaseData("box|compare", new { write = false }, "compare box did not write").SetName("Readback_CompareExit0WithOnlyAnOldReport_Stops");
+ yield return new TestCaseData("box|compare", new { exit = 0, verdict = "FAIL" }, "compare box exit 0 but its report says FAIL").SetName("Readback_ExitDisagreesWithVerdict_Stops");
+ yield return new TestCaseData("box|compare", new { exit = 10, verdict = "PASS" }, "compare box exit 10 but its report says PASS").SetName("Readback_FailExitWithPassReport_Stops");
+ yield return new TestCaseData("box|compare", new { wrongInputs = true }, "compare box judged a eplusout.sql other than").SetName("Readback_ReportJudgedOtherFiles_Stops");
+ }
+
+ [TestCaseSource(nameof(StaleOrInconsistentReports))]
+ public void Readback_StepThatDidNotCompleteOrReportFreshly_StopsAt70(string key, object scenario, string reason)
+ {
+ Driver driver = Arrange("rb-stale");
+ driver.Set(key, scenario);
+ Act(driver, "readback");
+
+ Assert.That(driver.Exit, Is.EqualTo(70), driver.Describe());
+ Assert.That(driver.Output, Does.Contain("STOPPED: " + reason));
+ Assert.That(driver.Calls, Has.None.StartsWith("two|"));
+ }
+
+ [Test]
+ public void Readback_NumericFail_CollectsBothFixturesAndExits10()
+ {
+ Driver driver = Arrange("rb-fail");
+ driver.Set("box|compare", new { exit = 10 });
+ Act(driver, "readback");
+
+ Assert.That(driver.Exit, Is.EqualTo(10), driver.Describe());
+ JsonNode summary = driver.Summary("readback");
+ Assert.That(summary["runs"].AsArray().Select(x => (string)x["label"] + " " + (string)x["verdict"]), Is.EqualTo(new[] { "box FAIL", "two PASS" }));
+ Assert.That((string)summary["result"], Is.EqualTo("FAIL"));
+ }
+
+ [Test]
+ public void Readback_Blocked_CollectsBothFixturesAndExits11()
+ {
+ Driver driver = Arrange("rb-blocked");
+ driver.Set("two|compare", new { exit = 11 });
+ Act(driver, "readback");
+
+ Assert.That(driver.Exit, Is.EqualTo(11), driver.Describe());
+ Assert.That(driver.Summary("readback")["runs"].AsArray().Select(x => (string)x["verdict"]), Is.EqualTo(new[] { "PASS", "BLOCKED" }));
+ }
+
+ [Test]
+ public void Readback_FailAndBlocked_Exits10()
+ {
+ Driver driver = Arrange("rb-fail-blocked");
+ driver.Set("box|compare", new { exit = 11 });
+ driver.Set("two|compare", new { exit = 10 });
+ Act(driver, "readback");
+
+ Assert.That(driver.Exit, Is.EqualTo(10), driver.Describe());
+ }
+
+ [Test]
+ public void Readback_Od7MetByNeitherFixture_IsNotPass()
+ {
+ Driver driver = Arrange("rb-od7");
+ driver.Set("box|compare", new { od7 = false });
+ driver.Set("two|compare", new { od7 = false });
+ Act(driver, "readback");
+
+ Assert.That(driver.Exit, Is.EqualTo(11), driver.Describe());
+ Assert.That(driver.Output, Does.Contain("OD-7 not met"));
+ }
+
+ // ---- changed inputs and build ------------------------------------------------------------------------------------
+
+ public enum Change
+ {
+ Idf,
+ Sql,
+ Eso,
+ B1a,
+ SamResults,
+ ModelHashRecorded,
+ ReceiptOfAnotherBuild,
+ HarnessAssembly,
+ HarnessNotInReceipt,
+ ManifestPointsOutsideTheRun,
+ }
+
+ [Test]
+ public void ChangedInputOrBuild_BeforeProcessing_StopsAt70BeforeAnyStep([Values] Change change, [Values("readback", "negative")] string phase)
+ {
+ Driver driver = Arrange("changed");
+ string run = driver.Run("two");
+ string folder = Path.Combine(run, "work", "run");
+ JsonObject manifest = JsonNode.Parse(File.ReadAllText(Path.Combine(run, "run-manifest.json"))).AsObject();
+ switch (change)
+ {
+ case Change.Idf: File.AppendAllText(Path.Combine(folder, "in.idf"), "x"); break;
+ case Change.Sql: File.AppendAllText(Path.Combine(folder, "eplusout.sql"), "x"); break;
+ case Change.Eso: File.AppendAllText(Path.Combine(folder, "eplusout.eso"), "x"); break;
+ case Change.B1a: File.AppendAllText(Path.Combine(run, "b1a.json"), "x"); break;
+ case Change.SamResults: File.AppendAllText(Path.Combine(run, "sam.json"), "x"); break;
+ case Change.ModelHashRecorded: manifest["fixtureSha256"] = new string('0', 64); break;
+ case Change.ReceiptOfAnotherBuild: manifest["receiptSha256"] = new string('1', 64); break;
+ case Change.HarnessAssembly: File.AppendAllText(Path.Combine(driver.Bin, "os-results-readback.dll"), "x"); break;
+ case Change.HarnessNotInReceipt:
+ JsonObject receipt = JsonNode.Parse(File.ReadAllText(driver.Receipt)).AsObject();
+ JsonArray files = receipt["files"].AsArray();
+ files.Remove(files.First(x => ((string)x["path"]).EndsWith("os-results-readback.ps1", StringComparison.Ordinal)));
+ File.WriteAllText(driver.Receipt, receipt.ToJsonString());
+ break;
+ case Change.ManifestPointsOutsideTheRun:
+ string elsewhere = Path.Combine(driver.Directory, "eplusout.sql");
+ File.Copy(Path.Combine(folder, "eplusout.sql"), elsewhere);
+ manifest["sql"] = elsewhere;
+ break;
+ }
+
+ File.WriteAllText(Path.Combine(run, "run-manifest.json"), manifest.ToJsonString());
+ Act(driver, phase);
+
+ Assert.That(driver.Exit, Is.EqualTo(70), driver.Describe());
+ Assert.That(driver.Output, Does.Contain("STOPPED"));
+ Assert.That(driver.Calls, Has.None.StartsWith("two"), "nothing of the changed run is processed");
+ if (change == Change.HarnessAssembly || change == Change.HarnessNotInReceipt)
+ {
+ Assert.That(driver.Calls, Is.Empty, "a receipt that does not hold stops before any step");
+ }
+ }
+
+ [Test]
+ public void Readback_OriginalChangedByAStep_StopsAt70()
+ {
+ Driver driver = Arrange("rb-touch");
+ driver.Set("box|subject", new { touch = driver.RunFile("box", "sam.json") });
+ Act(driver, "readback");
+
+ Assert.That(driver.Exit, Is.EqualTo(70), driver.Describe());
+ Assert.That(driver.Output, Does.Contain("STOPPED: box after readback: sam.json is not the file the run recorded"));
+ Assert.That((bool)driver.Summary("readback")["original-unchanged:box"], Is.False);
+ }
+
+ // ---- negative ----------------------------------------------------------------------------------------------------
+
+ [Test]
+ public void Negative_EveryCaseAsRequired_Exits0()
+ {
+ Driver driver = Act(Arrange("neg-pass"), "negative");
+
+ Assert.That(driver.Exit, Is.EqualTo(0), driver.Describe());
+ JsonNode summary = driver.Summary("negative");
+ Assert.That(summary["runs"].AsArray(), Has.Count.EqualTo(8));
+ Assert.That(summary["runs"].AsArray().All(x => (bool)x["asRequired"] && (bool)x["evidence"]), Is.True, summary.ToJsonString());
+ Assert.That(driver.Calls.Count(x => x.EndsWith("|subject", StringComparison.Ordinal)), Is.EqualTo(6), "the substitution cases use their captured subject");
+ }
+
+ [Test]
+ public void Negative_MutationNotDetected_Exits10()
+ {
+ Driver driver = Arrange("neg-undetected");
+ driver.Set("box-neg-duplicate-hour-both|compare", new { exit = 0 });
+ Act(driver, "negative");
+
+ Assert.That(driver.Exit, Is.EqualTo(10), driver.Describe());
+ Assert.That(driver.Output, Does.Contain("box-neg-duplicate-hour-both").And.Contain("NOT AS REQUIRED"));
+ Assert.That(driver.Calls, Does.Contain("two-neg-sensible-cooling-substitution|compare"), "an unexpected outcome is collected, not a stop");
+ }
+
+ [Test]
+ public void Negative_ControlFails_Exits10AndNoMutationOutcomeIsEvidence()
+ {
+ Driver driver = Arrange("neg-control");
+ driver.Set("box-ctl-unmutated-copy|compare", new { exit = 10 });
+ Act(driver, "negative");
+
+ Assert.That(driver.Exit, Is.EqualTo(10), driver.Describe());
+ JsonArray runs = driver.Summary("negative")["runs"].AsArray();
+ JsonNode[] box = runs.Where(x => ((string)x["label"]).StartsWith("box-", StringComparison.Ordinal)).ToArray();
+ Assert.That(box.Skip(1).All(x => (bool)x["asRequired"]), Is.True, "the mutations still gave their exits");
+ Assert.That(box.All(x => !(bool)x["evidence"]), Is.True, "but without a passing control none of them is evidence");
+ Assert.That(driver.Output, Does.Contain("box control did not PASS"));
+ }
+
+ [Test]
+ public void Negative_CopyNotMutated_IsNotAsRequired()
+ {
+ Driver driver = Arrange("neg-unmutated");
+ driver.Set("box-neg-delete-hour-both|negative", new { mutate = false });
+ Act(driver, "negative");
+
+ Assert.That(driver.Exit, Is.EqualTo(10), driver.Describe());
+ JsonNode record = driver.Summary("negative")["runs"].AsArray().Single(x => (string)x["label"] == "box-neg-delete-hour-both");
+ Assert.That((int)record["exit"], Is.EqualTo(11), "the stand-in compare still reports BLOCKED");
+ Assert.That((bool)record["asRequired"], Is.False);
+ Assert.That((bool)record["copyAsDesigned"], Is.False);
+ }
+
+ [Test]
+ public void Negative_RecordDisagreesWithTheDriver_StopsAt70()
+ {
+ Driver driver = Arrange("neg-record");
+ driver.Set("box-neg-sensible-cooling-substitution|negative", new { expectedExit = 0 });
+ Act(driver, "negative");
+
+ Assert.That(driver.Exit, Is.EqualTo(70), driver.Describe());
+ Assert.That(driver.Output, Does.Contain("not 'neg-sensible-cooling-substitution' with exit 10"));
+ }
+
+ [Test]
+ public void Negative_CompareInfrastructureError_StopsAt70()
+ {
+ Driver driver = Arrange("neg-infra");
+ driver.Set("box-neg-delete-hour-both|compare", new { exit = 70 });
+ Act(driver, "negative");
+
+ Assert.That(driver.Exit, Is.EqualTo(70), driver.Describe());
+ Assert.That(driver.Output, Does.Contain("STOPPED: compare box-neg-delete-hour-both exit 70"));
+ Assert.That(driver.Calls, Has.None.StartsWith("box-neg-duplicate"));
+ }
+
+ [Test]
+ public void Negative_OriginalChangedByACase_StopsAt70()
+ {
+ Driver driver = Arrange("neg-touch");
+ driver.Set("box-neg-delete-hour-both|negative", new { touch = Path.Combine(driver.Run("box"), "work", "run", "eplusout.sql") });
+ Act(driver, "negative");
+
+ Assert.That(driver.Exit, Is.EqualTo(70), driver.Describe());
+ Assert.That(driver.Output, Does.Contain("STOPPED: box after neg-delete-hour-both: eplusout.sql is not the file the run recorded"));
+ Assert.That((bool)driver.Summary("negative")["original-unchanged:box"], Is.False);
+ }
+
+ // ---- run phase guard ---------------------------------------------------------------------------------------------
+
+ [Test]
+ public void Run_WithoutEngineConfirmation_RefusesBeforeCreatingAnything()
+ {
+ Driver driver = Arrange("run-guard");
+ System.IO.Directory.Delete(driver.Root, true);
+ Act(driver, "run");
+
+ Assert.That(driver.Exit, Is.EqualTo(70), driver.Describe());
+ Assert.That(driver.Output, Does.Contain("-ConfirmEngineRuns"));
+ Assert.That(System.IO.Directory.Exists(driver.Root), Is.False);
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11F3Tests.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11F3Tests.cs
new file mode 100644
index 0000000..3dff1e6
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11F3Tests.cs
@@ -0,0 +1,169 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using NUnit.Framework;
+using SAM.Analytical.OpenStudio.Benchmark;
+using SAM.Analytical.OpenStudio.ResultsReadBack;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Tests
+{
+ ///
+ /// F3 (revision-2 change 2), the mandatory offline proof: Sensible substituted for Total is detected. The synthetic
+ /// two-zone data cool for 1530 hours with a latent part of at least 10 % of Total, and the Sensible peak is at another
+ /// hour. A precondition asserts that every targeted row discriminates Total from Sensible by at least 10^6 x its
+ /// tolerance (hours: a different hour), so the test cannot silently stop discriminating. Each substitution, made in the
+ /// producer objects (the B1a is mapped from them, so the closure holds) and in the import, must FAIL, never BLOCK.
+ ///
+ [TestFixture]
+ public class S11F3Tests
+ {
+ public enum Substitution
+ {
+ Consumption,
+ SpacePeak,
+ ModelPeak,
+ All,
+ }
+
+ private static string Model() => S11Support.Two();
+
+ /// The rows each substitution targets (load type filled in by the test).
+ private static IEnumerable Targets(S11Case run, Substitution substitution, LoadKind kind)
+ {
+ List result = new List();
+ if (substitution == Substitution.Consumption || substitution == Substitution.All)
+ {
+ result.Add("samP|model|" + kind + "|consumption");
+ result.Add("b1a|model|" + kind + "|consumption");
+ }
+
+ if (substitution == Substitution.ModelPeak || substitution == Substitution.All)
+ {
+ foreach (string layer in new[] { "samP", "b1a" })
+ {
+ result.Add(layer + "|model|" + kind + "|peakLoad");
+ result.Add(layer + "|model|" + kind + "|peakHour");
+ }
+ }
+
+ if (substitution == Substitution.SpacePeak || substitution == Substitution.All)
+ {
+ for (int i = 0; i < run.Synthetic.Zones.Count; i++)
+ {
+ foreach (string layer in new[] { "samP", "samI", "b1a" })
+ {
+ result.Add(layer + "|" + run.Space(i) + "|" + kind + "|peakLoad");
+ result.Add(layer + "|" + run.Space(i) + "|" + kind + "|peakHour");
+ }
+ }
+ }
+
+ return result;
+ }
+
+ [Test]
+ public void Precondition_EveryTargetedRowDiscriminatesTotalFromSensibleByAMillionTolerances([Values] LoadKind kind)
+ {
+ S11Case run = S11Support.Evaluate(SyntheticRun.For(Model(), "two"), Model(), "f3-precondition-" + kind);
+ Assert.That(run.Compare.Verdict, Is.EqualTo("PASS"), run.Summary());
+
+ foreach (string id in Targets(run, Substitution.All, kind))
+ {
+ ResultRow row = run.Row(id);
+ if (id.EndsWith("peakHour", StringComparison.Ordinal))
+ {
+ Assert.That(row.SensibleHourDiffers, Is.True, id + ": the Sensible peak must be at another hour (invalid test data otherwise)");
+ }
+ else
+ {
+ Assert.That(row.Margin, Is.GreaterThanOrEqualTo(1e6), id + ": invalid test data, Total and Sensible are not 10^6 tolerances apart");
+ }
+ }
+
+ int hours = run.Synthetic.Sql[SyntheticRun.Key(S11Names.Total(kind), run.Synthetic.Zones[0].IdealLoads)].Count(x => x.Value > 0);
+ Assert.That(hours, Is.GreaterThanOrEqualTo(1000), "at least 1000 hours with load");
+ foreach (SpaceReference space in run.Reference.Spaces)
+ {
+ double latent = space.LatentJoules[kind.ToString()];
+ double total = space.Quantities[S11Names.Total(kind)].Sum;
+ Assert.That(latent / total, Is.GreaterThanOrEqualTo(0.10), "latent at least 10 % of Total");
+ }
+ }
+
+ [Test]
+ public void SensibleSubstitutedForTotal_Fails([Values] Substitution substitution, [Values] LoadKind kind)
+ {
+ string model = Model();
+ SyntheticRun synthetic = SyntheticRun.For(model, "two");
+ S11Case reference = S11Support.Evaluate(synthetic, model, "f3-reference");
+ Assert.That(reference.Compare.Verdict, Is.EqualTo("PASS"), "unmutated control: " + reference.Summary());
+ ModelLoadReference sensible = reference.Reference.ModelOf(kind);
+
+ void Mutate(OpenStudioBenchmarkInputs inputs)
+ {
+ bool heating = kind == LoadKind.Heating;
+ if (substitution == Substitution.Consumption || substitution == Substitution.All)
+ {
+ inputs.ModelResult.SetValue(heating ? AnalyticalModelSimulationResultParameter.ConsumptionHeating : AnalyticalModelSimulationResultParameter.ConsumptionCooling, sensible.Sensible.ConsumptionKwh);
+ }
+
+ if (substitution == Substitution.ModelPeak || substitution == Substitution.All)
+ {
+ inputs.ModelResult.SetValue(heating ? AnalyticalModelSimulationResultParameter.PeakHeatingLoad : AnalyticalModelSimulationResultParameter.PeakCoolingLoad, sensible.Sensible.CoincidentPeakKw);
+ inputs.ModelResult.SetValue(heating ? AnalyticalModelSimulationResultParameter.PeakHeatingHour : AnalyticalModelSimulationResultParameter.PeakCoolingHour, sensible.Sensible.PeakHour);
+ }
+
+ if (substitution == Substitution.SpacePeak || substitution == Substitution.All)
+ {
+ foreach (SyntheticZone zone in synthetic.Zones)
+ {
+ QuantityReference space = S11Support.Sensible(reference.Reference, zone.SpaceGuid, kind);
+ foreach (SpaceSimulationResult result in S11Support.Annual(inputs, zone.SpaceGuid, kind))
+ {
+ result.SetValue(Analytical.SpaceSimulationResultParameter.Load, space.PeakWatts);
+ result.SetValue(Analytical.SpaceSimulationResultParameter.LoadIndex, space.PeakHour);
+ }
+ }
+ }
+ }
+
+ S11Case run = S11Support.Evaluate(synthetic, model, "f3-" + substitution + "-" + kind, mutateInputs: Mutate, beforeCompare: c =>
+ {
+ if (substitution == Substitution.SpacePeak || substitution == Substitution.All)
+ {
+ foreach (SubjectResult result in c.Subject.SamI.Where(x => x.IsAnnual && x.LoadType == kind.ToString()))
+ {
+ QuantityReference space = S11Support.Sensible(c.Reference, Guid.ParseExact(result.Reference, "N"), kind);
+ result.Load = space.PeakWatts;
+ result.LoadIndex = space.PeakHour;
+ }
+ }
+ });
+
+ Assert.That(run.Subject.ClosureIdentical, Is.True, "the B1a is mapped from the substituted objects: the closure holds");
+ Assert.That(run.Compare.Verdict, Is.EqualTo("FAIL"), run.Summary());
+ Assert.That(run.Compare.Blocked, Is.Zero, "a substitution must FAIL, never BLOCK: " + run.Summary());
+ foreach (string id in Targets(run, substitution, kind))
+ {
+ Assert.That(run.Row(id).Status, Is.EqualTo("FAIL"), id);
+ }
+
+ Assert.That(run.Row("b1a|model|-|totalBasisNote").Status, Is.EqualTo("PASS"), "the note still says Total while the values are Sensible: the value rows catch it");
+ LoadKind other = kind == LoadKind.Heating ? LoadKind.Cooling : LoadKind.Heating;
+ Assert.That(run.Row("b1a|model|" + other + "|consumption").Status, Is.EqualTo("PASS"), "the other load type is untouched");
+ }
+
+ [Test]
+ public void MissingTotalBasisNote_Fails()
+ {
+ string model = Model();
+ S11Case run = S11Support.Evaluate(SyntheticRun.For(model, "two"), model, "f3-no-note", afterProduce: c => S11Support.EditB1a(c, d => d.Provenance.Notes.Remove(S11Names.TotalBasisNote)));
+
+ Assert.That(run.Row("b1a|model|-|totalBasisNote").Status, Is.EqualTo("FAIL"));
+ Assert.That(run.Compare.Verdict, Is.EqualTo("FAIL"));
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11IdentityTests.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11IdentityTests.cs
new file mode 100644
index 0000000..b9b5616
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11IdentityTests.cs
@@ -0,0 +1,159 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using NUnit.Framework;
+using SAM.Analytical.OpenStudio.ResultsReadBack;
+using SAM.Analytical.Tas.DirectT3D.Validation.ReadBack;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Tests
+{
+ ///
+ /// The S11 identity is the S09 native identity (geometry bijection + IDF equipment chain). Owner direction at approval:
+ /// duplicate room names are handled through geometry and identity and are never rejected only because the names match.
+ ///
+ [TestFixture]
+ public class S11IdentityTests
+ {
+ private const string SpaceAName = "\"Name\": \"FixA\"";
+ private const string SpaceBName = "\"Name\": \"FixB\"";
+
+ [Test]
+ public void DuplicateSpaceNames_DistinctGeometry_ResolvedByGeometryAndPass()
+ {
+ // Same display name: space B gets a distinct GUID prefix, because the same name AND the same prefix give
+ // identical generated names (the separately tracked generated-name identity residual, next test).
+ string model = S11Support.Variant(TestSupport.Two, "two-same-name-distinct-prefix", (S11Support.SpaceBGuid, "6181d111-0000-0000-0000-00000000c0b1"), (SpaceBName, SpaceAName));
+ AnalyticalModel loaded = Core.Convert.ToSAM(model).Single();
+ List spaces = loaded.AdjacencyCluster.GetSpaces();
+ Assert.That(spaces.Select(x => x.Name).Distinct().Count(), Is.EqualTo(1), "both spaces carry the same name");
+
+ S11Case run = S11Support.Evaluate(SyntheticRun.For(model, "two-same-name"), model, "same-name");
+
+ Assert.That(run.Compare.Verdict, Is.EqualTo("PASS"), run.Summary());
+ Assert.That(run.Reference.Spaces.Select(x => x.NativeSpace).Distinct(StringComparer.OrdinalIgnoreCase).Count(), Is.EqualTo(2), "two native spaces, told apart by geometry");
+ Assert.That(run.Reference.Spaces.All(x => x.IdentityProblem == null && x.SurfacePairs > 0), Is.True);
+ Assert.That(run.Compare.Rows.Where(x => x.Layer == "identity" && x.Quantity == "native").All(x => x.Status == "PASS"), Is.True);
+ }
+
+ ///
+ /// Same names AND the shared GUID prefix of the signed-off fixture: the writer's names collide, so OpenStudio makes
+ /// one unique. The reference still identifies both spaces by geometry; the production key cross-check then fails
+ /// for the renamed one, which is a production naming collision, not a rejection of duplicate names. This is the
+ /// separately tracked generated-name identity residual (exact-name lookup for identical generated names); F-S11-1
+ /// (#35) deliberately leaves such keys unmapped rather than guessing.
+ ///
+ [Test]
+ public void DuplicateSpaceNames_SharedGuidPrefix_IdentityStillByGeometry_ProductionKeyCollisionReported()
+ {
+ string model = S11Support.Variant(TestSupport.Two, "two-same-name-shared-prefix", (SpaceBName, SpaceAName));
+ S11Case run = S11Support.Evaluate(SyntheticRun.For(model, "two-same-name-shared"), model, "same-name-shared");
+
+ Assert.That(run.Reference.Spaces.All(x => x.IdentityProblem == null), Is.True, string.Join("; ", run.Reference.Spaces.Select(x => x.IdentityProblem)));
+ Assert.That(run.Reference.Spaces.Select(x => x.IdealLoads).Distinct(StringComparer.OrdinalIgnoreCase).Count(), Is.EqualTo(2), "the native Ideal Loads are distinct");
+ List keyRows = run.Compare.Rows.Where(x => x.Layer == "identity" && x.Quantity == "idealLoadsKey").ToList();
+ Assert.That(keyRows.Count(x => x.Status == "FAIL"), Is.GreaterThanOrEqualTo(1), "the colliding production key differs from one native key: " + run.Summary());
+ Assert.That(run.Compare.Rows.Where(x => x.Layer == "identity" && x.Quantity == "native").All(x => x.Status == "PASS"), Is.True, "no space is rejected for its name");
+ }
+
+ [Test]
+ public void NativeIdentity_OnTheExportedTwoZoneIdf_IsBijectiveAndIndependentOfNames()
+ {
+ string model = S11Support.Model(TestSupport.Two);
+ AnalyticalModel loaded = Core.Convert.ToSAM(model).Single();
+ List idf = IdfText.Parse(SyntheticRun.ExportIdf(model, "two")).Where(x => NativeReader.IdfTypes.Contains(x.Type, StringComparer.OrdinalIgnoreCase)).ToList();
+ ExpectedModel expected = ReadBackExpected.Build(loaded, null, TestSupport.Two);
+
+ List identity = NativeZoneIdentity.Resolve(new NativeModel(new NativeSnapshot { Idf = idf }), expected);
+ Assert.That(identity, Has.Count.EqualTo(2));
+ Assert.That(identity.All(x => x.Problem == null && x.NameCarrierAgrees), Is.True);
+ Assert.That(identity.Select(x => x.Zone).Distinct().Count(), Is.EqualTo(2));
+
+ // Swapping the names of the two native spaces does not change which geometry each SAM space matches.
+ string a = identity[0].NativeSpace;
+ string b = identity[1].NativeSpace;
+ string swapped = SyntheticRun.ExportIdf(model, "two").Replace(a, "@@A@@").Replace(b, a).Replace("@@A@@", b);
+ List swappedIdf = IdfText.Parse(swapped).Where(x => NativeReader.IdfTypes.Contains(x.Type, StringComparer.OrdinalIgnoreCase)).ToList();
+ List after = NativeZoneIdentity.Resolve(new NativeModel(new NativeSnapshot { Idf = swappedIdf }), expected);
+ Assert.That(after[0].NativeSpace, Is.EqualTo(b), "geometry, not the name, makes the match");
+ Assert.That(after[0].NameCarrierAgrees, Is.True, "both names carry the shared GUID prefix, so the carrier cannot tell them apart");
+ }
+ }
+
+ /// Unit checks of the S11 calendar, ESO dictionary parsing and the completeness judgement.
+ [TestFixture]
+ public class S11UnitTests
+ {
+ private static RunCalendar Calendar(string beginYear, string endMonth = "12", string endDay = "31", string endYear = null)
+ {
+ string idf = "RunPeriod,\n Run 1, !- Name\n 1, !- Begin Month\n 1, !- Begin Day of Month\n " + beginYear + ", !- Begin Year\n " + endMonth + ", !- End Month\n " + endDay + ", !- End Day of Month\n " + (endYear ?? beginYear) + ", !- End Year\n Sunday; !- Day of Week for Start Day\n";
+ return RunCalendar.FromIdf(IdfText.Parse(idf));
+ }
+
+ [Test]
+ public void Calendar_FullNonLeapYear_Has8760OrderedHours()
+ {
+ RunCalendar calendar = Calendar("2006");
+ Assert.That(calendar.Problem, Is.Null);
+ Assert.That(calendar.Count, Is.EqualTo(8760));
+ Assert.That(calendar.IndexOf(new HourKey(1, 1, 1)), Is.EqualTo(0));
+ Assert.That(calendar.IndexOf(new HourKey(12, 31, 24)), Is.EqualTo(8759));
+ Assert.That(calendar.IndexOf(new HourKey(7, 7, 13)), Is.EqualTo(4500));
+ Assert.That(calendar.IndexOf(new HourKey(2, 29, 1)), Is.EqualTo(-1));
+ Assert.That(Calendar(string.Empty).Problem, Is.Null, "a blank year is EnergyPlus's non-leap default");
+ }
+
+ [TestCase("2024", "12", "31", null, "leap")]
+ [TestCase("2006", "6", "30", null, "full year")]
+ [TestCase("2006", "12", "31", "2007", "more than one year")]
+ public void Calendar_OtherRunPeriods_AreProblems(string beginYear, string endMonth, string endDay, string endYear, string expected)
+ {
+ RunCalendar calendar = Calendar(beginYear, endMonth, endDay, endYear);
+ Assert.That(calendar.Problem, Does.Contain(expected));
+ Assert.That(calendar.Count, Is.Zero);
+ }
+
+ [Test]
+ public void Calendar_TwoRunPeriods_IsAProblem()
+ {
+ string one = "RunPeriod, A, 1, 1, 2006, 12, 31, 2006;\n";
+ Assert.That(RunCalendar.FromIdf(IdfText.Parse(one + one.Replace(" A,", " B,"))).Problem, Does.Contain("2 RunPeriod"));
+ }
+
+ [Test]
+ public void EsoDictionaryLine_ParsesVariablesAndMeters()
+ {
+ EsoVariable variable = EsoResultRead.DictionaryLine("395,1,SAM_IDEALLOADS_OFFICE_CB68BED1,Zone Ideal Loads Supply Air Total Cooling Energy [J] !Hourly");
+ Assert.That(variable.Id, Is.EqualTo(395));
+ Assert.That(variable.Key, Is.EqualTo("SAM_IDEALLOADS_OFFICE_CB68BED1"));
+ Assert.That(variable.Name, Is.EqualTo(S11Names.TotalCooling));
+ Assert.That(variable.Units, Is.EqualTo("J"));
+ Assert.That(variable.Frequency, Is.EqualTo("Hourly"));
+ Assert.That(variable.IsMeter, Is.False);
+
+ EsoVariable meter = EsoResultRead.DictionaryLine("396,1,DistrictCooling:Facility [J] !TimeStep");
+ Assert.That(meter.IsMeter, Is.True);
+ Assert.That(meter.Name, Is.EqualTo("DistrictCooling:Facility"));
+ Assert.That(meter.Frequency, Is.EqualTo("TimeStep"));
+ }
+
+ [Test]
+ public void Completeness_CountAloneIsNeverAccepted()
+ {
+ RunCalendar calendar = Calendar("2006");
+ ChannelSeries series = new ChannelSeries { Channel = "sql", Variable = S11Names.TotalCooling, Key = "K", DictionaryEntries = 1 };
+ series.Points.AddRange(calendar.Hours.Select(x => new HourlyPoint { Month = x.Month, Day = x.Day, Hour = x.Hour, Value = 1 }));
+ Assert.That(Completeness.Check(calendar, series).Complete, Is.True);
+
+ series.Points[101].Hour = series.Points[100].Hour;
+ CompletenessResult shifted = Completeness.Check(calendar, series);
+ Assert.That(shifted.Found, Is.EqualTo(8760));
+ Assert.That(shifted.Complete, Is.False);
+ Assert.That(shifted.Missing, Is.EqualTo(1));
+ Assert.That(shifted.Duplicated, Is.EqualTo(1));
+ Assert.That(shifted.Values, Is.Null);
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11NegativeTests.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11NegativeTests.cs
new file mode 100644
index 0000000..02fd795
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11NegativeTests.cs
@@ -0,0 +1,341 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using NUnit.Framework;
+using SAM.Analytical.Benchmark;
+using SAM.Analytical.OpenStudio.Benchmark;
+using SAM.Analytical.OpenStudio.ResultsReadBack;
+using System;
+using System.Data.SQLite;
+using System.IO;
+using System.Linq;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Tests
+{
+ ///
+ /// S11 negatives other than completeness and F3 (plan section 7): each must FAIL or BLOCK by its intended mechanism,
+ /// and the zero rule, ties and design loads are checked positively as well.
+ ///
+ [TestFixture]
+ public class S11NegativeTests
+ {
+ private static string Two() => S11Support.Two();
+
+ private static SyntheticRun Synthetic() => SyntheticRun.For(Two(), "two");
+
+ private static void AssertFails(S11Case run, params string[] rows)
+ {
+ Assert.That(run.Compare.Verdict, Is.EqualTo("FAIL"), run.Summary());
+ foreach (string row in rows)
+ {
+ Assert.That(run.Row(row).Status, Is.EqualTo("FAIL"), row + ": " + run.Row(row).Reason);
+ }
+ }
+
+ private static void AssertBlocks(S11Case run, string reason)
+ {
+ Assert.That(run.Compare.Verdict, Is.EqualTo("BLOCKED"), run.Summary());
+ Assert.That(run.Compare.Failed, Is.Zero, run.Summary());
+ Assert.That(string.Join(" ", run.Compare.Rows.Select(x => x.Reason)), Does.Contain(reason));
+ }
+
+ // ---- zero rule and ties ----------------------------------------------------------------------------------------
+
+ private static SyntheticRun ZeroCoolingInSpace1()
+ {
+ SyntheticRun synthetic = Synthetic();
+ synthetic.EditSeries(true, true, s => s.ForEach(p => p.Value = 0), k => k == SyntheticRun.Key(S11Names.TotalCooling, synthetic.Zones[1].IdealLoads) || k == SyntheticRun.Key(S11Names.SensibleCooling, synthetic.Zones[1].IdealLoads));
+ return synthetic;
+ }
+
+ [Test]
+ public void AllZeroCooling_B1aLoadZeroHourUnavailable_SamHourZeroAccepted_Passes()
+ {
+ S11Case run = S11Support.Evaluate(ZeroCoolingInSpace1(), Two(), "zero-rule");
+
+ Assert.That(run.Compare.Verdict, Is.EqualTo("PASS"), run.Summary());
+ Assert.That(run.Reference.Space(run.Synthetic.Zones[1].SpaceGuid).Quantities[S11Names.TotalCooling].AllZero, Is.True);
+ Assert.That(run.Row("b1a|" + run.Space(1) + "|Cooling|peakHour").Reason, Does.Contain("zero rule"));
+ Assert.That(run.Row("samP|" + run.Space(1) + "|Cooling|peakHour").Subject, Is.EqualTo(0), "SAM's own earliest-of-a-full-year-tie hour 0");
+ }
+
+ [Test]
+ public void ZeroReplay_B1aPublishesHourZeroForAnAllZeroYear_Fails()
+ {
+ S11Case run = S11Support.Evaluate(ZeroCoolingInSpace1(), Two(), "zero-replay", afterProduce: c => S11Support.EditB1a(c, d =>
+ {
+ d.Spaces.Single(x => x.Guid == c.Synthetic.Zones[1].SpaceGuid.ToString("N")).Cooling.PeakHour = MetricValue.AvailableValue(0, MetricUnit.HourOfYear);
+ }));
+
+ AssertFails(run, "b1a|" + run.Space(1) + "|Cooling|peakHour");
+ }
+
+ [Test]
+ public void MissingZoneReportedAsZero_Blocks()
+ {
+ SyntheticRun synthetic = Synthetic();
+ foreach (string key in synthetic.Sql.Keys.Where(x => x.EndsWith("|" + synthetic.Zones[1].IdealLoads, StringComparison.Ordinal)).ToList())
+ {
+ synthetic.Sql.Remove(key);
+ synthetic.Eso.Remove(key);
+ }
+
+ S11Case run = S11Support.Evaluate(synthetic, Two(), "missing-zone-zero", afterProduce: c => S11Support.EditB1a(c, d =>
+ {
+ BenchmarkSpaceResult space = d.Spaces.Single(x => x.Guid == c.Synthetic.Zones[1].SpaceGuid.ToString("N"));
+ space.Cooling.PeakLoad = MetricValue.AvailableValue(0, MetricUnit.Watt);
+ }));
+
+ Assert.That(run.Compare.Verdict, Is.Not.EqualTo("PASS"));
+ Assert.That(run.Row("b1a|" + run.Space(1) + "|Cooling|peakLoad").Status, Is.EqualTo("BLOCKED"), "a missing zone is unavailable in the reference, never 0");
+ }
+
+ [Test]
+ public void Tie_EarliestHourRequired_LaterTieHourFails()
+ {
+ SyntheticRun synthetic = Synthetic();
+ string total = SyntheticRun.Key(S11Names.TotalCooling, synthetic.Zones[0].IdealLoads);
+ double peak = synthetic.Sql[total].Max(x => x.Value);
+ synthetic.EditSeries(true, true, s => s.Single(p => p.Month == 9 && p.Day == 1 && p.Hour == 12).Value = peak, k => k == total);
+ S11Case control = S11Support.Evaluate(synthetic, Two(), "tie-control");
+ Assert.That(control.Reference.Space(synthetic.Zones[0].SpaceGuid).Quantities[S11Names.TotalCooling].TieCount, Is.EqualTo(2));
+ Assert.That(control.Compare.Verdict, Is.EqualTo("PASS"), control.Summary());
+
+ int later = control.Reference.Space(synthetic.Zones[0].SpaceGuid).Quantities[S11Names.TotalCooling].PeakHour;
+ int septemberFirst = (31 + 28 + 31 + 30 + 31 + 30 + 31 + 31) * 24 + 11;
+ Assert.That(later, Is.LessThan(septemberFirst), "the earliest tie is the August peak");
+ S11Case run = S11Support.Evaluate(synthetic, Two(), "tie-later", mutateInputs: inputs =>
+ {
+ foreach (SpaceSimulationResult result in S11Support.Annual(inputs, synthetic.Zones[0].SpaceGuid, LoadKind.Cooling))
+ {
+ result.SetValue(Analytical.SpaceSimulationResultParameter.LoadIndex, septemberFirst);
+ }
+ });
+
+ AssertFails(run, "samP|" + run.Space(0) + "|Cooling|peakHour", "b1a|" + run.Space(0) + "|Cooling|peakHour");
+ }
+
+ // ---- value slips ---------------------------------------------------------------------------------------------
+
+ [TestCase("hourPlusOne")]
+ [TestCase("hourMinusOne")]
+ [TestCase("heatingCoolingSwapped")]
+ [TestCase("times1000")]
+ [TestCase("dividedBy3600")]
+ [TestCase("consumptionOff1e-6")]
+ public void ValueSlip_Fails(string slip)
+ {
+ Guid space = Synthetic().Zones[0].SpaceGuid;
+ S11Case run = S11Support.Evaluate(Synthetic(), Two(), "slip-" + slip, mutateInputs: inputs =>
+ {
+ switch (slip)
+ {
+ case "hourPlusOne":
+ case "hourMinusOne":
+ foreach (SpaceSimulationResult result in S11Support.Annual(inputs, space, LoadKind.Cooling))
+ {
+ result.TryGetValue(Analytical.SpaceSimulationResultParameter.LoadIndex, out int hour);
+ result.SetValue(Analytical.SpaceSimulationResultParameter.LoadIndex, hour + (slip == "hourPlusOne" ? 1 : -1));
+ }
+
+ break;
+ case "heatingCoolingSwapped":
+ foreach (SpaceSimulationResult result in inputs.SpaceResults.Where(x => x.Reference == space.ToString("N")))
+ {
+ result.TryGetValue(Analytical.SpaceSimulationResultParameter.LoadType, out string loadType);
+ result.SetValue(Analytical.SpaceSimulationResultParameter.LoadType, loadType == "Heating" ? "Cooling" : "Heating");
+ }
+
+ break;
+ case "times1000":
+ foreach (SpaceSimulationResult result in S11Support.Annual(inputs, space, LoadKind.Cooling))
+ {
+ result.TryGetValue(Analytical.SpaceSimulationResultParameter.Load, out double load);
+ result.SetValue(Analytical.SpaceSimulationResultParameter.Load, load * 1000);
+ }
+
+ break;
+ case "dividedBy3600":
+ inputs.ModelResult.TryGetValue(AnalyticalModelSimulationResultParameter.ConsumptionCooling, out double kwh);
+ inputs.ModelResult.SetValue(AnalyticalModelSimulationResultParameter.ConsumptionCooling, kwh / 3600);
+ break;
+ case "consumptionOff1e-6":
+ inputs.ModelResult.TryGetValue(AnalyticalModelSimulationResultParameter.ConsumptionCooling, out double value);
+ inputs.ModelResult.SetValue(AnalyticalModelSimulationResultParameter.ConsumptionCooling, value * (1 + 1e-6));
+ break;
+ }
+ });
+
+ string target = slip.StartsWith("hour", StringComparison.Ordinal) ? "b1a|" + run.Space(0) + "|Cooling|peakHour"
+ : slip == "times1000" || slip == "heatingCoolingSwapped" ? "b1a|" + run.Space(0) + "|Cooling|peakLoad"
+ : "b1a|model|Cooling|consumption";
+ AssertFails(run, target, target.Replace("b1a|", "samP|"));
+ }
+
+ // ---- channels, keys, identity ----------------------------------------------------------------------------------
+
+ [Test]
+ public void SqlAndEsoDisagreeInOneHour_Blocks()
+ {
+ SyntheticRun synthetic = Synthetic();
+ synthetic.EditSeries(false, true, s => s.Single(p => p.Month == 8 && p.Day == 1 && p.Hour == 12).Value += 1, k => k == SyntheticRun.Key(S11Names.TotalCooling, synthetic.Zones[0].IdealLoads));
+ AssertBlocks(S11Support.Evaluate(synthetic, Two(), "c4-disagree"), "S11-C4");
+ }
+
+ [Test]
+ public void UnknownReportKey_Blocks()
+ {
+ SyntheticRun synthetic = Synthetic();
+ string ghost = SyntheticRun.Key(S11Names.TotalCooling, "SAM_IDEALLOADS_GHOST_0badf00d");
+ synthetic.Sql[ghost] = synthetic.Sql[SyntheticRun.Key(S11Names.TotalCooling, synthetic.Zones[0].IdealLoads)].Select(x => x.Copy()).ToList();
+ synthetic.Eso[ghost] = synthetic.Sql[ghost].Select(x => x.Copy()).ToList();
+ AssertBlocks(S11Support.Evaluate(synthetic, Two(), "c5-unknown-key"), "S11-C5");
+ }
+
+ [Test]
+ public void NativeKeyIsNotTheProductionKey_Fails()
+ {
+ S11Case run = S11Support.Evaluate(Synthetic(), Two(), "key-mismatch", beforeCompare: c => c.Subject.ProductionKeys[0].IdealLoads = "SAM_IdealLoads_Other_00000000");
+ AssertFails(run, "identity|space:" + run.Subject.ProductionKeys[0].SpaceGuid.ToString("N") + "|-|idealLoadsKey");
+ }
+
+ [Test]
+ public void ZoneWithTwoIdealLoadsSystems_Blocks()
+ {
+ SyntheticRun synthetic = Synthetic();
+ int at =synthetic.Idf.IndexOf("ZoneHVAC:EquipmentList,", StringComparison.Ordinal);
+ Assert.That(at, Is.GreaterThanOrEqualTo(0));
+ int end = synthetic.Idf.IndexOf(';', at);
+ string block = synthetic.Idf.Substring(at, end - at);
+ synthetic.Idf = synthetic.Idf.Substring(0, at) + block + ",\n ZoneHVAC:IdealLoadsAirSystem, !- Zone Equipment Object Type 2\n " + synthetic.Zones[1].IdealLoads + ", !- Zone Equipment Name 2\n 2, !- Zone Equipment Cooling Sequence 2\n 2" + synthetic.Idf.Substring(end);
+
+ S11Case run = S11Support.Evaluate(synthetic, Two(), "two-ideal-loads");
+ Assert.That(run.Compare.Verdict, Is.Not.EqualTo("PASS"), run.Summary());
+ Assert.That(run.Reference.Spaces.Any(x => x.IdentityProblem != null && (x.IdentityProblem.Contains("Ideal Loads") || x.IdentityProblem.Contains("bijective"))), Is.True, string.Join("; ", run.Reference.Spaces.Select(x => x.IdentityProblem)));
+ }
+
+ [Test]
+ public void NonOpenStudioSource_Fails()
+ {
+ S11Case run = S11Support.Evaluate(Synthetic(), Two(), "source", beforeCompare: c => c.Subject.SamI.First(x => x.IsAnnual).Source = "SAM.Analytical.Tas");
+ Assert.That(run.Compare.Verdict, Is.EqualTo("FAIL"), run.Summary());
+ Assert.That(run.Compare.Rows.Any(x => x.Layer == "samI" && x.Quantity == "result" && x.Status == "FAIL" && x.Reason.Contains("source")), Is.True);
+ }
+
+ [Test]
+ public void MissingResult_Fails()
+ {
+ Guid space = Synthetic().Zones[1].SpaceGuid;
+ S11Case run = S11Support.Evaluate(Synthetic(), Two(), "missing-result", mutateInputs: inputs => inputs.SpaceResults.RemoveAll(x => x.Reference == space.ToString("N") && x.TryGetValue(Analytical.SpaceSimulationResultParameter.LoadType, out string t) && t == "Heating"));
+ AssertFails(run, "samP|" + run.Space(1) + "|Heating|result", "b1a|" + run.Space(1) + "|Heating|peakLoad");
+ }
+
+ [Test]
+ public void CapturedObjectsAlteredAfterTheB1a_ClosureFails_AndSamPRowsBlock()
+ {
+ S11Case run = S11Support.Evaluate(Synthetic(), Two(), "closure", afterProduce: c =>
+ {
+ OpenStudioBenchmarkInputs inputs = OpenStudioBenchmarkInputs.Read(Path.Combine(c.Run, RunLayout.SamResultsFile));
+ inputs.SpaceResults[0].TryGetValue(Analytical.SpaceSimulationResultParameter.Load, out double load);
+ inputs.SpaceResults[0].SetValue(Analytical.SpaceSimulationResultParameter.Load, load + 1);
+ inputs.Write(Path.Combine(c.Run, RunLayout.SamResultsFile));
+ });
+
+ Assert.That(run.Subject.ClosureIdentical, Is.False, run.Subject.ClosureDetail);
+ AssertFails(run, "closure|model|-|samPToB1a");
+ Assert.That(run.Compare.Rows.Where(x => x.Layer == "samP").All(x => x.Status == "BLOCKED"), Is.True, "every SAM-P row blocks without the closure");
+ }
+
+ // ---- sizing, design load, versions, meters, basis -----------------------------------------------------------
+
+ [Test]
+ public void ZoneSizesWithDesignDaySourceNone_Blocks()
+ {
+ SyntheticRun synthetic = Synthetic();
+ synthetic.ZoneSizes.Add(new ZoneSizeRow { ZoneName = synthetic.Zones[0].Zone, LoadType = "Heating", CalcDesLoad = 1234.5 });
+ AssertBlocks(S11Support.Evaluate(synthetic, Two(), "c8-sizes-none"), "S11-C8");
+ }
+
+ [Test]
+ public void DesignLoadMappedFromCalcDesLoad_WhenSizingRan_Passes()
+ {
+ SyntheticRun synthetic = Synthetic();
+ foreach (SyntheticZone zone in synthetic.Zones)
+ {
+ synthetic.ZoneSizes.Add(new ZoneSizeRow { ZoneName = zone.Zone, LoadType = "Heating", CalcDesLoad = 2345.25 });
+ synthetic.ZoneSizes.Add(new ZoneSizeRow { ZoneName = zone.Zone, LoadType = "Cooling", CalcDesLoad = 1875.125 });
+ }
+
+ S11Case run = S11Support.Evaluate(synthetic, Two(), "design-load", DesignDaySource.EmbeddedModel);
+ Assert.That(run.Compare.Verdict, Is.EqualTo("PASS"), run.Summary());
+ Assert.That(run.Row("b1a|" + run.Space(0) + "|Heating|designLoad").Reference, Is.EqualTo(2345.25));
+ Assert.That(run.Row("samP|" + run.Space(1) + "|Cooling|designLoad").Status, Is.EqualTo("PASS"));
+ Assert.That(run.Row("samI|" + run.Space(1) + "|Cooling|designLoad").Status, Is.EqualTo("INFO"), "the legacy SAM-I design-day path is informational");
+ }
+
+ [Test]
+ public void DesignLoadPublishedAsZeroWithoutSizing_Fails()
+ {
+ S11Case run = S11Support.Evaluate(Synthetic(), Two(), "design-zero", afterProduce: c => S11Support.EditB1a(c, d => d.Spaces[0].Heating.DesignLoad = MetricValue.AvailableValue(0, MetricUnit.Watt)));
+ Assert.That(run.Compare.Verdict, Is.EqualTo("FAIL"));
+ Assert.That(run.Compare.Rows.Any(x => x.Layer == "b1a" && x.Quantity == "designLoad" && x.Status == "FAIL"), Is.True);
+ }
+
+ [Test]
+ public void WrongEnergyPlusVersion_Blocks()
+ {
+ SyntheticRun synthetic = Synthetic();
+ synthetic.SqlVersion = "EnergyPlus, Version 24.2.0-94a887817b, YMD=2026.10.24 08:00";
+ AssertBlocks(S11Support.Evaluate(synthetic, Two(), "c9-version"), "S11-C9");
+ }
+
+ [Test]
+ public void FacilityMeterDisagreesWithTheZones_BlocksConsumption()
+ {
+ SyntheticRun synthetic = Synthetic();
+ synthetic.SqlEdit = connection =>
+ {
+ using (SQLiteCommand command = connection.CreateCommand())
+ {
+ command.CommandText = "UPDATE ReportData SET Value = Value + 1000 WHERE ReportDataIndex = (SELECT MIN(rd.ReportDataIndex) FROM ReportData rd JOIN ReportDataDictionary d ON d.ReportDataDictionaryIndex = rd.ReportDataDictionaryIndex WHERE d.Name = @n)";
+ command.Parameters.AddWithValue("@n", S11Names.CoolingMeter);
+ command.ExecuteNonQuery();
+ }
+ };
+
+ S11Case run = S11Support.Evaluate(synthetic, Two(), "c7-meter");
+ Assert.That(run.Row("b1a|model|Cooling|consumption").Status, Is.EqualTo("BLOCKED"));
+ Assert.That(run.Row("b1a|model|Cooling|consumption").Reason, Does.Contain("S11-C7"));
+ Assert.That(run.Row("b1a|model|Cooling|peakLoad").Status, Is.EqualTo("PASS"), "the meter only qualifies the consumption");
+ }
+
+ [Test]
+ public void NegativeLatent_BlocksThatLoadType()
+ {
+ SyntheticRun synthetic = Synthetic();
+ synthetic.EditSeries(true, true, s => s.Single(p => p.Month == 8 && p.Day == 2 && p.Hour == 12).Value += 5_000_000, k => k == SyntheticRun.Key(S11Names.SensibleCooling, synthetic.Zones[0].IdealLoads));
+ S11Case run = S11Support.Evaluate(synthetic, Two(), "c6-negative-latent");
+
+ Assert.That(run.Row("b1a|" + run.Space(0) + "|Cooling|peakLoad").Status, Is.EqualTo("BLOCKED"));
+ Assert.That(run.Row("b1a|" + run.Space(0) + "|Cooling|peakLoad").Reason, Does.Contain("S11-C6"));
+ Assert.That(run.Row("b1a|" + run.Space(0) + "|Heating|peakLoad").Status, Is.EqualTo("PASS"));
+ }
+
+ [Test]
+ public void WrongUnit_Blocks()
+ {
+ SyntheticRun synthetic = Synthetic();
+ synthetic.SqlEdit = connection =>
+ {
+ using (SQLiteCommand command = connection.CreateCommand())
+ {
+ command.CommandText = "UPDATE ReportDataDictionary SET Units = 'kWh' WHERE Name = @n";
+ command.Parameters.AddWithValue("@n", S11Names.TotalHeating);
+ command.ExecuteNonQuery();
+ }
+ };
+
+ AssertBlocks(S11Support.Evaluate(synthetic, Two(), "c3-unit"), "S11-C3");
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11ResultsTests.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11ResultsTests.cs
new file mode 100644
index 0000000..5998a08
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11ResultsTests.cs
@@ -0,0 +1,229 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using NUnit.Framework;
+using SAM.Analytical.Benchmark;
+using SAM.Analytical.OpenStudio.Benchmark;
+using SAM.Analytical.OpenStudio.ResultsReadBack;
+using SAM.Analytical.OpenStudio.ResultsReadBack.Cli;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Tests
+{
+ ///
+ /// S11 positive path and the separate-process harness: a consistent synthetic run PASSes every required row with read,
+ /// subject and compare each in its own process, and the CLI negatives on copies give their required exits.
+ ///
+ [TestFixture]
+ public class S11ResultsTests
+ {
+ [Test]
+ public void ConsistentTwoZoneRun_PassesEveryRow_InSeparateProcesses_AndTheNegativesRefuseCopies()
+ {
+ string model = S11Support.Two();
+ S11Case run = S11Support.Prepare(SyntheticRun.For(model, "two"), model, "two-cli");
+
+ Assert.That(S11Support.Cli(out string read, "read", "--run", run.Run, "--model", model, "--fixture-id", TestSupport.Two), Is.EqualTo(0), read);
+ Assert.That(S11Support.Cli(out string subject, "subject", "--run", run.Run, "--model", model), Is.EqualTo(0), subject);
+ int exit = S11Support.Cli(out string compare, "compare", "--run", run.Run, "--model", model);
+ CompareDocument document = S11Json.Load(Path.Combine(run.Run, "compare", "compare.json"));
+ Assert.That(exit, Is.EqualTo(0), compare + " " + string.Join(" | ", document.Rows.Where(x => x.Status != "PASS").Take(10).Select(x => x.Id + " " + x.Status + " " + x.Reason)));
+ Assert.That(document.Verdict, Is.EqualTo("PASS"));
+ Assert.That(document.Failed + document.Blocked, Is.Zero);
+ Assert.That(document.Required, Is.GreaterThan(60), "every layer, space, load type and quantity is a row");
+ Assert.That(document.Notes, Does.Contain(CompareDocument.S10Limitation));
+ Assert.That(document.F3.MeetsOd7, Is.True, document.F3.Detail);
+
+ ReferenceDocument reference = S11Json.Load(Path.Combine(run.Run, "reference.json"));
+ SubjectDocument captured = S11Json.Load(Path.Combine(run.Run, "subject.json"));
+ Assert.That(new[] { reference.ProcessId, captured.ProcessId, document.ProcessId }.Distinct().Count(), Is.EqualTo(3), "read, subject and compare ran in three processes");
+ Assert.That(reference.SqlSha256Before, Is.EqualTo(reference.SqlSha256After));
+ Assert.That(reference.EsoSha256Before, Is.EqualTo(reference.EsoSha256After));
+ Assert.That(captured.ClosureIdentical, Is.True, captured.ClosureDetail);
+ Assert.That(File.ReadAllText(Path.Combine(run.Run, "compare", "compare.md")), Does.Contain("Verdict: **PASS**"));
+
+ // Negatives on copies, each read/subject/compared fresh; the original stays byte-identical.
+ string before = Sha256.Of(Path.Combine(run.Run, "work", "run", "eplusout.sql")) + Sha256.Of(Path.Combine(run.Run, "work", "run", "eplusout.eso")) + Sha256.Of(Path.Combine(run.Run, "b1a.json")) + Sha256.Of(Path.Combine(run.Run, "sam.json"));
+ foreach (string caseId in NegativeStep.Cases.Keys)
+ {
+ string copy = run.Run + "-" + caseId;
+ Assert.That(S11Support.Cli(out string negative, "negative", "--run", run.Run, "--model", model, "--case", caseId, "--out", copy), Is.EqualTo(0), negative);
+ Assert.That(S11Support.Cli(out string copyRead, "read", "--run", copy, "--model", model, "--fixture-id", TestSupport.Two), Is.EqualTo(0), copyRead);
+ if (!File.Exists(Path.Combine(copy, "subject.json")))
+ {
+ Assert.That(S11Support.Cli(out string copySubject, "subject", "--run", copy, "--model", model), Is.EqualTo(0), copySubject);
+ }
+
+ int copyExit = S11Support.Cli(out string copyCompare, "compare", "--run", copy, "--model", model);
+ CompareDocument copyDocument = S11Json.Load(Path.Combine(copy, "compare", "compare.json"));
+ Assert.That(copyExit, Is.EqualTo(NegativeStep.Cases[caseId]), caseId + ": " + copyCompare + " " + string.Join(" | ", copyDocument.Rows.Where(x => x.Status != "PASS").Take(6).Select(x => x.Id + " " + x.Status + " " + x.Reason)));
+ if (caseId == "neg-sensible-cooling-substitution")
+ {
+ Assert.That(copyDocument.Blocked, Is.Zero, "a substitution must FAIL, never BLOCK");
+ Assert.That(copyDocument.Rows.Where(x => x.LoadType == "Cooling" && (x.Quantity == "peakLoad" || x.Quantity == "consumption") && x.Layer != "identity").All(x => x.Status == "FAIL"), Is.True, "every cooling value row fails");
+ Assert.That(copyDocument.Inputs["mutation"], Does.Contain("Sensible"));
+ }
+ }
+
+ string after = Sha256.Of(Path.Combine(run.Run, "work", "run", "eplusout.sql")) + Sha256.Of(Path.Combine(run.Run, "work", "run", "eplusout.eso")) + Sha256.Of(Path.Combine(run.Run, "b1a.json")) + Sha256.Of(Path.Combine(run.Run, "sam.json"));
+ Assert.That(after, Is.EqualTo(before), "the negatives never touch the original run");
+ }
+
+ [Test]
+ public void ConsistentBoxRun_PassesEveryRow()
+ {
+ string model = S11Support.Model(TestSupport.Box);
+ S11Case run = S11Support.Evaluate(SyntheticRun.For(model, "box"), model, "box");
+
+ Assert.That(run.Compare.Verdict, Is.EqualTo("PASS"), run.Summary());
+ Assert.That(run.Reference.Spaces, Has.Count.EqualTo(1));
+ Assert.That(run.Reference.Checks.Where(x => x.Status == "BLOCKED"), Is.Empty);
+ Assert.That(run.Reference.Completeness.All(x => x.Complete && x.Found == 8760), Is.True);
+ Assert.That(run.Reference.Checks.Count(x => x.Id == "S11-C7" && x.Status == "PASS"), Is.EqualTo(4), "both meters in both files equal the zones' Total energy");
+ }
+
+ [Test]
+ public void CompareStep_RefusesInputsTheReadAndSubjectDidNotSee()
+ {
+ string model = S11Support.Model(TestSupport.Box);
+ S11Case run = S11Support.Evaluate(SyntheticRun.For(model, "box"), model, "box-consistency");
+ S11Json.Save(run.Reference, Path.Combine(run.Run, RunLayout.ReferenceFile));
+ S11Json.Save(run.Subject, Path.Combine(run.Run, RunLayout.SubjectFile));
+ File.AppendAllText(Path.Combine(run.Run, "work", "run", "eplusout.eso"), "\n");
+
+ int exit = S11Support.Cli(out string output, "compare", "--run", run.Run, "--model", model);
+
+ Assert.That(exit, Is.EqualTo(ReadBackExit.Infrastructure), output);
+ Assert.That(output, Does.Contain("eplusout.eso is not the file the read step saw"));
+ }
+
+ // ---- F-S11-1 / F-S11-2 integrated: positive regressions on the signed-off fixtures ------------------------------
+
+ ///
+ /// F-S11-1 (SAM_OpenStudio#35): the signed-off s08-fix-two spaces FixA and FixB share the GUID prefix 5080c000.
+ /// Through the production import (SAM-P producer objects, SAM-I AddResults) and B1a each space keeps its own
+ /// zone-specific fractional unmet hours, and the native and production identities stay distinct and agree. Before
+ /// #35 one zone's not-met time overwrote the other's and these rows FAILed; a swap between the zones FAILs too.
+ ///
+ [Test]
+ public void SignedOffTwoZone_SharedGuidPrefix_EachSpaceKeepsItsOwnResults()
+ {
+ string model = S11Support.Two();
+ S11Case run = S11Support.Evaluate(SyntheticRun.For(model, "two", fractionalUnmet: true), model, "two-shared-prefix");
+
+ Guid a = Guid.Parse(S11Support.SpaceAGuid);
+ Guid b = Guid.Parse(S11Support.SpaceBGuid);
+ Assert.That(a.ToString("N").Substring(0, 8), Is.EqualTo(b.ToString("N").Substring(0, 8)), "the signed-off fixture's shared prefix");
+ Assert.That(run.Synthetic.Zones.Select(x => x.SpaceGuid), Is.EquivalentTo(new[] { a, b }));
+ Assert.That(run.Compare.Verdict, Is.EqualTo("PASS"), run.Summary());
+ Assert.That(run.Compare.Failed + run.Compare.Blocked, Is.Zero);
+
+ // Native identity: two distinct spaces, zones and Ideal Loads; each is the production key of its own SAM space.
+ Assert.That(run.Reference.Spaces.All(x => x.IdentityProblem == null), Is.True);
+ foreach (Func part in new Func[] { x => x.NativeSpace, x => x.Zone, x => x.IdealLoads })
+ {
+ Assert.That(run.Reference.Spaces.Select(part).Distinct(StringComparer.OrdinalIgnoreCase).Count(), Is.EqualTo(2));
+ }
+
+ foreach (ProductionKey key in run.Subject.ProductionKeys)
+ {
+ SpaceReference native = run.Reference.Space(key.SpaceGuid);
+ Assert.That(key.IdealLoads, Is.EqualTo(native.IdealLoads).IgnoreCase, key.SpaceName);
+ Assert.That(key.ThermalZone, Is.EqualTo(native.Zone).IgnoreCase, key.SpaceName);
+ }
+
+ List identity = run.Compare.Rows.Where(x => x.Layer == "identity" && (x.Quantity == "native" || x.Quantity == "idealLoadsKey" || x.Quantity == "zoneKey")).ToList();
+ Assert.That(identity, Has.Count.EqualTo(6));
+ Assert.That(identity.All(x => x.Status == "PASS"), Is.True, run.Summary());
+
+ // Zone-specific fractional unmet hours: exact in the independent reference, and each layer carries its own space's.
+ for (int z = 0; z < run.Synthetic.Zones.Count; z++)
+ {
+ Guid space = run.Synthetic.Zones[z].SpaceGuid;
+ double heating = z == 0 ? 14.5 : 7.25;
+ double cooling = z == 0 ? 14.75 : 5.25;
+ Assert.That(run.Reference.Space(space).Quantities[S11Names.UnmetHeating].Sum, Is.EqualTo(heating));
+ Assert.That(run.Reference.Space(space).Quantities[S11Names.UnmetCooling].Sum, Is.EqualTo(cooling));
+ foreach (string layer in new[] { "samP", "samI", "b1a" })
+ {
+ Assert.That(run.Row(layer + "|" + run.Space(z) + "|Heating|unmetHours").Subject, Is.EqualTo(heating), layer);
+ Assert.That(run.Row(layer + "|" + run.Space(z) + "|Cooling|unmetHours").Subject, Is.EqualTo(cooling), layer);
+ }
+ }
+
+ // SAM-I: every annual result is attached to the space it references; no zone or space was left unmatched.
+ List annual = run.Subject.SamI.Where(x => x.IsAnnual).ToList();
+ Assert.That(annual, Has.Count.EqualTo(4));
+ Assert.That(annual.All(x => x.AttachedTo.HasValue && Guid.Parse(x.Reference) == x.AttachedTo.Value), Is.True);
+ Assert.That(annual.Select(x => x.AttachedTo.Value).Distinct(), Is.EquivalentTo(new[] { a, b }));
+ Assert.That(run.Subject.SamIDiagnostics.Where(x => x.Contains("matched no SAM space") || x.Contains("No simulation result found")), Is.Empty);
+ }
+
+ ///
+ /// F-S11-2 (SAM#219): fractional EnergyPlus not-met time, 14.5 h heating and 14.75 h cooling (binary-exact), survives
+ /// the production SQL import, a SAM JSON save and reload (the producer's sam.json, and the imported model) and B1a,
+ /// and S11 PASSes. Before SAM#219 the integer metadata rounded half to even (14 and 15) and these rows FAILed.
+ ///
+ [Test]
+ public void FractionalUnmetHours_SurviveSqlImport_SamSaveReload_AndB1a()
+ {
+ string model = S11Support.Model(TestSupport.Box);
+ S11Case run = S11Support.Evaluate(SyntheticRun.For(model, "box", fractionalUnmet: true), model, "box-fractional-unmet");
+ Guid space = run.Synthetic.Zones.Single().SpaceGuid;
+
+ Assert.That(run.Compare.Verdict, Is.EqualTo("PASS"), run.Summary());
+ Assert.That(run.Reference.Space(space).Quantities[S11Names.UnmetHeating].Sum, Is.EqualTo(14.5));
+ Assert.That(run.Reference.Space(space).Quantities[S11Names.UnmetCooling].Sum, Is.EqualTo(14.75));
+ foreach (string layer in new[] { "samP", "samI", "b1a" })
+ {
+ foreach ((string loadType, double expected) in new[] { ("Heating", 14.5), ("Cooling", 14.75) })
+ {
+ ResultRow row = run.Row(layer + "|" + run.Space(0) + "|" + loadType + "|unmetHours");
+ Assert.That(row.Status, Is.EqualTo("PASS"), row.Id);
+ Assert.That(row.Reference, Is.EqualTo(expected), row.Id);
+ Assert.That(row.Subject, Is.EqualTo(expected), row.Id);
+ }
+ }
+
+ // SQL import (the producer's objects), then saved as sam.json and read back.
+ Assert.That(Unmet(S11Support.Annual(run.Inputs, space, LoadKind.Heating)), Is.EqualTo(14.5));
+ Assert.That(Unmet(S11Support.Annual(run.Inputs, space, LoadKind.Cooling)), Is.EqualTo(14.75));
+ OpenStudioBenchmarkInputs reloaded = OpenStudioBenchmarkInputs.Read(Path.Combine(run.Run, RunLayout.SamResultsFile));
+ Assert.That(Unmet(S11Support.Annual(reloaded, space, LoadKind.Heating)), Is.EqualTo(14.5));
+ Assert.That(Unmet(S11Support.Annual(reloaded, space, LoadKind.Cooling)), Is.EqualTo(14.75));
+
+ // B1a as written.
+ BenchmarkDocument b1a = BenchmarkSerializer.Deserialize(File.ReadAllText(Path.Combine(run.Run, RunLayout.B1aFile)));
+ BenchmarkSpaceResult b1aSpace = b1a.Spaces.Single(x => x.Guid == space.ToString("N"));
+ Assert.That(b1aSpace.Heating.UnmetHours.Value, Is.EqualTo(14.5));
+ Assert.That(b1aSpace.Cooling.UnmetHours.Value, Is.EqualTo(14.75));
+
+ // SAM-I: the SAM_UI/Grasshopper import into the model, saved as SAM JSON and reopened.
+ AdjacencyCluster cluster = new AdjacencyCluster(run.Model.AdjacencyCluster);
+ Analytical.OpenStudio.Modify.AddResults(cluster, Path.Combine(run.Run, "work", "run", "eplusout.sql"), out List _);
+ string saved = Path.Combine(run.Run, "imported-model.json");
+ Assert.That(Core.Convert.ToFile(new AnalyticalModel(run.Model, cluster), saved, Core.SAMFileType.Json), Is.True);
+ AnalyticalModel reopened = Core.Convert.ToSAM(saved).Single();
+ Space reopenedSpace = reopened.AdjacencyCluster.GetSpaces().Single(x => x.Guid == space);
+ List attached = reopened.AdjacencyCluster.GetResults(reopenedSpace);
+ Assert.That(Unmet(attached.Where(x => IsLoadType(x, LoadKind.Heating))), Is.EqualTo(14.5));
+ Assert.That(Unmet(attached.Where(x => IsLoadType(x, LoadKind.Cooling))), Is.EqualTo(14.75));
+ }
+
+ private static bool IsLoadType(SpaceSimulationResult result, LoadKind kind)
+ {
+ return result.TryGetValue(Analytical.SpaceSimulationResultParameter.LoadType, out string loadType) && loadType == kind.ToString();
+ }
+
+ /// The unmet hours of the single result, read as a double (the stored value, not a rounded one).
+ private static double Unmet(IEnumerable results)
+ {
+ SpaceSimulationResult result = results.Single();
+ Assert.That(result.TryGetValue(Analytical.SpaceSimulationResultParameter.UnmetHours, out double value), Is.True);
+ return value;
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11SourceScanTests.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11SourceScanTests.cs
new file mode 100644
index 0000000..bbb2fde
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11SourceScanTests.cs
@@ -0,0 +1,122 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using NUnit.Framework;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace SAM.Analytical.OpenStudio.ResultsReadBack.Tests
+{
+ ///
+ /// S11 reference independence (ported from SAM_Tas S10SourceScanTests): no file of the reference library (native
+ /// readers, reference, compare, report, classes) and not its project file may reach the code under test: the
+ /// OpenStudio runner, the importer and producer conversions, the production naming and unit helpers, AddResults, the
+ /// SAM.Analytical.OpenStudio / SAM.Core.OpenStudio namespaces or the OpenStudio SDK. Only the CLI's subject step calls
+ /// production code.
+ ///
+ [TestFixture]
+ public class S11SourceScanTests
+ {
+ public static readonly string[] Forbidden =
+ {
+ "OpenStudioSimulationRunner", "Convert.ToSAM", "ToSAM_", "OpenStudioName", "ConvertUnit", "JoulesToKilowattHours",
+ "JoulesPerIntervalToWatts", "AddResults", "Core.OpenStudio", "OpenStudioSimulationResultSet", "OpenStudioBenchmarkInputs",
+ "ToBenchmark", "BenchmarkInputs", "using OpenStudio", "OpenStudio.Model", "SqlFile",
+ };
+
+ /// The production namespace, but not this library's own or the S09 reader's.
+ private static readonly Regex ProductionNamespace = new Regex(@"\bSAM\.Analytical\.OpenStudio(?!\.(ResultsReadBack|InputReadBack)\b)(\.|;|\s)", RegexOptions.CultureInvariant);
+
+ private static string LibraryDirectory()
+ {
+ DirectoryInfo directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
+ while (directory != null)
+ {
+ string candidate = Path.Combine(directory.FullName, "benchmark", "SAM.Analytical.OpenStudio.ResultsReadBack");
+ if (Directory.Exists(candidate)) return candidate;
+ candidate = Path.Combine(directory.FullName, "SAM.Analytical.OpenStudio.ResultsReadBack");
+ if (File.Exists(Path.Combine(candidate, "SAM.Analytical.OpenStudio.ResultsReadBack.csproj"))) return candidate;
+ directory = directory.Parent;
+ }
+
+ throw new DirectoryNotFoundException("SAM.Analytical.OpenStudio.ResultsReadBack not found above " + TestContext.CurrentContext.TestDirectory);
+ }
+
+ /// Every forbidden token found in the code lines of (comment lines are not code).
+ public static List Scan(string source)
+ {
+ List result = new List();
+ string[] lines = source.Split('\n');
+ for (int i = 0; i < lines.Length; i++)
+ {
+ string line = lines[i].Trim();
+ if (line.StartsWith("//", StringComparison.Ordinal)) continue;
+ foreach (string token in Forbidden.Where(x => line.Contains(x)))
+ {
+ result.Add("line " + (i + 1) + ": " + token);
+ }
+
+ if (ProductionNamespace.IsMatch(line))
+ {
+ result.Add("line " + (i + 1) + ": SAM.Analytical.OpenStudio namespace");
+ }
+ }
+
+ return result;
+ }
+
+ private static IEnumerable ReferenceFiles()
+ {
+ return Directory.GetFiles(LibraryDirectory(), "*.cs", SearchOption.AllDirectories).Where(x => !x.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar) && !x.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar));
+ }
+
+ [Test]
+ public void ReferenceFiles_CallNothingOfTheCodeUnderTest()
+ {
+ List files = ReferenceFiles().ToList();
+ Assert.That(files.Select(Path.GetFileName), Is.SupersetOf(new[] { "SqlResultRead.cs", "EsoResultRead.cs", "RunCalendar.cs", "ResultReference.cs", "ResultCompare.cs", "ResultReport.cs" }));
+ foreach (string file in files)
+ {
+ Assert.That(Scan(File.ReadAllText(file)), Is.Empty, file);
+ }
+ }
+
+ [Test]
+ public void ReferenceProject_ReferencesNoProductionProjectAndNoSdk()
+ {
+ System.Xml.Linq.XDocument project = System.Xml.Linq.XDocument.Load(Path.Combine(LibraryDirectory(), "SAM.Analytical.OpenStudio.ResultsReadBack.csproj"));
+ List includes = project.Descendants().Where(x => x.Name.LocalName == "ProjectReference" || x.Name.LocalName == "PackageReference" || x.Name.LocalName == "Reference").Select(x => (string)x.Attribute("Include")).ToList();
+ Assert.That(includes, Is.EquivalentTo(new[]
+ {
+ "System.Data.SQLite.Core",
+ @"..\SAM.Analytical.OpenStudio.InputReadBack\SAM.Analytical.OpenStudio.InputReadBack.csproj",
+ "SAM.Analytical.Benchmark", "SAM.Core", "SAM.Geometry", "SAM.Analytical", "SAM.Architectural", "SAM.Weather",
+ }), "the reference library references only SQLite, the S09 reader and SAM core/schema assemblies");
+ }
+
+ [Test]
+ public void ReferenceAssembly_LoadsNoProductionAssembly()
+ {
+ string[] references = typeof(ResultReference).Assembly.GetReferencedAssemblies().Select(x => x.Name).ToArray();
+ Assert.That(references, Has.None.EqualTo("SAM.Analytical.OpenStudio"));
+ Assert.That(references, Has.None.EqualTo("SAM.Core.OpenStudio"));
+ Assert.That(references, Has.None.EqualTo("OpenStudio"));
+ Assert.That(references, Has.None.EqualTo("benchmark-openstudio"));
+ }
+
+ [Test]
+ public void Scanner_FindsAPlantedForbiddenCall()
+ {
+ string path = ReferenceFiles().Single(x => Path.GetFileName(x) == "ResultReference.cs");
+ string planted = File.ReadAllText(path).Replace("public static ReferenceDocument Build(", "public static object Cheat(string p) => SAM.Analytical.OpenStudio.Modify.AddResults(null, p, out _);\n public static ReferenceDocument Build(");
+ List found = Scan(planted);
+ Assert.That(found, Has.Some.Contains("AddResults"));
+ Assert.That(found, Has.Some.Contains("namespace"));
+ Assert.That(Scan("string key = Core.OpenStudio.Query.OpenStudioName(\"IdealLoads\", name, guid);"), Has.Count.EqualTo(2));
+ Assert.That(Scan("// a comment may name OpenStudioSimulationRunner"), Is.Empty);
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11Support.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11Support.cs
new file mode 100644
index 0000000..80b0870
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11Support.cs
@@ -0,0 +1,161 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using NUnit.Framework;
+using SAM.Analytical.Benchmark;
+using SAM.Analytical.OpenStudio.Benchmark;
+using SAM.Analytical.OpenStudio.ResultsReadBack;
+using SAM.Analytical.OpenStudio.ResultsReadBack.Cli;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Tests
+{
+ /// One prepared synthetic S11 run: its directory, model and the objects the steps produced.
+ internal sealed class S11Case
+ {
+ public string Run;
+ public string ModelPath;
+ public AnalyticalModel Model;
+ public SyntheticRun Synthetic;
+ public OpenStudioBenchmarkInputs Inputs;
+ public ReferenceDocument Reference;
+ public SubjectDocument Subject;
+ public CompareDocument Compare;
+
+ public ResultRow Row(string id) => Compare.Rows.SingleOrDefault(x => x.Id == id) ?? throw new AssertionException("no row " + id + "; rows: " + string.Join(", ", Compare.Rows.Select(x => x.Id).Take(80)));
+
+ public string Space(int index) => "space:" + Synthetic.Zones[index].SpaceGuid.ToString("N");
+
+ public string Summary() => Compare.Verdict + ": " + string.Join(" | ", Compare.Rows.Where(x => x.Status != "PASS" && x.Status != "INFO").Take(12).Select(x => x.Id + " " + x.Status + " " + x.Reason));
+ }
+
+ internal static class S11Support
+ {
+ public static string Model(string fixtureId) => TestSupport.Fixture(fixtureId);
+
+ /// Space A's (FixA) GUID in s08-fix-two.
+ public const string SpaceAGuid = "5080c000-0000-0000-0000-00000000c0a1";
+
+ /// Space B's (FixB) GUID in s08-fix-two; it shares its first 8 hex digits (5080c000) with space A.
+ public const string SpaceBGuid = "5080c000-0000-0000-0000-00000000c0b1";
+
+ ///
+ /// The signed-off s08-fix-two fixture, unchanged: FixA and FixB share the GUID prefix 5080c000. The importer
+ /// associates their zone results by name identity proven unique by the SQL Zones inventory (F-S11-1, #35), so no
+ /// re-GUIDed variant is needed.
+ ///
+ public static string Two() => Model(TestSupport.Two);
+
+ /// A text variant of a signed-off fixture, written once to a stable path (so its export is cached).
+ public static string Variant(string fixtureId, string name, params (string From, string To)[] replacements)
+ {
+ string text = File.ReadAllText(TestSupport.Fixture(fixtureId));
+ foreach ((string from, string to) in replacements)
+ {
+ Assert.That(text, Does.Contain(from), name + ": the fixture must contain " + from);
+ text = text.Replace(from, to);
+ }
+
+ string path = Path.Combine(TestContext.CurrentContext.WorkDirectory, "s11-models", name + ".json");
+ Directory.CreateDirectory(Path.GetDirectoryName(path));
+ if (!File.Exists(path) || File.ReadAllText(path) != text)
+ {
+ File.WriteAllText(path, text);
+ }
+
+ return path;
+ }
+
+ ///
+ /// Writes the synthetic files, produces b1a.json and sam.json through the production extraction and the benchmark
+ /// producer, then runs read, subject and compare IN PROCESS (the separate-process path has its own tests).
+ /// may edit the run (b1a.json, sam.json) before the reference and subject read it;
+ /// may edit the subject, as a mutated subject would be.
+ ///
+ public static S11Case Evaluate(SyntheticRun synthetic, string modelPath, string label, DesignDaySource designDaySource = DesignDaySource.None, Action mutateInputs = null, Action afterProduce = null, Action beforeCompare = null)
+ {
+ S11Case result = Prepare(synthetic, modelPath, label, designDaySource, mutateInputs);
+ afterProduce?.Invoke(result);
+ return Judge(result, beforeCompare);
+ }
+
+ public static S11Case Prepare(SyntheticRun synthetic, string modelPath, string label, DesignDaySource designDaySource = DesignDaySource.None, Action mutateInputs = null)
+ {
+ S11Case result = new S11Case { Run = NewRun(label), ModelPath = modelPath, Model = Core.Convert.ToSAM(modelPath).Single(), Synthetic = synthetic };
+ synthetic.Write(result.Run);
+ result.Inputs = SyntheticProducer.Produce(result.Model, result.Run, designDaySource, mutateInputs);
+ return result;
+ }
+
+ public static S11Case Judge(S11Case result, Action beforeCompare = null)
+ {
+ RunLayout layout = RunLayout.Resolve(result.Run, out List problems);
+ Assert.That(problems, Is.Empty);
+ result.Reference = ResultReference.Build(result.Model, result.ModelPath, layout.Idf, layout.Sql, layout.Eso, "s11-test");
+ result.Subject = SubjectStep.Capture(result.Model, result.ModelPath, layout);
+ beforeCompare?.Invoke(result);
+ BenchmarkDocument b1a = BenchmarkSerializer.Deserialize(File.ReadAllText(layout.B1a));
+ result.Compare = ResultCompare.Run(result.Model, result.Reference, result.Subject, b1a, SamResults.Read(layout.SamResults));
+ return result;
+ }
+
+ public static string NewRun(string label)
+ {
+ string path = Path.Combine(TestContext.CurrentContext.WorkDirectory, "s11-runs", label + "-" + Guid.NewGuid().ToString("N").Substring(0, 6));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ /// Runs os-results-readback in its own process; returns the exit code with its stdout and stderr in output.
+ public static int Cli(out string output, params string[] arguments)
+ {
+ string exe = Path.Combine(TestContext.CurrentContext.TestDirectory, "os-results-readback.exe");
+ Assert.That(File.Exists(exe), Is.True, exe);
+ ProcessStartInfo info = new ProcessStartInfo(exe)
+ {
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ WorkingDirectory = TestContext.CurrentContext.TestDirectory
+ };
+
+ foreach (string argument in arguments)
+ {
+ info.ArgumentList.Add(argument);
+ }
+
+ using (Process process = Process.Start(info))
+ {
+ System.Threading.Tasks.Task error = process.StandardError.ReadToEndAsync();
+ output = process.StandardOutput.ReadToEnd();
+ process.WaitForExit();
+ output = "[stdout]" + Environment.NewLine + output + "[stderr]" + Environment.NewLine + error.Result;
+ return process.ExitCode;
+ }
+ }
+
+ /// The reference SENSIBLE value of a space quantity, for substitution negatives.
+ public static QuantityReference Sensible(ReferenceDocument reference, Guid space, LoadKind kind)
+ {
+ return reference.Space(space).Quantities[S11Names.Sensible(kind)];
+ }
+
+ public static IEnumerable Annual(OpenStudioBenchmarkInputs inputs, Guid space, LoadKind kind)
+ {
+ return inputs.SpaceResults.Where(x => x.Reference == space.ToString("N") && x.TryGetValue(Analytical.SpaceSimulationResultParameter.LoadType, out string loadType) && loadType == kind.ToString());
+ }
+
+ /// Rewrites b1a.json through the schema types (a test-side edit of the produced document).
+ public static void EditB1a(S11Case run, Action edit)
+ {
+ string path = Path.Combine(run.Run, RunLayout.B1aFile);
+ BenchmarkDocument document = BenchmarkSerializer.Deserialize(File.ReadAllText(path));
+ edit(document);
+ BenchmarkSerializer.Write(path, document);
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11Synthetic.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11Synthetic.cs
new file mode 100644
index 0000000..f1f7053
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/S11Synthetic.cs
@@ -0,0 +1,531 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using NUnit.Framework;
+using SAM.Analytical.Benchmark;
+using SAM.Analytical.OpenStudio.Benchmark;
+using SAM.Analytical.OpenStudio.ResultsReadBack;
+using SAM.Analytical.Tas.DirectT3D.Validation.ReadBack;
+using System;
+using System.Collections.Generic;
+using System.Data.SQLite;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Tests
+{
+ /// One synthetic hourly value as a file will stamp it.
+ internal sealed class SyntheticPoint
+ {
+ public int Month;
+ public int Day;
+ public int Hour;
+ public double Value;
+ public string Environment;
+ public bool Warmup;
+ public int Dst;
+
+ public SyntheticPoint Copy() => (SyntheticPoint)MemberwiseClone();
+ }
+
+ /// One identified zone of the exported fixture IDF: its SAM space, native Zone and Ideal Loads names.
+ internal sealed class SyntheticZone
+ {
+ public Guid SpaceGuid;
+ public string Zone;
+ public string IdealLoads;
+ }
+
+ ///
+ /// A synthetic EnergyPlus run for S11 offline tests: the S09 export of a fixture (OpenStudio SDK translation only, no
+ /// EnergyPlus) gives the in.idf; deterministic hourly series keyed by that IDF's native names are written as a
+ /// real-schema eplusout.sql and an eplusout.eso. Each file holds its own copy of every series, so a test can break one
+ /// file, both, or one series. Values: heating in the cold months and cooling in summer, both with a latent part of at
+ /// least 10 % of Total, and a Total peak at a different hour from the Sensible peak (F3 discrimination).
+ ///
+ internal sealed class SyntheticRun
+ {
+ private static readonly int[] DaysInMonth = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
+ private static readonly CultureInfo ci = CultureInfo.InvariantCulture;
+ private static readonly Dictionary exports = new Dictionary(StringComparer.Ordinal);
+
+ public string Idf;
+ public string RunPeriodName;
+ public List Zones = new List();
+ public Dictionary> Sql = new Dictionary>(StringComparer.Ordinal);
+ public Dictionary> Eso = new Dictionary>(StringComparer.Ordinal);
+ public List> SqlEnvironments = new List>();
+ public List EsoEnvironments = new List();
+ public string SqlVersion = "EnergyPlus, Version 25.1.0-1c11a3d85f, YMD=2026.10.24 08:00";
+ public string EsoVersion = "Program Version,EnergyPlus, Version 25.1.0-1c11a3d85f, YMD=2026.10.24 08:00";
+ public bool Meters = true;
+ public List ZoneSizes = new List();
+
+ /// Extra SQL edits after the file is written (e.g. a second dictionary entry).
+ public Action SqlEdit;
+
+ /// Extra ESO text edits after the file is written (e.g. a repeated value line).
+ public Func, List> EsoEdit;
+
+ public static string Key(string variable, string key) => S11Names.SeriesKey(variable, key);
+
+ /// The exported in.idf of a model (cached per model file hash); the export runs os-input-readback in its own process.
+ public static string ExportIdf(string modelPath, string label)
+ {
+ string hash = Hashing.Sha256(modelPath);
+ lock (exports)
+ {
+ if (!exports.TryGetValue(hash, out string idf))
+ {
+ string run = TestSupport.NewDirectory("s11-export-" + label);
+ int exit = TestSupport.Cli(out string output, "export", "--model", modelPath, "--epw", TestSupport.Budapest(), "--out", run, "--label", label);
+ Assert.That(exit, Is.EqualTo(0), output);
+ idf = File.ReadAllText(Path.Combine(run, "in.idf"));
+ exports[hash] = idf;
+ }
+
+ return idf;
+ }
+ }
+
+ /// A run for the model at , every series complete and consistent in both files.
+ public static SyntheticRun For(string modelPath, string label, bool fractionalUnmet = false)
+ {
+ AnalyticalModel model = Core.Convert.ToSAM(modelPath).Single();
+ SyntheticRun result = new SyntheticRun { Idf = ExportIdf(modelPath, label) };
+ List idf = IdfText.Parse(result.Idf).Where(x => NativeReader.IdfTypes.Contains(x.Type, StringComparer.OrdinalIgnoreCase)).ToList();
+ result.RunPeriodName = idf.Single(x => x.Type == "RunPeriod").Name.ToUpperInvariant();
+ NativeModel native = new NativeModel(new NativeSnapshot { Idf = idf });
+ foreach (NativeZoneIdentityEntry entry in NativeZoneIdentity.Resolve(native, ReadBackExpected.Build(model, null, label)))
+ {
+ Assert.That(entry.Problem, Is.Null, "the exported fixture must have a clean identity");
+ result.Zones.Add(new SyntheticZone { SpaceGuid = entry.SpaceGuid, Zone = entry.Zone.ToUpperInvariant(), IdealLoads = entry.IdealLoads.ToUpperInvariant() });
+ }
+
+ result.SqlEnvironments.Add(new KeyValuePair(result.RunPeriodName, 3));
+ result.EsoEnvironments.Add(result.RunPeriodName);
+ for (int z = 0; z < result.Zones.Count; z++)
+ {
+ SyntheticZone zone = result.Zones[z];
+ foreach (string variable in S11Names.IdealLoadsVariables.Concat(S11Names.ZoneVariables))
+ {
+ string key = S11Names.ZoneVariables.Contains(variable) ? zone.Zone : zone.IdealLoads;
+ List points = new List();
+ int hourOfYear = 0;
+ for (int month = 1; month <= 12; month++)
+ {
+ for (int day = 1; day <= DaysInMonth[month - 1]; day++)
+ {
+ for (int hour = 1; hour <= 24; hour++)
+ {
+ points.Add(new SyntheticPoint { Month = month, Day = day, Hour = hour, Value = Value(variable, z, month, day, hour, hourOfYear, fractionalUnmet), Environment = result.RunPeriodName });
+ hourOfYear++;
+ }
+ }
+ }
+
+ result.Sql[Key(variable, key)] = points;
+ result.Eso[Key(variable, key)] = points.Select(x => x.Copy()).ToList();
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Deterministic, non-round values [J per hour or hr]. Cooling: May-September, EnergyPlus hours 10-19 (1530 hours).
+ /// Sensible cooling peaks on 15 July h15; Total peaks on 10 August h14 where the latent is large. Heating likewise in
+ /// November-March with a latent (humidification) part. Zone index shifts the magnitudes. Not-met time is whole hours
+ /// (29 heating, 20 cooling, equal in every zone) unless : EnergyPlus reports
+ /// timestep fractions, here binary-exact quarter hours that differ per zone so the annual sums are exact and a swap
+ /// between zones is visible. Zone 0: heating 29 x 0.5 = 14.5 h, cooling 19 x 0.75 + 0.5 = 14.75 h; every other
+ /// zone: heating 29 x 0.25 = 7.25 h, cooling 19 x 0.25 + 0.5 = 5.25 h (0.5 on 28 September).
+ ///
+ public static double Value(string variable, int zone, int month, int day, int hour, int hourOfYear, bool fractionalUnmet = false)
+ {
+ double scale = 1 + 0.37 * zone;
+ bool cooling = month >= 5 && month <= 9 && hour >= 10 && hour <= 19;
+ bool heating = (month >= 11 || month <= 3) && (hour <= 9 || hour >= 18);
+ double wave = 0.5 + 0.5 * Math.Sin(hourOfYear * 0.0137 + zone);
+ double sensibleCooling = cooling ? scale * (1_000_000.123 + 400_000.0 * wave) : 0;
+ double sensibleHeating = heating ? scale * (1_500_000.321 + 500_000.0 * wave) : 0;
+ if (month == 7 && day == 15 && hour == 15) sensibleCooling = scale * 2_000_000.5;
+ if (month == 1 && day == 20 && hour == 6) sensibleHeating = scale * 2_600_000.25;
+ double latentCooling = sensibleCooling * 0.15;
+ double latentHeating = sensibleHeating * 0.12;
+ if (month == 8 && day == 10 && hour == 14) { sensibleCooling = scale * 1_950_000.75; latentCooling = scale * 600_000.0; }
+ if (month == 2 && day == 3 && hour == 7) { sensibleHeating = scale * 2_550_000.5; latentHeating = scale * 700_000.0; }
+
+ switch (variable)
+ {
+ case S11Names.SensibleCooling: return sensibleCooling;
+ case S11Names.TotalCooling: return sensibleCooling + latentCooling;
+ case S11Names.SensibleHeating: return sensibleHeating;
+ case S11Names.TotalHeating: return sensibleHeating + latentHeating;
+ case S11Names.UnmetHeating: return heating && hour == 7 && day % 5 == 0 ? (fractionalUnmet ? (zone == 0 ? 0.5 : 0.25) : 1.0) : 0;
+ case S11Names.UnmetCooling: return cooling && hour == 16 && day % 7 == 0 ? (fractionalUnmet ? (month == 9 && day == 28 ? 0.5 : zone == 0 ? 0.75 : 0.25) : 1.0) : 0;
+ default: throw new ArgumentException(variable);
+ }
+ }
+
+ /// Applies to every series of the chosen file(s).
+ public void EditSeries(bool sql, bool eso, Action> edit, Func which = null)
+ {
+ foreach (Dictionary> file in new[] { sql ? Sql : null, eso ? Eso : null }.Where(x => x != null))
+ {
+ foreach (KeyValuePair> series in file.Where(x => which == null || which(x.Key)).ToList())
+ {
+ edit(series.Value);
+ }
+ }
+ }
+
+ // ---- writing ---------------------------------------------------------------------------------------------------
+
+ /// Writes run/{in.idf, eplusout.sql, eplusout.eso} below .
+ public void Write(string runDirectory)
+ {
+ string folder = Path.Combine(runDirectory, "work", "run");
+ Directory.CreateDirectory(folder);
+ File.WriteAllText(Path.Combine(folder, "in.idf"), Idf);
+ WriteSql(Path.Combine(folder, "eplusout.sql"));
+ WriteEso(Path.Combine(folder, "eplusout.eso"));
+ }
+
+ private void WriteSql(string path)
+ {
+ using (SQLiteConnection connection = new SQLiteConnection("Data Source=" + path))
+ {
+ connection.Open();
+ using (SQLiteTransaction transaction = connection.BeginTransaction())
+ using (SQLiteCommand command = connection.CreateCommand())
+ {
+ void Exec(string sql)
+ {
+ command.CommandText = sql;
+ command.ExecuteNonQuery();
+ }
+
+ Exec("CREATE TABLE Simulations (SimulationIndex INTEGER PRIMARY KEY, EnergyPlusVersion TEXT, TimeStamp TEXT, NumTimestepsPerHour INTEGER, Completed BOOL, CompletedSuccessfully BOOL)");
+ Exec("CREATE TABLE EnvironmentPeriods (EnvironmentPeriodIndex INTEGER PRIMARY KEY, SimulationIndex INTEGER, EnvironmentName TEXT, EnvironmentType INTEGER)");
+ Exec("CREATE TABLE Time (TimeIndex INTEGER PRIMARY KEY, Year INTEGER, Month INTEGER, Day INTEGER, Hour INTEGER, Minute INTEGER, Dst INTEGER, Interval INTEGER, IntervalType INTEGER, SimulationDays INTEGER, DayType TEXT, EnvironmentPeriodIndex INTEGER, WarmupFlag INTEGER)");
+ Exec("CREATE TABLE ReportDataDictionary (ReportDataDictionaryIndex INTEGER PRIMARY KEY, IsMeter INTEGER, Type TEXT, IndexGroup TEXT, TimestepType TEXT, KeyValue TEXT, Name TEXT, ReportingFrequency TEXT, ScheduleName TEXT, Units TEXT)");
+ Exec("CREATE TABLE ReportData (ReportDataIndex INTEGER PRIMARY KEY, TimeIndex INTEGER, ReportDataDictionaryIndex INTEGER, Value REAL)");
+ Exec("CREATE TABLE Zones (ZoneIndex INTEGER PRIMARY KEY, ZoneName TEXT, Volume REAL, FloorArea REAL)");
+ Exec("CREATE TABLE Surfaces (SurfaceIndex INTEGER PRIMARY KEY, SurfaceName TEXT, ConstructionIndex INTEGER, ClassName TEXT, Area REAL, GrossArea REAL, Perimeter REAL, Azimuth REAL, Height REAL, Reveal REAL, Shape INTEGER, Sides INTEGER, Tilt REAL, Width REAL, HeatTransferSurf INTEGER, BaseSurfaceIndex INTEGER, ZoneIndex INTEGER, ExtBoundCond INTEGER, ExtSolar INTEGER, ExtWind INTEGER)");
+ Exec("CREATE TABLE ZoneSizes (ZoneSizesIndex INTEGER PRIMARY KEY, ZoneName TEXT, LoadType TEXT, CalcDesLoad REAL, UserDesLoad REAL, CalcDesFlow REAL, UserDesFlow REAL, DesDayName TEXT, PeakHrMin TEXT, PeakTemp REAL, PeakHumRat REAL, CalcOutsideAirFlow REAL, DOASHeatAddRate REAL)");
+ Exec("CREATE TABLE NominalLighting (NominalLightingIndex INTEGER PRIMARY KEY, ObjectName TEXT, ZoneIndex INTEGER, ScheduleIndex INTEGER, DesignLevel REAL)");
+ Exec("CREATE TABLE NominalInfiltration (NominalInfiltrationIndex INTEGER PRIMARY KEY, ObjectName TEXT, ZoneIndex INTEGER, ScheduleIndex INTEGER, DesignLevel REAL)");
+ Exec("CREATE TABLE NominalElectricEquipment (NominalElectricEquipmentIndex INTEGER PRIMARY KEY, ObjectName TEXT, ZoneIndex INTEGER, ScheduleIndex INTEGER, DesignLevel REAL)");
+
+ Parameter(command, "INSERT INTO Simulations VALUES (1, @v, 'YMD=2026.10.24 08:00', 6, 1, 1)", "@v", SqlVersion);
+ Dictionary environments = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (int i = 0; i < SqlEnvironments.Count; i++)
+ {
+ environments[SqlEnvironments[i].Key] = i + 1;
+ Parameter(command, "INSERT INTO EnvironmentPeriods VALUES (" + (i + 1) + ", 1, @n, " + SqlEnvironments[i].Value + ")", "@n", SqlEnvironments[i].Key);
+ }
+
+ for (int i = 0; i < Zones.Count; i++)
+ {
+ Parameter(command, "INSERT INTO Zones VALUES (" + (i + 1) + ", @n, 60, 20)", "@n", Zones[i].Zone);
+ }
+
+ foreach (ZoneSizeRow row in ZoneSizes)
+ {
+ Parameter(command, "INSERT INTO ZoneSizes (ZoneName, LoadType, CalcDesLoad, UserDesLoad, DesDayName, PeakHrMin) VALUES (@n, '" + row.LoadType + "', " + row.CalcDesLoad.ToString("R", ci) + ", " + (row.CalcDesLoad * 1.25).ToString("R", ci) + ", 'DESIGN DAY', ' 1/21 06:00')", "@n", row.ZoneName);
+ }
+
+ // Time rows: one per distinct stamp and occurrence (a repeated stamp gets its own Time row, as a merged
+ // environment would), in calendar order per environment.
+ Dictionary times = new Dictionary(StringComparer.Ordinal);
+ long timeIndex = 0;
+ long Time(SyntheticPoint point, int occurrence)
+ {
+ string stamp = string.Join("|", point.Environment, point.Month, point.Day, point.Hour, point.Warmup, point.Dst, occurrence);
+ if (!times.TryGetValue(stamp, out long index))
+ {
+ index = ++timeIndex;
+ times[stamp] = index;
+ int environment = environments.TryGetValue(point.Environment, out int e) ? e : 99;
+ Exec(string.Format(ci, "INSERT INTO Time VALUES ({0}, 2006, {1}, {2}, {3}, 0, {4}, 60, 1, 1, 'Monday', {5}, {6})", index, point.Month, point.Day, point.Hour, point.Dst, environment, point.Warmup ? 1 : 0));
+ }
+
+ return index;
+ }
+
+ long dictionary = 0;
+ long data = 0;
+ Dictionary meterTotals = new Dictionary(StringComparer.Ordinal);
+ foreach (KeyValuePair> series in Sql)
+ {
+ string[] parts = series.Key.Split('|');
+ bool zoneVariable = S11Names.ZoneVariables.Contains(parts[0]);
+ dictionary++;
+ Parameters(command, "INSERT INTO ReportDataDictionary VALUES (" + dictionary + ", 0, 'Sum', @g, @t, @k, @n, 'Hourly', '', @u)", ("@g", zoneVariable ? "Zone" : "System"), ("@t", zoneVariable ? "Zone" : "HVAC System"), ("@k", parts[1]), ("@n", parts[0]), ("@u", S11Names.UnitOf(parts[0])));
+ Dictionary occurrences = new Dictionary(StringComparer.Ordinal);
+ foreach (SyntheticPoint point in series.Value)
+ {
+ string stamp = string.Join("|", point.Environment, point.Month, point.Day, point.Hour, point.Warmup, point.Dst);
+ occurrences.TryGetValue(stamp, out int occurrence);
+ occurrences[stamp] = occurrence + 1;
+ long time = Time(point, occurrence);
+ data++;
+ Exec(string.Format(ci, "INSERT INTO ReportData VALUES ({0}, {1}, {2}, {3})", data, time, dictionary, point.Value.ToString("R", ci)));
+ }
+ }
+
+ if (Meters)
+ {
+ foreach (LoadKind kind in new[] { LoadKind.Heating, LoadKind.Cooling })
+ {
+ dictionary++;
+ Parameters(command, "INSERT INTO ReportDataDictionary VALUES (" + dictionary + ", 1, 'Sum', 'Facility', 'Zone', '', @n, 'Hourly', '', 'J')", ("@n", S11Names.Meter(kind)));
+ foreach (SyntheticPoint point in MeterSeries(kind))
+ {
+ data++;
+ Exec(string.Format(ci, "INSERT INTO ReportData VALUES ({0}, {1}, {2}, {3})", data, Time(point, 0), dictionary, point.Value.ToString("R", ci)));
+ }
+ }
+ }
+
+ transaction.Commit();
+ }
+
+ SqlEdit?.Invoke(connection);
+ }
+
+ SQLiteConnection.ClearAllPools();
+ }
+
+ ///
+ /// A facility meter: per stamp, the sum of the zones' Total energy in the SQL series of the RunPeriod (as EnergyPlus
+ /// meters the Ideal Loads), so a test that edits the series edits the meter with them.
+ ///
+ private List MeterSeries(LoadKind kind)
+ {
+ Dictionary sums = new Dictionary(StringComparer.Ordinal);
+ List result = new List();
+ foreach (SyntheticZone zone in Zones)
+ {
+ if (!Sql.TryGetValue(Key(S11Names.Total(kind), zone.IdealLoads), out List series))
+ {
+ continue;
+ }
+
+ HashSet seen = new HashSet(StringComparer.Ordinal);
+ foreach (SyntheticPoint point in series.Where(x => x.Environment == RunPeriodName && !x.Warmup))
+ {
+ string stamp = point.Month + "|" + point.Day + "|" + point.Hour;
+ if (!seen.Add(stamp))
+ {
+ continue;
+ }
+
+ if (!sums.TryGetValue(stamp, out SyntheticPoint sum))
+ {
+ sum = new SyntheticPoint { Month = point.Month, Day = point.Day, Hour = point.Hour, Environment = RunPeriodName };
+ sums[stamp] = sum;
+ result.Add(sum);
+ }
+
+ sum.Value += point.Value;
+ }
+ }
+
+ return result;
+ }
+
+ private void WriteEso(string path)
+ {
+ List lines = new List { EsoVersion };
+ lines.Add("1,5,Environment Title[],Latitude[deg],Longitude[deg],Time Zone[],Elevation[m]");
+ lines.Add("2,8,Day of Simulation[],Month[],Day of Month[],DST Indicator[1=yes 0=no],Hour[],StartMinute[],EndMinute[],DayType");
+ lines.Add("3,5,Cumulative Day of Simulation[],Month[],Day of Month[],DST Indicator[1=yes 0=no],DayType ! When Daily Report Variables Requested");
+ lines.Add("4,2,Cumulative Days of Simulation[],Month[] ! When Monthly Report Variables Requested");
+ lines.Add("5,1,Cumulative Days of Simulation[] ! When Run Period Report Variables Requested");
+ lines.Add("6,1,Calendar Year of Simulation[] ! When Annual Report Variables Requested");
+ Dictionary ids = new Dictionary(StringComparer.Ordinal);
+ int id = 7;
+ foreach (string series in Eso.Keys)
+ {
+ string[] parts = series.Split('|');
+ ids[series] = id;
+ lines.Add(id + ",1," + parts[1] + "," + parts[0] + " [" + S11Names.UnitOf(parts[0]) + "] !Hourly");
+ id++;
+ }
+
+ Dictionary meterIds = new Dictionary(StringComparer.Ordinal);
+ if (Meters)
+ {
+ foreach (LoadKind kind in new[] { LoadKind.Heating, LoadKind.Cooling })
+ {
+ meterIds[S11Names.Meter(kind)] = id;
+ lines.Add(id + ",1," + S11Names.Meter(kind) + " [J] !Hourly");
+ id++;
+ }
+ }
+
+ lines.Add("End of Data Dictionary");
+ foreach (string environment in EsoEnvironments)
+ {
+ lines.Add("1," + environment + ", 47.52, 19.03, 1.00, 129.00");
+
+ // Stamps in first-appearance order over all series of this environment, each occurrence its own block.
+ List order = new List();
+ Dictionary>> blocks = new Dictionary>>(StringComparer.Ordinal);
+ foreach (KeyValuePair> series in Eso)
+ {
+ Dictionary occurrences = new Dictionary(StringComparer.Ordinal);
+ foreach (SyntheticPoint point in series.Value.Where(x => string.Equals(x.Environment, environment, StringComparison.OrdinalIgnoreCase)))
+ {
+ string stamp = string.Join("|", point.Month, point.Day, point.Hour, point.Dst);
+ occurrences.TryGetValue(stamp, out int occurrence);
+ occurrences[stamp] = occurrence + 1;
+ string block = stamp + "|" + occurrence;
+ if (!blocks.TryGetValue(block, out List> list))
+ {
+ list = new List>();
+ blocks[block] = list;
+ order.Add(block);
+ }
+
+ list.Add(new KeyValuePair(ids[series.Key], point));
+ }
+ }
+
+ Dictionary meters = new Dictionary(StringComparer.Ordinal);
+ if (Meters && environment == RunPeriodName)
+ {
+ foreach (LoadKind kind in new[] { LoadKind.Heating, LoadKind.Cooling })
+ {
+ foreach (SyntheticPoint point in MeterSeries(kind))
+ {
+ meters[S11Names.Meter(kind) + "|" + point.Month + "|" + point.Day + "|" + point.Hour] = point.Value;
+ }
+ }
+ }
+
+ int dayOfSimulation = 0;
+ string lastDay = null;
+ foreach (string block in order)
+ {
+ SyntheticPoint first = blocks[block][0].Value;
+ string dayKey = first.Month + "/" + first.Day;
+ if (dayKey != lastDay)
+ {
+ dayOfSimulation++;
+ lastDay = dayKey;
+ }
+
+ lines.Add(string.Format(ci, "2,{0},{1,2},{2,2},{3,2},{4,2}, 0.00,60.00,Monday", dayOfSimulation, first.Month, first.Day, first.Dst, first.Hour));
+ foreach (KeyValuePair value in blocks[block])
+ {
+ lines.Add(value.Key.ToString(ci) + "," + value.Value.Value.ToString("R", ci));
+ }
+
+ if (block.EndsWith("|0", StringComparison.Ordinal))
+ {
+ foreach (KeyValuePair meter in meterIds)
+ {
+ if (meters.TryGetValue(meter.Key + "|" + first.Month + "|" + first.Day + "|" + first.Hour, out double value))
+ {
+ lines.Add(meter.Value.ToString(ci) + "," + value.ToString("R", ci));
+ }
+ }
+ }
+ }
+ }
+
+ lines.Add("End of Data");
+ lines.Add(" Number of Records Written= " + lines.Count);
+ if (EsoEdit != null)
+ {
+ lines = EsoEdit(lines);
+ }
+
+ File.WriteAllText(path, string.Join("\n", lines) + "\n", new UTF8Encoding(false));
+ }
+
+ private static void Parameter(SQLiteCommand command, string sql, string name, object value)
+ {
+ Parameters(command, sql, (name, value));
+ }
+
+ private static void Parameters(SQLiteCommand command, string sql, params (string Name, object Value)[] values)
+ {
+ command.CommandText = sql;
+ command.Parameters.Clear();
+ foreach ((string name, object value) in values)
+ {
+ command.Parameters.AddWithValue(name, value);
+ }
+
+ command.ExecuteNonQuery();
+ command.Parameters.Clear();
+ }
+ }
+
+ ///
+ /// The producer side of a synthetic run, offline: the production C5 extraction of the SQL (OpenStudioSimulationRunner,
+ /// reached by reflection because it is internal; the route's own Program would call it after the simulation), then the
+ /// benchmark producer's BenchmarkInputs/ToBenchmark, writing b1a.json and sam.json exactly as benchmark-openstudio does.
+ ///
+ internal static class SyntheticProducer
+ {
+ public static OpenStudioSimulationResultSet ResultSet(string sqlPath)
+ {
+ Type runner = typeof(OpenStudioSimulationResultSet).Assembly.GetType("SAM.Analytical.OpenStudio.OpenStudioSimulationRunner", true);
+ MethodInfo extractLoads = runner.GetMethod("ExtractLoads", BindingFlags.NonPublic | BindingFlags.Static);
+ MethodInfo extractResultSet = runner.GetMethod("ExtractResultSet", BindingFlags.NonPublic | BindingFlags.Static);
+ object[] arguments = { sqlPath, null };
+ object summary = extractLoads.Invoke(null, arguments);
+ Assert.That(summary, Is.Not.Null, "production ExtractLoads failed: " + arguments[1]);
+ return (OpenStudioSimulationResultSet)extractResultSet.Invoke(null, new object[] { sqlPath, summary, false, 12.5, 0, 0, 0 });
+ }
+
+ public static OpenStudioBenchmarkContext Context(AnalyticalModel model, OpenStudioSimulationResultSet resultSet, DesignDaySource designDaySource = DesignDaySource.None)
+ {
+ OpenStudioBenchmarkContext context = new OpenStudioBenchmarkContext
+ {
+ SourceModelName = model.Name ?? "model",
+ SourceModelGuid = model.Guid.ToString("N"),
+ SourceFileHash = BenchmarkHash.ComputeSha256(new byte[] { 1, 2, 3 }),
+ CanonicalModelHash = BenchmarkCanonicalJson.ComputeSha256("{\"s11\":\"synthetic\"}"),
+ CanonicalizationVersion = BenchmarkCanonicalization.CurrentVersion,
+ SamCommit = "0123456789abcdef",
+ RunnerCommit = "fedcba9876543210",
+ EngineName = "EnergyPlus",
+ EngineVersion = "25.1.0",
+ SdkVersion = "3.10.0",
+ WeatherIdentity = "budapest",
+ WeatherHash = BenchmarkHash.ComputeSha256(new byte[] { 4, 5, 6 }),
+ DesignDaySource = designDaySource,
+ RunTimestampUtc = new DateTimeOffset(2026, 10, 24, 8, 0, 0, TimeSpan.Zero),
+ DurationSeconds = 12.5,
+ State = RunState.Success,
+ ResultSet = resultSet,
+ };
+
+ context.SpaceDesignLoadResults = resultSet.ToSAM_SpaceDesignLoadResults(model.AdjacencyCluster.GetSpaces(), out List _);
+ return context;
+ }
+
+ /// Writes b1a.json and sam.json for the run in ; returns the producer objects.
+ public static OpenStudioBenchmarkInputs Produce(AnalyticalModel model, string runDirectory, DesignDaySource designDaySource = DesignDaySource.None, Action mutate = null)
+ {
+ OpenStudioSimulationResultSet resultSet = ResultSet(Path.Combine(runDirectory, "work", "run", "eplusout.sql"));
+ OpenStudioBenchmarkContext context = Context(model, resultSet, designDaySource);
+ OpenStudioBenchmarkInputs inputs = model.BenchmarkInputs(context);
+ mutate?.Invoke(inputs);
+ BenchmarkDocument document = model.ToBenchmark(context, inputs);
+ Assert.That(BenchmarkValidator.Validate(document).IsValid, Is.True, "the produced B1a must validate");
+ BenchmarkSerializer.Write(Path.Combine(runDirectory, RunLayout.B1aFile), document);
+ inputs.Write(Path.Combine(runDirectory, RunLayout.SamResultsFile));
+ return inputs;
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/SAM.Analytical.OpenStudio.InputReadBack.Tests.csproj b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/SAM.Analytical.OpenStudio.InputReadBack.Tests.csproj
index 9080740..96167b2 100644
--- a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/SAM.Analytical.OpenStudio.InputReadBack.Tests.csproj
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/SAM.Analytical.OpenStudio.InputReadBack.Tests.csproj
@@ -13,7 +13,7 @@
@@ -25,15 +25,27 @@
+
+
+
+
+
+
+
+
+
+
+ ..\..\..\SAM_Validation\build\SAM.Analytical.Benchmark.dll
+
..\..\..\SAM\build\SAM.Core.dll
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack/Compare/NativeZoneIdentity.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack/Compare/NativeZoneIdentity.cs
new file mode 100644
index 0000000..0e0645d
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack/Compare/NativeZoneIdentity.cs
@@ -0,0 +1,215 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using SAM.Analytical.InputEquivalence;
+using SAM.Analytical.Tas.DirectT3D.Validation.ReadBack;
+using SAM.Geometry.Spatial;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using EQ = SAM.Analytical.InputEquivalence.Query;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack
+{
+ /// One native surface of a space with its vertices in building coordinates, or the reason they are unreadable.
+ public sealed class NativeSurface
+ {
+ public IdfObject Object;
+ public List Vertices;
+ public string Problem;
+ }
+
+ ///
+ /// The native space whose surfaces are, one to one, a SAM space's panel polygons. is null when
+ /// none matches or when more than one does (); and then
+ /// still hold the first bijective candidate, exactly as the S09 comparison has always kept them.
+ ///
+ public sealed class NativeSpaceMatch
+ {
+ public string NativeSpace;
+ public GeometryMatchResult Match;
+ public List Surfaces;
+ public bool Ambiguous;
+ }
+
+ /// The native identity of one SAM space: its native Space, the Zone that holds it and the Zone's Ideal Loads.
+ public sealed class NativeZoneIdentityEntry
+ {
+ public Guid SpaceGuid { get; set; }
+ public string SpaceName { get; set; }
+ public string NativeSpace { get; set; }
+ public int SurfacePairs { get; set; }
+
+ /// The IDF Zone name: the report key of zone variables (setpoint not-met time).
+ public string Zone { get; set; }
+
+ /// The ZoneHVAC:IdealLoadsAirSystem name: the report key of the Ideal Loads variables.
+ public string IdealLoads { get; set; }
+
+ public int IdealLoadsCount { get; set; }
+
+ /// True when the writer's GUID suffix agrees with the geometry match (it may only contradict it, never make it).
+ public bool NameCarrierAgrees { get; set; }
+
+ /// Null when the identity is established; otherwise why it is not.
+ public string Problem { get; set; }
+ }
+
+ ///
+ /// The S09 native identity, shared by the S09 input comparison and the S11 results reference. A SAM space is matched to
+ /// the native IDF Space whose surfaces are its panel polygons one to one (geometry alone); the IDF Zone of that Space is
+ /// followed through ZoneHVAC:EquipmentConnections and ZoneHVAC:EquipmentList to its ZoneHVAC:IdealLoadsAirSystem. Names
+ /// are never used to make a match, so two SAM spaces with the same name are told apart by their geometry.
+ ///
+ public static class NativeZoneIdentity
+ {
+ /// The IDF BuildingSurface:Detailed objects of , with their vertices in building coordinates.
+ public static List SurfacesOf(NativeModel native, string spaceName)
+ {
+ List result = new List();
+ foreach (IdfObject surface in native.Idf("BuildingSurface:Detailed"))
+ {
+ if (!string.Equals(surface.Value(4, "Space Name", out string _), spaceName, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ IdfObject zone = native.IdfByName("Zone", surface.Value(3));
+ List vertices = native.Vertices(surface, 11, zone, out string problem);
+ result.Add(new NativeSurface { Object = surface, Vertices = vertices, Problem = problem });
+ }
+
+ return result;
+ }
+
+ ///
+ /// The native space whose surfaces are exactly the space's panel polygons. A candidate with an unreadable surface is
+ /// skipped; a second bijective candidate makes the match ambiguous and ends the search.
+ ///
+ public static NativeSpaceMatch MatchSpace(NativeModel native, ExpectedSpace space)
+ {
+ NativeSpaceMatch result = new NativeSpaceMatch();
+ List expectedPolygons = space.Panels.Select(x => x.Outer).ToList();
+ foreach (IdfObject candidate in native.Idf("Space"))
+ {
+ List candidateSurfaces = SurfacesOf(native, candidate.Name);
+ if (candidateSurfaces.Any(x => x.Vertices == null))
+ {
+ continue;
+ }
+
+ GeometryMatchResult candidateMatch = EQ.MatchPolygons(expectedPolygons, candidateSurfaces.Select(x => OsGeometry.ToPolygon3D(x.Vertices)).ToList());
+ if (candidateMatch.IsBijection)
+ {
+ if (result.NativeSpace != null)
+ {
+ result.NativeSpace = null;
+ result.Ambiguous = true;
+ break;
+ }
+
+ result.NativeSpace = candidate.Name;
+ result.Match = candidateMatch;
+ result.Surfaces = candidateSurfaces;
+ }
+ }
+
+ return result;
+ }
+
+ /// Every ZoneHVAC:IdealLoadsAirSystem the zone's equipment list names (empty when the chain is broken).
+ public static List IdealLoadsOf(NativeModel native, IdfObject zone)
+ {
+ List result = new List();
+ if (zone == null)
+ {
+ return result;
+ }
+
+ IdfObject connections = native.Idf("ZoneHVAC:EquipmentConnections").FirstOrDefault(x => string.Equals(x.Value(0, "Zone Name", out string _), zone.Name, StringComparison.OrdinalIgnoreCase));
+ IdfObject list = native.IdfByName("ZoneHVAC:EquipmentList", connections?.Value(1, "Zone Conditioning Equipment List Name", out string _));
+ if (list == null)
+ {
+ return result;
+ }
+
+ for (int i = 1; i + 1 < list.Fields.Count; i++)
+ {
+ if (string.Equals(list.Value(i), "ZoneHVAC:IdealLoadsAirSystem", StringComparison.OrdinalIgnoreCase))
+ {
+ IdfObject o = native.IdfByName("ZoneHVAC:IdealLoadsAirSystem", list.Value(i + 1));
+ if (o != null)
+ {
+ result.Add(o);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// The native identity of every expected space. An entry has a when its
+ /// space matches no native space or more than one, when its native space or Zone is shared with another SAM space
+ /// (not bijective), or when its Zone does not have exactly one Ideal Loads system.
+ ///
+ public static List Resolve(NativeModel native, ExpectedModel expected)
+ {
+ List result = new List();
+ foreach (ExpectedSpace space in expected.Spaces)
+ {
+ NativeZoneIdentityEntry entry = new NativeZoneIdentityEntry { SpaceGuid = space.Guid, SpaceName = space.Name };
+ NativeSpaceMatch match = MatchSpace(native, space);
+ if (match.Ambiguous)
+ {
+ entry.Problem = "more than one native space matches its geometry";
+ }
+ else if (match.NativeSpace == null)
+ {
+ entry.Problem = "no native space has exactly its panel polygons";
+ }
+ else
+ {
+ entry.NativeSpace = match.NativeSpace;
+ entry.SurfacePairs = match.Match.Pairs.Count;
+ entry.NameCarrierAgrees = match.NativeSpace.EndsWith("_" + space.Guid.ToString("N").Substring(0, 8), StringComparison.OrdinalIgnoreCase);
+ IdfObject zone = native.IdfByName("Zone", native.IdfByName("Space", match.NativeSpace)?.Value(1, "Zone Name", out string _));
+ if (zone == null)
+ {
+ entry.Problem = "native space '" + match.NativeSpace + "' names no IDF Zone";
+ }
+ else
+ {
+ entry.Zone = zone.Name;
+ List idealLoads = IdealLoadsOf(native, zone);
+ entry.IdealLoadsCount = idealLoads.Count;
+ if (idealLoads.Count != 1)
+ {
+ entry.Problem = "zone '" + zone.Name + "' has " + idealLoads.Count + " Ideal Loads systems (exactly one is required)";
+ }
+ else
+ {
+ entry.IdealLoads = idealLoads[0].Name;
+ }
+ }
+ }
+
+ result.Add(entry);
+ }
+
+ // Bijective: no native space, zone or Ideal Loads may serve two SAM spaces.
+ foreach (Func facet in new Func[] { x => x.NativeSpace, x => x.Zone, x => x.IdealLoads })
+ {
+ foreach (IGrouping group in result.Where(x => facet(x) != null).GroupBy(facet, StringComparer.OrdinalIgnoreCase).Where(x => x.Count() > 1))
+ {
+ foreach (NativeZoneIdentityEntry entry in group)
+ {
+ entry.Problem = (entry.Problem == null ? string.Empty : entry.Problem + "; ") + "native '" + group.Key + "' serves " + group.Count() + " SAM spaces (not bijective)";
+ }
+ }
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack/Compare/OsComparison.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack/Compare/OsComparison.cs
index 05edc73..11f8803 100644
--- a/benchmark/SAM.Analytical.OpenStudio.InputReadBack/Compare/OsComparison.cs
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack/Compare/OsComparison.cs
@@ -330,64 +330,21 @@ private AlignmentStatus Alignment(params NativeSchedule[] schedules)
// ---- spaces, surfaces, apertures --------------------------------------------------------------------------------
- private sealed class NativeSurface
- {
- public IdfObject Object;
- public List Vertices;
- public string Problem;
- }
-
- private List SurfacesOf(string spaceName)
- {
- List result = new List();
- foreach (IdfObject surface in native.Idf("BuildingSurface:Detailed"))
- {
- if (!string.Equals(surface.Value(4, "Space Name", out string _), spaceName, StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- IdfObject zone = native.IdfByName("Zone", surface.Value(3));
- List vertices = native.Vertices(surface, 11, zone, out string problem);
- result.Add(new NativeSurface { Object = surface, Vertices = vertices, Problem = problem });
- }
-
- return result;
- }
-
private void CompareSpace(ExpectedSpace space, List matchedSpaces, List matchedSurfaces, List matchedSubSurfaces)
{
string key = Keys.Space(space.Guid);
string carrier = "_" + space.Guid.ToString("N").Substring(0, 8);
- List expectedPolygons = space.Panels.Select(x => x.Outer).ToList();
// Identity: the native space whose surfaces are, one to one, the space's panel polygons (geometry alone). The
- // name suffix the writer mints from the SAM GUID may only contradict that match, never make it.
- string nativeSpace = null;
- GeometryMatchResult match = null;
- List surfaces = null;
- foreach (IdfObject candidate in native.Idf("Space"))
+ // name suffix the writer mints from the SAM GUID may only contradict that match, never make it. The match is the
+ // shared NativeZoneIdentity (also the S11 results reference), unchanged in behaviour.
+ NativeSpaceMatch spaceMatch = NativeZoneIdentity.MatchSpace(native, space);
+ string nativeSpace = spaceMatch.NativeSpace;
+ GeometryMatchResult match = spaceMatch.Match;
+ List surfaces = spaceMatch.Surfaces;
+ if (spaceMatch.Ambiguous)
{
- List candidateSurfaces = SurfacesOf(candidate.Name);
- if (candidateSurfaces.Any(x => x.Vertices == null))
- {
- continue;
- }
-
- GeometryMatchResult candidateMatch = EQ.MatchPolygons(expectedPolygons, candidateSurfaces.Select(x => OsGeometry.ToPolygon3D(x.Vertices)).ToList());
- if (candidateMatch.IsBijection)
- {
- if (nativeSpace != null)
- {
- nativeSpace = null;
- Supporting.Add(key + ": more than one native space matches its geometry");
- break;
- }
-
- nativeSpace = candidate.Name;
- match = candidateMatch;
- surfaces = candidateSurfaces;
- }
+ Supporting.Add(key + ": more than one native space matches its geometry");
}
IdentityStatus identity;
@@ -2100,31 +2057,7 @@ private void CompareVentilation(ExpectedSpace space, string nativeSpace, IdfObje
private IdfObject IdealLoadsOf(IdfObject zone)
{
- if (zone == null)
- {
- return null;
- }
-
- IdfObject connections = native.Idf("ZoneHVAC:EquipmentConnections").FirstOrDefault(x => string.Equals(x.Value(0, "Zone Name", out string _), zone.Name, StringComparison.OrdinalIgnoreCase));
- IdfObject list = native.IdfByName("ZoneHVAC:EquipmentList", connections?.Value(1, "Zone Conditioning Equipment List Name", out string _));
- if (list == null)
- {
- return null;
- }
-
- List idealLoads = new List();
- for (int i = 1; i + 1 < list.Fields.Count; i++)
- {
- if (string.Equals(list.Value(i), "ZoneHVAC:IdealLoadsAirSystem", StringComparison.OrdinalIgnoreCase))
- {
- IdfObject o = native.IdfByName("ZoneHVAC:IdealLoadsAirSystem", list.Value(i + 1));
- if (o != null)
- {
- idealLoads.Add(o);
- }
- }
- }
-
+ List idealLoads = NativeZoneIdentity.IdealLoadsOf(native, zone);
if (idealLoads.Count > 1)
{
Consistency.Add("zone '" + zone.Name + "' has " + idealLoads.Count + " Ideal Loads systems");
diff --git a/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/CompareStep.cs b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/CompareStep.cs
new file mode 100644
index 0000000..f1ff7b8
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/CompareStep.cs
@@ -0,0 +1,84 @@
+// 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.OpenStudio.InputReadBack;
+using System.Collections.Generic;
+using System.IO;
+
+namespace SAM.Analytical.OpenStudio.ResultsReadBack.Cli
+{
+ /// The comparison: library code only. Its inputs must be the files the read and subject steps saw (by hash).
+ internal static class CompareStep
+ {
+ public static int Run(string runDirectory, string modelPath, TextWriter output, TextWriter error)
+ {
+ RunLayout layout = Program.Layout(runDirectory, error, out bool ok);
+ if (!ok)
+ {
+ return ReadBackExit.Infrastructure;
+ }
+
+ foreach (string file in new[] { layout.Reference, layout.Subject })
+ {
+ if (!File.Exists(file))
+ {
+ error.WriteLine("infrastructure: missing " + file + " (run read and subject first)");
+ return ReadBackExit.Infrastructure;
+ }
+ }
+
+ AnalyticalModel model = Program.LoadModel(modelPath);
+ ReferenceDocument reference = S11Json.Load(layout.Reference);
+ SubjectDocument subject = S11Json.Load(layout.Subject);
+
+ List problems = Consistency(modelPath, layout, reference, subject);
+ if (problems.Count > 0)
+ {
+ foreach (string problem in problems)
+ {
+ error.WriteLine("infrastructure: " + problem);
+ }
+
+ return ReadBackExit.Infrastructure;
+ }
+
+ BenchmarkDocument b1a = BenchmarkSerializer.Deserialize(File.ReadAllText(layout.B1a));
+ SamResults samP = SamResults.Read(layout.SamResults);
+ CompareDocument compare = ResultCompare.Run(model, reference, subject, b1a, samP);
+ compare.Inputs["model"] = Sha256.Of(modelPath);
+ compare.Inputs["in.idf"] = reference.IdfSha256Before;
+ compare.Inputs["eplusout.sql"] = reference.SqlSha256Before;
+ compare.Inputs["eplusout.eso"] = reference.EsoSha256Before;
+ compare.Inputs["b1a.json"] = Sha256.Of(layout.B1a);
+ compare.Inputs["sam.json"] = Sha256.Of(layout.SamResults);
+ compare.Inputs["reference.json"] = Sha256.Of(layout.Reference);
+ compare.Inputs["subject.json"] = Sha256.Of(layout.Subject);
+ compare.Inputs["readerPid"] = reference.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ compare.Inputs["subjectPid"] = subject.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ compare.Inputs["comparePid"] = compare.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ compare.Inputs["mutation"] = subject.Mutation;
+ ResultReport.Save(compare, reference, layout.Compare);
+ output.WriteLine("compare: " + compare.Verdict + " (" + compare.Passed + " PASS, " + compare.Failed + " FAIL, " + compare.Blocked + " BLOCKED of " + compare.Required + "); " + Path.Combine(layout.Compare, "compare.json"));
+ return compare.ExitCode;
+ }
+
+ /// The reference, the subject and the files on disk must describe the same run; otherwise nothing is judged.
+ internal static List Consistency(string modelPath, RunLayout layout, ReferenceDocument reference, SubjectDocument subject)
+ {
+ List problems = new List();
+ if (reference.Schema != ReferenceDocument.CurrentSchema) problems.Add("reference.json schema " + reference.Schema);
+ if (subject.Schema != SubjectDocument.CurrentSchema) problems.Add("subject.json schema " + subject.Schema);
+ string sql = Sha256.Of(layout.Sql);
+ if (reference.SqlSha256Before != sql || subject.SqlSha256Before != sql || subject.SqlSha256After != sql) problems.Add("eplusout.sql is not the file the read and subject steps saw");
+ if (reference.EsoSha256Before != Sha256.Of(layout.Eso)) problems.Add("eplusout.eso is not the file the read step saw");
+ if (reference.IdfSha256Before != Sha256.Of(layout.Idf)) problems.Add("in.idf is not the file the read step saw");
+ if (subject.B1aSha256 != Sha256.Of(layout.B1a)) problems.Add("b1a.json is not the file the subject step saw");
+ if (subject.SamResultsSha256 != Sha256.Of(layout.SamResults)) problems.Add("sam.json is not the file the subject step saw");
+ string model = Sha256.Of(modelPath);
+ if (reference.ModelSha256 != model || subject.ModelSha256 != model) problems.Add("the model is not the one the read and subject steps used");
+ if (reference.ProcessId == subject.ProcessId && reference.ProcessStartedUtc == subject.ProcessStartedUtc) problems.Add("read and subject ran in the same process");
+ return problems;
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/NegativeStep.cs b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/NegativeStep.cs
new file mode 100644
index 0000000..eb2e020
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/NegativeStep.cs
@@ -0,0 +1,323 @@
+// 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.OpenStudio.Benchmark;
+using SAM.Analytical.OpenStudio.InputReadBack;
+using System;
+using System.Collections.Generic;
+using System.Data.SQLite;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+
+namespace SAM.Analytical.OpenStudio.ResultsReadBack.Cli
+{
+ /// The record of a negative copy (negative.json): what was mutated and the exit compare must give.
+ public sealed class NegativeRecord
+ {
+ public string Case { get; set; }
+ public string Source { get; set; }
+ public int ExpectedExit { get; set; }
+ public string Mutation { get; set; }
+ public Dictionary SourceSha256 { get; set; } = new Dictionary(StringComparer.Ordinal);
+ public DateTime CreatedUtc { get; set; }
+ }
+
+ ///
+ /// Disposable negatives on COPIES of a produced run (no extra simulation; the original is never touched):
+ ///
+ /// - ctl-unmutated-copy: a plain copy; compare must PASS when the original passes.
+ /// - neg-delete-hour-both: one hour of every S11 series removed from BOTH the SQL and the ESO copy; must BLOCK.
+ /// - neg-duplicate-hour-both: one hour of every S11 series repeated in BOTH copies; must BLOCK.
+ /// - neg-sensible-cooling-substitution: the producer objects (sam.json) and the import (SAM-I) carry the reference
+ /// SENSIBLE cooling in place of Total; the B1a copy is re-mapped from those objects, so the closure holds and the
+ /// substitution must FAIL on the cooling rows. For the hour cases the read/subject/compare steps run fresh on the copy;
+ /// for the substitution the subject is captured here, then its SAM-I cooling values are replaced.
+ ///
+ ///
+ internal static class NegativeStep
+ {
+ public static readonly Dictionary Cases = new Dictionary(StringComparer.Ordinal)
+ {
+ ["ctl-unmutated-copy"] = ReadBackExit.Pass,
+ ["neg-delete-hour-both"] = ReadBackExit.BlockedTechnical,
+ ["neg-duplicate-hour-both"] = ReadBackExit.BlockedTechnical,
+ ["neg-sensible-cooling-substitution"] = ReadBackExit.Fail,
+ };
+
+ /// The mutated hour: 0-based hour 4500 of a non-leap year is 7 July, interval 12:00-13:00 (EnergyPlus hour 13).
+ public static readonly HourKey MutatedHour = new HourKey(7, 7, 13);
+
+ public static int Run(string runDirectory, string modelPath, string caseId, string outputDirectory, TextWriter output)
+ {
+ if (!Cases.TryGetValue(caseId, out int expectedExit))
+ {
+ throw new ArgumentException("unknown case '" + caseId + "'; cases: " + string.Join(", ", Cases.Keys));
+ }
+
+ if (Directory.Exists(outputDirectory) && Directory.EnumerateFileSystemEntries(outputDirectory).Any())
+ {
+ throw new IOException("The negative directory must be new or empty: " + outputDirectory);
+ }
+
+ RunLayout source = RunLayout.Resolve(runDirectory, out List problems);
+ if (problems.Count > 0)
+ {
+ throw new IOException(string.Join("; ", problems));
+ }
+
+ NegativeRecord record = new NegativeRecord { Case = caseId, Source = source.Directory, ExpectedExit = expectedExit, CreatedUtc = DateTime.UtcNow };
+ foreach (string file in new[] { source.B1a, source.SamResults, source.Sql, source.Eso, source.Idf })
+ {
+ record.SourceSha256[Path.GetFileName(file)] = Sha256.Of(file);
+ }
+
+ string runFolder = Path.Combine(outputDirectory, "work", "run");
+ Directory.CreateDirectory(runFolder);
+ File.Copy(source.B1a, Path.Combine(outputDirectory, RunLayout.B1aFile));
+ File.Copy(source.SamResults, Path.Combine(outputDirectory, RunLayout.SamResultsFile));
+ File.Copy(source.Sql, Path.Combine(runFolder, "eplusout.sql"));
+ File.Copy(source.Eso, Path.Combine(runFolder, "eplusout.eso"));
+ File.Copy(source.Idf, Path.Combine(runFolder, "in.idf"));
+ RunLayout copy = RunLayout.Resolve(outputDirectory, out _);
+
+ switch (caseId)
+ {
+ case "ctl-unmutated-copy":
+ record.Mutation = "none (control)";
+ break;
+ case "neg-delete-hour-both":
+ record.Mutation = "deleted " + MutatedHour + " of every S11 series in SQL (" + SqlDeleteHour(copy.Sql, MutatedHour) + " rows) and ESO (" + EsoEditHour(copy.Eso, MutatedHour, false) + " lines)";
+ break;
+ case "neg-duplicate-hour-both":
+ record.Mutation = "repeated " + MutatedHour + " of every S11 series in SQL (" + SqlDuplicateHour(copy.Sql, MutatedHour) + " rows) and ESO (" + EsoEditHour(copy.Eso, MutatedHour, true) + " lines)";
+ break;
+ case "neg-sensible-cooling-substitution":
+ record.Mutation = SensibleSubstitution(source, copy, modelPath);
+ break;
+ }
+
+ S11Json.Save(record, Path.Combine(outputDirectory, RunLayout.NegativeFile));
+ output.WriteLine("negative " + caseId + ": " + record.Mutation + "; compare must exit " + expectedExit);
+ return ReadBackExit.Pass;
+ }
+
+ // ---- SQL ---------------------------------------------------------------------------------------------------------
+
+ private static List HourlyIndexes(SQLiteConnection connection)
+ {
+ List result = new List();
+ using (SQLiteCommand command = connection.CreateCommand())
+ {
+ string names = string.Join(",", S11Names.IdealLoadsVariables.Concat(S11Names.ZoneVariables).Select(x => "'" + x + "'"));
+ command.CommandText = "SELECT ReportDataDictionaryIndex FROM ReportDataDictionary WHERE ReportingFrequency = 'Hourly' AND IsMeter = 0 AND Name IN (" + names + ")";
+ using (SQLiteDataReader reader = command.ExecuteReader())
+ {
+ while (reader.Read())
+ {
+ result.Add(reader.GetInt64(0));
+ }
+ }
+ }
+
+ return result;
+ }
+
+ private static long HourlyTimeIndex(SQLiteConnection connection, HourKey hour)
+ {
+ using (SQLiteCommand command = connection.CreateCommand())
+ {
+ command.CommandText = "SELECT t.TimeIndex FROM Time t JOIN EnvironmentPeriods e ON e.EnvironmentPeriodIndex = t.EnvironmentPeriodIndex WHERE e.EnvironmentType = 3 AND t.Interval = 60 AND t.Month = @m AND t.Day = @d AND t.Hour = @h AND t.WarmupFlag = 0";
+ command.Parameters.AddWithValue("@m", hour.Month);
+ command.Parameters.AddWithValue("@d", hour.Day);
+ command.Parameters.AddWithValue("@h", hour.Hour);
+ object value = command.ExecuteScalar();
+ if (value == null)
+ {
+ throw new InvalidDataException("no hourly Time row for " + hour);
+ }
+
+ return System.Convert.ToInt64(value, CultureInfo.InvariantCulture);
+ }
+ }
+
+ private static SQLiteConnection Open(string path)
+ {
+ SQLiteConnection connection = new SQLiteConnection(new SQLiteConnectionStringBuilder { DataSource = path, FailIfMissing = true }.ConnectionString);
+ connection.Open();
+ return connection;
+ }
+
+ internal static int SqlDeleteHour(string sqlPath, HourKey hour)
+ {
+ try
+ {
+ using (SQLiteConnection connection = Open(sqlPath))
+ using (SQLiteCommand command = connection.CreateCommand())
+ {
+ long timeIndex = HourlyTimeIndex(connection, hour);
+ command.CommandText = "DELETE FROM ReportData WHERE TimeIndex = @t AND ReportDataDictionaryIndex IN (" + string.Join(",", HourlyIndexes(connection)) + ")";
+ command.Parameters.AddWithValue("@t", timeIndex);
+ return command.ExecuteNonQuery();
+ }
+ }
+ finally
+ {
+ SQLiteConnection.ClearAllPools();
+ }
+ }
+
+ /// A second hourly Time row with the same stamp, and a copy of every S11 value at it.
+ internal static int SqlDuplicateHour(string sqlPath, HourKey hour)
+ {
+ try
+ {
+ using (SQLiteConnection connection = Open(sqlPath))
+ using (SQLiteCommand command = connection.CreateCommand())
+ {
+ long timeIndex = HourlyTimeIndex(connection, hour);
+ command.CommandText = "SELECT MAX(TimeIndex) + 1 FROM Time";
+ long duplicate = System.Convert.ToInt64(command.ExecuteScalar(), CultureInfo.InvariantCulture);
+ command.CommandText = "INSERT INTO Time (TimeIndex, Year, Month, Day, Hour, Minute, Dst, Interval, IntervalType, SimulationDays, DayType, EnvironmentPeriodIndex, WarmupFlag) "
+ + "SELECT @n, Year, Month, Day, Hour, Minute, Dst, Interval, IntervalType, SimulationDays, DayType, EnvironmentPeriodIndex, WarmupFlag FROM Time WHERE TimeIndex = @t";
+ command.Parameters.AddWithValue("@n", duplicate);
+ command.Parameters.AddWithValue("@t", timeIndex);
+ command.ExecuteNonQuery();
+ command.CommandText = "INSERT INTO ReportData (TimeIndex, ReportDataDictionaryIndex, Value) SELECT @n, ReportDataDictionaryIndex, Value FROM ReportData WHERE TimeIndex = @t AND ReportDataDictionaryIndex IN (" + string.Join(",", HourlyIndexes(connection)) + ")";
+ return command.ExecuteNonQuery();
+ }
+ }
+ finally
+ {
+ SQLiteConnection.ClearAllPools();
+ }
+ }
+
+ // ---- ESO ---------------------------------------------------------------------------------------------------------
+
+ /// Removes (or repeats) the S11 value lines after the RunPeriod's hourly stamp of .
+ internal static int EsoEditHour(string esoPath, HourKey hour, bool duplicate)
+ {
+ List lines = File.ReadAllLines(esoPath).ToList();
+ HashSet wanted = new HashSet(S11Names.IdealLoadsVariables.Concat(S11Names.ZoneVariables), StringComparer.OrdinalIgnoreCase);
+ HashSet ids = new HashSet();
+ int end = lines.FindIndex(x => x.StartsWith("End of Data Dictionary", StringComparison.Ordinal));
+ for (int i = 1; i < end; i++)
+ {
+ EsoVariable variable = EsoResultRead.DictionaryLine(lines[i]);
+ if (variable != null && !variable.IsMeter && wanted.Contains(variable.Name) && string.Equals(variable.Frequency, "Hourly", StringComparison.OrdinalIgnoreCase))
+ {
+ ids.Add(variable.Id);
+ }
+ }
+
+ for (int i = end + 1; i < lines.Count; i++)
+ {
+ string[] f = lines[i].Split(',');
+ if (f[0] != "2" || f.Length < 8)
+ {
+ continue;
+ }
+
+ if (int.Parse(f[2].Trim(), CultureInfo.InvariantCulture) != hour.Month || int.Parse(f[3].Trim(), CultureInfo.InvariantCulture) != hour.Day || int.Parse(f[5].Trim(), CultureInfo.InvariantCulture) != hour.Hour
+ || double.Parse(f[6].Trim(), CultureInfo.InvariantCulture) != 0 || double.Parse(f[7].Trim(), CultureInfo.InvariantCulture) != 60)
+ {
+ continue;
+ }
+
+ List block = new List();
+ for (int j = i + 1; j < lines.Count; j++)
+ {
+ string[] g = lines[j].Split(',');
+ if (!int.TryParse(g[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id) || id <= 6)
+ {
+ break;
+ }
+
+ if (ids.Contains(id))
+ {
+ block.Add(j);
+ }
+ }
+
+ if (duplicate)
+ {
+ List copy = new List { lines[i] };
+ copy.AddRange(block.Select(x => lines[x]));
+ int after = block.Count > 0 ? block.Max() + 1 : i + 1;
+ while (after < lines.Count && int.TryParse(lines[after].Split(',')[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id) && id > 6)
+ {
+ after++;
+ }
+
+ lines.InsertRange(after, copy);
+ File.WriteAllLines(esoPath, lines);
+ return copy.Count;
+ }
+
+ for (int k = block.Count - 1; k >= 0; k--)
+ {
+ lines.RemoveAt(block[k]);
+ }
+
+ File.WriteAllLines(esoPath, lines);
+ return block.Count;
+ }
+
+ throw new InvalidDataException("no hourly ESO stamp for " + hour);
+ }
+
+ // ---- F3 substitution ---------------------------------------------------------------------------------------------
+
+ ///
+ /// Replaces every cooling quantity of the producer objects and of the import by the reference SENSIBLE cooling, and
+ /// re-maps the B1a copy from the mutated objects with the producer (so the copy is self-consistent).
+ ///
+ private static string SensibleSubstitution(RunLayout source, RunLayout copy, string modelPath)
+ {
+ if (!File.Exists(source.Reference) || !File.Exists(source.Subject))
+ {
+ throw new IOException("the source run needs reference.json and subject.json (run read and subject first)");
+ }
+
+ ReferenceDocument reference = S11Json.Load(source.Reference);
+ AnalyticalModel model = Program.LoadModel(modelPath);
+ ModelLoadReference cooling = reference.ModelOf(LoadKind.Cooling);
+ if (!cooling.Sensible.Available || reference.Spaces.Any(x => !x.Quantities[S11Names.SensibleCooling].Available))
+ {
+ throw new InvalidDataException("the reference has no complete Sensible cooling to substitute");
+ }
+
+ OpenStudioBenchmarkInputs inputs = OpenStudioBenchmarkInputs.Read(source.SamResults);
+ inputs.ModelResult.SetValue(AnalyticalModelSimulationResultParameter.ConsumptionCooling, cooling.Sensible.ConsumptionKwh);
+ inputs.ModelResult.SetValue(AnalyticalModelSimulationResultParameter.PeakCoolingLoad, cooling.Sensible.CoincidentPeakKw);
+ inputs.ModelResult.SetValue(AnalyticalModelSimulationResultParameter.PeakCoolingHour, cooling.Sensible.PeakHour);
+ foreach (SpaceSimulationResult result in inputs.SpaceResults)
+ {
+ if (result.TryGetValue(Analytical.SpaceSimulationResultParameter.LoadType, out string loadType) && loadType == LoadType.Cooling.ToString())
+ {
+ QuantityReference sensible = reference.Spaces.Single(x => x.SpaceGuid.ToString("N") == result.Reference).Quantities[S11Names.SensibleCooling];
+ result.SetValue(Analytical.SpaceSimulationResultParameter.Load, sensible.PeakWatts);
+ result.SetValue(Analytical.SpaceSimulationResultParameter.LoadIndex, sensible.PeakHour);
+ }
+ }
+
+ inputs.Write(copy.SamResults);
+ BenchmarkDocument produced = BenchmarkSerializer.Deserialize(File.ReadAllText(source.B1a));
+ BenchmarkSerializer.Write(copy.B1a, model.ToBenchmark(SubjectStep.Context(produced.Provenance), inputs));
+
+ SubjectDocument subject = SubjectStep.Capture(model, modelPath, copy);
+ foreach (SubjectResult result in subject.SamI.Where(x => x.IsAnnual && x.LoadType == LoadType.Cooling.ToString()))
+ {
+ QuantityReference sensible = reference.Spaces.Single(x => x.SpaceGuid.ToString("N") == result.Reference).Quantities[S11Names.SensibleCooling];
+ result.Load = sensible.PeakWatts;
+ result.LoadIndex = sensible.PeakHour;
+ }
+
+ subject.Mutation = "SAM-I cooling Load/LoadIndex replaced by the reference Sensible cooling peak";
+ S11Json.Save(subject, copy.Subject);
+ return "producer objects and SAM-I carry the reference Sensible cooling (consumption " + cooling.Sensible.ConsumptionKwh.ToString("R", CultureInfo.InvariantCulture) + " kWh for Total " + cooling.Total.ConsumptionKwh.ToString("R", CultureInfo.InvariantCulture) + " kWh); B1a re-mapped from the mutated objects";
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/Program.cs b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/Program.cs
new file mode 100644
index 0000000..3275124
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/Program.cs
@@ -0,0 +1,132 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using SAM.Analytical.OpenStudio.InputReadBack;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace SAM.Analytical.OpenStudio.ResultsReadBack.Cli
+{
+ ///
+ /// os-results-readback: the S11 OpenStudio results round-trip harness. Each step runs in its own process.
+ ///
+ /// - read --run <run dir> --model <fixture.json> [--fixture-id id]: the independent SQL/ESO reference (reference.json)
+ /// - subject --run <run dir> --model <fixture.json>: the code under test (subject.json)
+ /// - compare --run <run dir> --model <fixture.json>: the verdict (compare/compare.json, compare.md)
+ /// - negative --run <run dir> --model <fixture.json> --case <id> --out <new dir>: a mutated copy
+ ///
+ /// Exit codes follow S08/S09: 0 PASS, 10 FAIL, 11 BLOCKED technical, 64 usage, 70 infrastructure. read, subject and
+ /// negative return 0 when they completed (their findings are judged by compare).
+ ///
+ public static class Program
+ {
+ public static int Main(string[] args)
+ {
+ if (args == null || args.Length == 0 || (args.Length - 1) % 2 != 0)
+ {
+ return Usage();
+ }
+
+ Dictionary options = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (int i = 1; i + 1 < args.Length; i += 2)
+ {
+ if (!args[i].StartsWith("--", StringComparison.Ordinal) || options.ContainsKey(args[i].Substring(2)))
+ {
+ return Usage();
+ }
+
+ options[args[i].Substring(2)] = args[i + 1];
+ }
+
+ try
+ {
+ switch (args[0].ToLowerInvariant())
+ {
+ case "read":
+ return ReadStep.Run(Required(options, "run"), Required(options, "model"), Optional(options, "fixture-id"), Console.Out, Console.Error);
+ case "subject":
+ return SubjectStep.Run(Required(options, "run"), Required(options, "model"), Console.Out, Console.Error);
+ case "compare":
+ return CompareStep.Run(Required(options, "run"), Required(options, "model"), Console.Out, Console.Error);
+ case "negative":
+ return NegativeStep.Run(Required(options, "run"), Required(options, "model"), Required(options, "case"), Required(options, "out"), Console.Out);
+ default:
+ return Usage();
+ }
+ }
+ catch (UsageException exception)
+ {
+ Console.Error.WriteLine(exception.Message);
+ return Usage();
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine("infrastructure: " + exception);
+ return ReadBackExit.Infrastructure;
+ }
+ }
+
+ /// The frozen fixture model (the SAM inputs: expected spaces, areas, volumes).
+ internal static AnalyticalModel LoadModel(string modelPath)
+ {
+ if (!File.Exists(modelPath))
+ {
+ throw new FileNotFoundException("model not found", modelPath);
+ }
+
+ AnalyticalModel model = Core.Convert.ToSAM(modelPath)?.FirstOrDefault();
+ if (model == null)
+ {
+ throw new InvalidDataException(modelPath + " is not a SAM AnalyticalModel");
+ }
+
+ return model;
+ }
+
+ /// The run layout, or the infrastructure problems that stop a step.
+ internal static RunLayout Layout(string runDirectory, TextWriter error, out bool ok)
+ {
+ RunLayout layout = RunLayout.Resolve(runDirectory, out List problems);
+ foreach (string problem in problems)
+ {
+ error.WriteLine("infrastructure: " + problem);
+ }
+
+ ok = problems.Count == 0;
+ return layout;
+ }
+
+ private static string Required(Dictionary options, string name)
+ {
+ if (!options.TryGetValue(name, out string value) || string.IsNullOrWhiteSpace(value))
+ {
+ throw new UsageException("missing --" + name);
+ }
+
+ return value;
+ }
+
+ private static string Optional(Dictionary options, string name)
+ {
+ return options.TryGetValue(name, out string value) ? value : null;
+ }
+
+ private static int Usage()
+ {
+ Console.Error.WriteLine("usage: os-results-readback read --run --model [--fixture-id id]");
+ Console.Error.WriteLine(" os-results-readback subject --run --model ");
+ Console.Error.WriteLine(" os-results-readback compare --run --model ");
+ Console.Error.WriteLine(" os-results-readback negative --run --model --case <" + string.Join("|", NegativeStep.Cases.Keys) + "> --out ");
+ return ReadBackExit.Usage;
+ }
+
+ private sealed class UsageException : Exception
+ {
+ public UsageException(string message) : base(message)
+ {
+ }
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/ReadStep.cs b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/ReadStep.cs
new file mode 100644
index 0000000..ae204d4
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/ReadStep.cs
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using SAM.Analytical.OpenStudio.InputReadBack;
+using System.IO;
+
+namespace SAM.Analytical.OpenStudio.ResultsReadBack.Cli
+{
+ /// The reference read: library code only (no production importer, writer or naming).
+ internal static class ReadStep
+ {
+ public static int Run(string runDirectory, string modelPath, string fixtureId, TextWriter output, TextWriter error)
+ {
+ RunLayout layout = Program.Layout(runDirectory, error, out bool ok);
+ if (!ok)
+ {
+ return ReadBackExit.Infrastructure;
+ }
+
+ AnalyticalModel model = Program.LoadModel(modelPath);
+ ReferenceDocument reference = ResultReference.Build(model, modelPath, layout.Idf, layout.Sql, layout.Eso, fixtureId ?? Path.GetFileNameWithoutExtension(modelPath));
+ S11Json.Save(reference, layout.Reference);
+ output.WriteLine("read: " + layout.Reference + " (" + reference.Spaces.Count + " spaces, " + reference.GlobalBlocks.Count + " global block(s), " + reference.Completeness.Count + " series judged)");
+ return ReadBackExit.Pass;
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/SAM.Analytical.OpenStudio.ResultsReadBack.Cli.csproj b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/SAM.Analytical.OpenStudio.ResultsReadBack.Cli.csproj
new file mode 100644
index 0000000..470e23a
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/SAM.Analytical.OpenStudio.ResultsReadBack.Cli.csproj
@@ -0,0 +1,61 @@
+
+
+
+ Exe
+ net8.0
+ SAM.Analytical.OpenStudio.ResultsReadBack.Cli
+ os-results-readback
+ false
+ x64
+ x64
+ x64
+ latest
+ disable
+ disable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ..\..\..\SAM_Validation\build\SAM.Analytical.Benchmark.dll
+
+
+ ..\..\..\SAM\build\SAM.Core.dll
+
+
+ ..\..\..\SAM\build\SAM.Geometry.dll
+
+
+ ..\..\..\SAM\build\SAM.Analytical.dll
+
+
+ ..\..\..\SAM\build\SAM.Architectural.dll
+
+
+ ..\..\..\SAM\build\SAM.Weather.dll
+
+
+
+
diff --git a/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/SubjectStep.cs b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/SubjectStep.cs
new file mode 100644
index 0000000..051e665
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack.Cli/SubjectStep.cs
@@ -0,0 +1,153 @@
+// 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.OpenStudio.Benchmark;
+using SAM.Analytical.OpenStudio.InputReadBack;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json.Nodes;
+
+namespace SAM.Analytical.OpenStudio.ResultsReadBack.Cli
+{
+ ///
+ /// The subject capture: the ONLY step that runs the code under test. It (1) re-maps the captured producer objects
+ /// (sam.json) with the producer's own ToBenchmark and requires byte-identical B1a model and spaces sections (closure),
+ /// (2) records the production report keys of every SAM space, and (3) imports the same SQL into a fresh copy of the
+ /// model with the SAM_UI/Grasshopper path Modify.AddResults.
+ ///
+ internal static class SubjectStep
+ {
+ public static int Run(string runDirectory, string modelPath, TextWriter output, TextWriter error)
+ {
+ RunLayout layout = Program.Layout(runDirectory, error, out bool ok);
+ if (!ok)
+ {
+ return ReadBackExit.Infrastructure;
+ }
+
+ AnalyticalModel model = Program.LoadModel(modelPath);
+ SubjectDocument subject = Capture(model, modelPath, layout);
+ S11Json.Save(subject, layout.Subject);
+ output.WriteLine("subject: " + layout.Subject + " (closure " + (subject.ClosureIdentical ? "identical" : "DIFFERS") + ", " + subject.SamI.Count + " SAM-I space results)");
+ return ReadBackExit.Pass;
+ }
+
+ public static SubjectDocument Capture(AnalyticalModel model, string modelPath, RunLayout layout)
+ {
+ SubjectDocument result = new SubjectDocument
+ {
+ ProcessId = Environment.ProcessId,
+ ProcessStartedUtc = ProcessIdentity.CurrentStartedUtc(),
+ StartedUtc = DateTime.UtcNow,
+ SubjectAssemblySha256 = Sha256.Of(typeof(SubjectStep).Assembly.Location),
+ ProducerAssemblySha256 = Sha256.Of(typeof(OpenStudioBenchmarkInputs).Assembly.Location),
+ ImporterAssemblySha256 = Sha256.Of(typeof(Analytical.OpenStudio.Modify).Assembly.Location),
+ ModelSha256 = Sha256.Of(modelPath),
+ B1aSha256 = Sha256.Of(layout.B1a),
+ SamResultsSha256 = Sha256.Of(layout.SamResults),
+ SqlSha256Before = Sha256.Of(layout.Sql),
+ };
+
+ Closure(model, layout, result);
+
+ foreach (Space space in model.AdjacencyCluster?.GetSpaces() ?? new List())
+ {
+ result.ProductionKeys.Add(new ProductionKey
+ {
+ SpaceGuid = space.Guid,
+ SpaceName = space.Name,
+ IdealLoads = Core.OpenStudio.Query.OpenStudioName("IdealLoads", space.Name, space.Guid),
+ ThermalZone = Core.OpenStudio.Query.OpenStudioName("ThermalZone", space.Name, space.Guid),
+ });
+ }
+
+ Import(model, layout.Sql, result);
+ result.SqlSha256After = Sha256.Of(layout.Sql);
+ result.FinishedUtc = DateTime.UtcNow;
+ return result;
+ }
+
+ ///
+ /// The captured objects, mapped again by the producer with a context rebuilt from the produced document's own
+ /// provenance, must reproduce its model and spaces sections byte for byte.
+ ///
+ private static void Closure(AnalyticalModel model, RunLayout layout, SubjectDocument result)
+ {
+ try
+ {
+ string producedJson = File.ReadAllText(layout.B1a);
+ BenchmarkDocument produced = BenchmarkSerializer.Deserialize(producedJson);
+ OpenStudioBenchmarkInputs inputs = OpenStudioBenchmarkInputs.Read(layout.SamResults);
+ BenchmarkDocument regenerated = model.ToBenchmark(Context(produced.Provenance), inputs);
+ JsonObject producedNode = JsonNode.Parse(producedJson) as JsonObject;
+ JsonObject regeneratedNode = JsonNode.Parse(BenchmarkSerializer.Serialize(regenerated)) as JsonObject;
+ string producedModel = producedNode?["model"]?.ToJsonString();
+ string regeneratedModel = regeneratedNode?["model"]?.ToJsonString();
+ string producedSpaces = producedNode?["spaces"]?.ToJsonString();
+ string regeneratedSpaces = regeneratedNode?["spaces"]?.ToJsonString();
+ result.ClosureModelSha256Produced = Hash(producedModel);
+ result.ClosureModelSha256Regenerated = Hash(regeneratedModel);
+ result.ClosureSpacesSha256Produced = Hash(producedSpaces);
+ result.ClosureSpacesSha256Regenerated = Hash(regeneratedSpaces);
+ bool model_ = producedModel != null && producedModel == regeneratedModel;
+ bool spaces = producedSpaces != null && producedSpaces == regeneratedSpaces;
+ result.ClosureIdentical = model_ && spaces;
+ result.ClosureDetail = result.ClosureIdentical
+ ? "the captured objects re-map to byte-identical B1a model and spaces sections"
+ : "the captured objects re-map to a different B1a: model section " + (model_ ? "identical" : "DIFFERS") + ", spaces section " + (spaces ? "identical" : "DIFFERS");
+ }
+ catch (Exception exception) when (exception is IOException || exception is System.Text.Json.JsonException || exception is InvalidDataException || exception is InvalidCastException)
+ {
+ result.ClosureIdentical = false;
+ result.ClosureDetail = "closure could not be evaluated: " + exception.Message;
+ }
+ }
+
+ /// A producer context carrying the produced document's provenance (the mapping reads only provenance from it).
+ internal static OpenStudioBenchmarkContext Context(BenchmarkProvenance provenance)
+ {
+ OpenStudioBenchmarkContext context = new OpenStudioBenchmarkContext
+ {
+ SourceModelName = provenance.SourceModelName,
+ SourceModelGuid = provenance.SourceModelGuid,
+ SourceFileHash = provenance.SourceFileHash,
+ CanonicalModelHash = provenance.CanonicalModelHash,
+ CanonicalizationVersion = provenance.CanonicalizationVersion,
+ SamCommit = provenance.SamCommit,
+ RunnerCommit = provenance.RunnerCommit,
+ EngineName = provenance.Engine?.Name,
+ EngineVersion = provenance.Engine?.Version,
+ SdkVersion = provenance.Engine?.SdkVersion,
+ WeatherIdentity = provenance.Weather?.Identity,
+ WeatherHash = provenance.Weather?.Hash,
+ DesignDaySource = provenance.DesignDaySource,
+ RunTimestampUtc = provenance.RunTimestampUtc,
+ DurationSeconds = provenance.DurationSeconds ?? 0,
+ State = provenance.State,
+ };
+
+ context.Warnings.AddRange(provenance.Warnings ?? new List());
+ context.Notes.AddRange(provenance.Notes ?? new List());
+ return context;
+ }
+
+ /// SAM-I: the production import of the SQL into a fresh copy of the model, with its diagnostics and relations.
+ private static void Import(AnalyticalModel model, string sqlPath, SubjectDocument result)
+ {
+ AdjacencyCluster cluster = new AdjacencyCluster(model.AdjacencyCluster);
+ List results = Analytical.OpenStudio.Modify.AddResults(cluster, sqlPath, out List diagnostics) ?? new List();
+ result.SamIDiagnostics.AddRange(diagnostics ?? new List());
+ List spaces = cluster.GetSpaces() ?? new List();
+ foreach (SpaceSimulationResult spaceResult in results.OfType())
+ {
+ Space attached = spaces.FirstOrDefault(x => cluster.GetResults(x)?.Any(r => r.Guid == spaceResult.Guid) == true);
+ result.SamI.Add(SamResults.Describe(spaceResult, attached?.Guid));
+ }
+ }
+
+ private static string Hash(string text) => text == null ? null : Sha256.OfBytes(System.Text.Encoding.UTF8.GetBytes(text));
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack/Build-S11-Receipt.ps1 b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack/Build-S11-Receipt.ps1
new file mode 100644
index 0000000..44b87c5
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack/Build-S11-Receipt.ps1
@@ -0,0 +1,155 @@
+# SPDX-License-Identifier: LGPL-3.0-or-later
+# Copyright (c) 2020-2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+#
+# S11: the controlled, engine-free Release build whose receipt Run-S11-Results.ps1 requires (ported from SAM_Tas
+# Build-S10-Receipt.ps1). Nothing here starts OpenStudio or EnergyPlus.
+#
+# 1. SAM, SAM_Validation, SAM_Tas and SAM_OpenStudio must each be a clean checkout (no change, no untracked file). Their
+# HEADs are the build commits.
+# 2. MSBuild rebuilds, one project at a time, the SAM core libraries into SAM\build, the B1a schema into
+# SAM_Validation\build, then the two S11 executables: benchmark-openstudio (the producer) and os-results-readback
+# (the harness, which builds the S09 reader and the SAM_Tas evaluation core it references). Each build gets
+# /p:SAMSourceRevision=, so every assembly carries InformationalVersion +.
+# The Grasshopper projects are NOT built: their post-build step deploys into %APPDATA%\SAM.
+# 3. Only after every build exits 0: the checkouts are still clean at the same HEADs; each declared artifact was written
+# by this build and carries its checkout's stamp; every SAM\build library beside the executables is byte-identical
+# to its SAM\build copy.
+# 4. The receipt (default: s11-build-receipt.json beside os-results-readback.exe) records the commits, the commands and
+# the sha256 of every .dll/.exe in both executable folders. It is removed first, so a refused build leaves none.
+#
+# Usage (Windows PowerShell 5.1; the defaults are this workspace's paths):
+# powershell -ExecutionPolicy Bypass -File Build-S11-Receipt.ps1
+param(
+ [string]$MSBuild = 'C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe',
+ [string]$RunnerRepository = '',
+ [string]$BuildReceipt = ''
+)
+
+$ErrorActionPreference = 'Stop'
+if (-not $RunnerRepository) { $RunnerRepository = Join-Path $PSScriptRoot '..\..' }
+$RunnerRepository = [IO.Path]::GetFullPath($RunnerRepository)
+$workspace = Split-Path $RunnerRepository
+$SamRepository = Join-Path $workspace 'SAM'
+$ValidationRepository = Join-Path $workspace 'SAM_Validation'
+$TasRepository = Join-Path $workspace 'SAM_Tas'
+$benchmarkProject = Join-Path $RunnerRepository 'benchmark\SAM.Analytical.OpenStudio.Benchmark\SAM.Analytical.OpenStudio.Benchmark.csproj'
+$harnessProject = Join-Path $RunnerRepository 'benchmark\SAM.Analytical.OpenStudio.ResultsReadBack.Cli\SAM.Analytical.OpenStudio.ResultsReadBack.Cli.csproj'
+$benchmarkExe = Join-Path $RunnerRepository 'benchmark\SAM.Analytical.OpenStudio.Benchmark\bin\x64\Release\net8.0\benchmark-openstudio.exe'
+$harnessExe = Join-Path $RunnerRepository 'benchmark\SAM.Analytical.OpenStudio.ResultsReadBack.Cli\bin\x64\Release\net8.0\os-results-readback.exe'
+if (-not $BuildReceipt) { $BuildReceipt = Join-Path (Split-Path $harnessExe) 's11-build-receipt.json' }
+$BuildReceipt = [IO.Path]::GetFullPath($BuildReceipt)
+
+# The SAM core libraries the S11 executables load from SAM\build, in dependency order.
+$samProjects = 'SAM.Core', 'SAM.Math', 'SAM.Units', 'SAM.Geometry', 'SAM.Weather', 'SAM.Architectural', 'SAM.Analytical'
+
+function Refuse([string]$Reason) {
+ Write-Host "REFUSED (no receipt written): $Reason"
+ exit 3
+}
+
+# SHA-256 through .NET (Get-FileHash is a module function Windows PowerShell 5.1 may not autoload in a child host).
+function Sha([string]$Path) {
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
+ $stream = [IO.File]::OpenRead($Path)
+ try {
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try { return ([BitConverter]::ToString($sha.ComputeHash($stream)) -replace '-', '').ToLowerInvariant() } finally { $sha.Dispose() }
+ }
+ finally { $stream.Dispose() }
+}
+
+$git = 'C:\Program Files\Git\cmd\git.exe'
+if (-not (Test-Path $git)) { $git = 'git' }
+
+function Git([string]$Directory) {
+ $ErrorActionPreference = 'Continue'
+ & $git -C $Directory @args 2>$null
+}
+
+function Clean-Head([string]$Directory) {
+ if (-not (Test-Path (Join-Path $Directory '.git'))) { Refuse "$Directory is not a git checkout" }
+ $head = (Git $Directory rev-parse HEAD | Out-String).Trim()
+ if ($head -notmatch '^[0-9a-f]{40}$') { Refuse "HEAD of $Directory is not a full commit SHA ('$head')" }
+ $status = @(Git $Directory status --porcelain --untracked-files=all)
+ if ($LASTEXITCODE -ne 0) { Refuse "git status failed in $Directory" }
+ if ($status.Count -gt 0) { Refuse "$Directory has $($status.Count) uncommitted or untracked change(s), e.g. $($status[0])" }
+ return $head
+}
+
+function Test-Stamp([string]$Path, [string]$Commit) {
+ $text = [Text.Encoding]::GetEncoding(28591).GetString([IO.File]::ReadAllBytes($Path))
+ return $text.Contains('+' + $Commit)
+}
+
+if (Test-Path -LiteralPath $BuildReceipt) { Remove-Item -LiteralPath $BuildReceipt -Force }
+$heads = [ordered]@{}
+foreach ($repository in @($SamRepository, $ValidationRepository, $TasRepository, $RunnerRepository)) { $heads[$repository] = Clean-Head $repository }
+if (-not (Test-Path -LiteralPath $MSBuild)) { Refuse "MSBuild not found: $MSBuild" }
+
+$samBuild = Join-Path $SamRepository 'build'
+$started = (Get-Date).ToUniversalTime()
+$commands = New-Object System.Collections.Generic.List[object]
+
+function Build([string]$Project, [string]$Commit, [string[]]$Extra) {
+ $arguments = @($Project, '-restore', '-t:Rebuild', '-p:Configuration=Release', "-p:SAMSourceRevision=$Commit", '-nr:false', '-v:m') + $Extra
+ Write-Host "==> $Project"
+ & $MSBuild @arguments | Out-Host
+ $code = $LASTEXITCODE
+ $commands.Add([ordered]@{ file = $MSBuild; arguments = ($arguments -join ' '); exitCode = $code })
+ if ($code -ne 0) { Refuse "MSBuild exited $code for $Project" }
+}
+
+foreach ($name in $samProjects) { Build (Join-Path $SamRepository "SAM\$name\$name.csproj") $heads[$SamRepository] @() }
+Build (Join-Path $ValidationRepository 'SAM.Analytical.Benchmark\SAM.Analytical.Benchmark.csproj') $heads[$ValidationRepository] @()
+Build $benchmarkProject $heads[$RunnerRepository] @('-p:Platform=x64')
+Build $harnessProject $heads[$RunnerRepository] @('-p:Platform=x64')
+
+foreach ($repository in @($heads.Keys)) {
+ if ((Clean-Head $repository) -ne $heads[$repository]) { Refuse "the HEAD of $repository moved during the build" }
+}
+
+$benchmarkDir = Split-Path $benchmarkExe
+$harnessDir = Split-Path $harnessExe
+$stamped = New-Object System.Collections.Generic.List[object]
+$declared = @(
+ [ordered]@{ path = $benchmarkExe; commit = $heads[$RunnerRepository]; compiled = $false },
+ [ordered]@{ path = [IO.Path]::ChangeExtension($benchmarkExe, '.dll'); commit = $heads[$RunnerRepository]; compiled = $true },
+ [ordered]@{ path = $harnessExe; commit = $heads[$RunnerRepository]; compiled = $false },
+ [ordered]@{ path = [IO.Path]::ChangeExtension($harnessExe, '.dll'); commit = $heads[$RunnerRepository]; compiled = $true },
+ [ordered]@{ path = (Join-Path $harnessDir 'SAM.Analytical.OpenStudio.ResultsReadBack.dll'); commit = $heads[$RunnerRepository]; compiled = $true },
+ [ordered]@{ path = (Join-Path $harnessDir 'SAM.Analytical.OpenStudio.InputReadBack.dll'); commit = $heads[$RunnerRepository]; compiled = $true },
+ [ordered]@{ path = (Join-Path $harnessDir 'SAM.Analytical.OpenStudio.dll'); commit = $heads[$RunnerRepository]; compiled = $true },
+ # The SAM_Tas evaluation core is compiled by the harness build (project reference), so it carries the runner's stamp;
+ # its source commit is the clean SAM_Tas HEAD recorded under commits.
+ [ordered]@{ path = (Join-Path $harnessDir 'SAM.Analytical.InputEquivalence.dll'); commit = $heads[$RunnerRepository]; compiled = $true },
+ [ordered]@{ path = (Join-Path $ValidationRepository 'build\SAM.Analytical.Benchmark.dll'); commit = $heads[$ValidationRepository]; compiled = $true }
+) + @($samProjects | ForEach-Object { [ordered]@{ path = (Join-Path $samBuild "$_.dll"); commit = $heads[$SamRepository]; compiled = $true } })
+foreach ($artifact in $declared) {
+ $item = Get-Item -LiteralPath $artifact.path -ErrorAction SilentlyContinue
+ if (-not $item) { Refuse "the build did not produce $($artifact.path)" }
+ if ($item.LastWriteTimeUtc -lt $started) { Refuse "$($artifact.path) was not written by this build" }
+ if ($artifact.compiled) {
+ if (-not (Test-Stamp $artifact.path $artifact.commit)) { Refuse "$($artifact.path) does not carry the stamp +$($artifact.commit)" }
+ $stamped.Add([ordered]@{ path = $item.FullName; commit = $artifact.commit })
+ }
+}
+
+foreach ($library in @(Get-ChildItem -LiteralPath $benchmarkDir, $harnessDir -Filter 'SAM.*.dll' | Where-Object { Test-Path -LiteralPath (Join-Path $samBuild $_.Name) })) {
+ if ($samProjects -notcontains $library.BaseName) { Refuse "$($library.FullName) is a SAM\build library this build does not rebuild" }
+ if ((Sha $library.FullName) -ne (Sha (Join-Path $samBuild $library.Name))) { Refuse "$($library.FullName) differs from $(Join-Path $samBuild $library.Name)" }
+}
+
+$files = @(Get-ChildItem -LiteralPath $benchmarkDir, $harnessDir -File | Where-Object { $_.Extension -in '.dll', '.exe' } | Sort-Object FullName | ForEach-Object { [ordered]@{ path = $_.FullName; sha256 = (Sha $_.FullName) } })
+
+[ordered]@{
+ schema = 's11-build-receipt/1'
+ note = 'Written by Build-S11-Receipt.ps1 after a successful controlled build of clean checkouts; Run-S11-Results.ps1 verifies it and never writes it.'
+ startedUtc = $started.ToString('o'); completedUtc = (Get-Date).ToUniversalTime().ToString('o'); machine = $env:COMPUTERNAME
+ msbuild = $MSBuild; commands = $commands
+ commits = @($heads.Keys | ForEach-Object { [ordered]@{ repository = $_; commit = $heads[$_]; branch = (Git $_ rev-parse --abbrev-ref HEAD | Out-String).Trim() } })
+ benchmarkExe = $benchmarkExe; harnessExe = $harnessExe
+ stamped = $stamped; files = $files
+} | ConvertTo-Json -Depth 5 | Set-Content -Encoding utf8 $BuildReceipt
+
+Write-Host "receipt: $BuildReceipt ($($files.Count) files)"
+exit 0
diff --git a/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack/Classes/RunLayout.cs b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack/Classes/RunLayout.cs
new file mode 100644
index 0000000..0d790ce
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack/Classes/RunLayout.cs
@@ -0,0 +1,82 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace SAM.Analytical.OpenStudio.ResultsReadBack
+{
+ ///
+ /// The files of one S11 run directory: b1a.json and sam.json written by benchmark-openstudio, the EnergyPlus run folder
+ /// somewhere below it (exactly one eplusout.sql, with eplusout.eso and in.idf beside it), and the S11 outputs.
+ ///
+ public sealed class RunLayout
+ {
+ public const string B1aFile = "b1a.json";
+ public const string SamResultsFile = "sam.json";
+ public const string ReferenceFile = "reference.json";
+ public const string SubjectFile = "subject.json";
+ public const string NegativeFile = "negative.json";
+ public const string CompareDirectory = "compare";
+
+ public string Directory { get; private set; }
+ public string B1a { get; private set; }
+ public string SamResults { get; private set; }
+ public string Sql { get; private set; }
+ public string Eso { get; private set; }
+ public string Idf { get; private set; }
+ public string Reference => Path.Combine(Directory, ReferenceFile);
+ public string Subject => Path.Combine(Directory, SubjectFile);
+ public string Compare => Path.Combine(Directory, CompareDirectory);
+
+ /// The layout of ; lists every missing or ambiguous file.
+ public static RunLayout Resolve(string directory, out List problems)
+ {
+ problems = new List();
+ RunLayout result = new RunLayout { Directory = Path.GetFullPath(directory) };
+ if (!System.IO.Directory.Exists(result.Directory))
+ {
+ problems.Add("run directory does not exist: " + result.Directory);
+ return result;
+ }
+
+ result.B1a = Path.Combine(result.Directory, B1aFile);
+ result.SamResults = Path.Combine(result.Directory, SamResultsFile);
+ foreach (string file in new[] { result.B1a, result.SamResults })
+ {
+ if (!File.Exists(file))
+ {
+ problems.Add("missing " + file);
+ }
+ }
+
+ List sql = System.IO.Directory.GetFiles(result.Directory, "eplusout.sql", SearchOption.AllDirectories).OrderBy(x => x, System.StringComparer.Ordinal).ToList();
+ if (sql.Count != 1)
+ {
+ problems.Add(sql.Count + " eplusout.sql files below " + result.Directory + " (exactly one is required)");
+ return result;
+ }
+
+ result.Sql = sql[0];
+ string runFolder = Path.GetDirectoryName(result.Sql);
+ result.Eso = Path.Combine(runFolder, "eplusout.eso");
+ result.Idf = Path.Combine(runFolder, "in.idf");
+ foreach (string file in new[] { result.Eso, result.Idf })
+ {
+ if (!File.Exists(file))
+ {
+ problems.Add("missing " + file + " beside eplusout.sql");
+ }
+ }
+
+ int esoCount = System.IO.Directory.GetFiles(result.Directory, "eplusout.eso", SearchOption.AllDirectories).Length;
+ if (esoCount != 1)
+ {
+ problems.Add(esoCount + " eplusout.eso files below " + result.Directory + " (exactly one is required)");
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack/Classes/SubjectDocument.cs b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack/Classes/SubjectDocument.cs
new file mode 100644
index 0000000..b5b0900
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.ResultsReadBack/Classes/SubjectDocument.cs
@@ -0,0 +1,159 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json.Nodes;
+
+namespace SAM.Analytical.OpenStudio.ResultsReadBack
+{
+ /// One SAM SpaceSimulationResult as the subject saw it (parameter values, identity, relation).
+ public sealed class SubjectResult
+ {
+ public Guid ResultGuid { get; set; }
+ public string Name { get; set; }
+ public string Reference { get; set; }
+ public string Source { get; set; }
+ public string LoadType { get; set; }
+ public double? Load { get; set; }
+ public int? LoadIndex { get; set; }
+ public double? UnmetHours { get; set; }
+ public double? DesignLoad { get; set; }
+
+ /// The SAM space the result is related to in the cluster; null when unattached.
+ public Guid? AttachedTo { get; set; }
+
+ public bool IsDesignLoad => DesignLoad.HasValue;
+ public bool IsAnnual => !DesignLoad.HasValue && Load.HasValue;
+ }
+
+ /// The report keys the production naming gives one SAM space (cross-checked against the native identity).
+ public sealed class ProductionKey
+ {
+ public Guid SpaceGuid { get; set; }
+ public string SpaceName { get; set; }
+ public string IdealLoads { get; set; }
+ public string ThermalZone { get; set; }
+ }
+
+ ///
+ /// subject.json: what the code under test produced, captured by the CLI's subject step (the only step that runs
+ /// production code): the closure of the captured producer objects onto the produced B1a, the production report keys,
+ /// and the SAM_UI/Grasshopper import (Modify.AddResults) of the same SQL on a fresh copy of the model.
+ ///
+ public sealed class SubjectDocument
+ {
+ public const string CurrentSchema = "s11-subject/1";
+
+ public string Schema { get; set; } = CurrentSchema;
+ public int ProcessId { get; set; }
+ public DateTime ProcessStartedUtc { get; set; }
+ public DateTime StartedUtc { get; set; }
+ public DateTime FinishedUtc { get; set; }
+ public string SubjectAssemblySha256 { get; set; }
+ public string ProducerAssemblySha256 { get; set; }
+ public string ImporterAssemblySha256 { get; set; }
+ public string ModelSha256 { get; set; }
+ public string B1aSha256 { get; set; }
+ public string SamResultsSha256 { get; set; }
+ public string SqlSha256Before { get; set; }
+ public string SqlSha256After { get; set; }
+
+ /// True when the captured objects re-mapped offline give byte-identical B1a model and spaces sections.
+ public bool ClosureIdentical { get; set; }
+ public string ClosureDetail { get; set; }
+ public string ClosureModelSha256Produced { get; set; }
+ public string ClosureModelSha256Regenerated { get; set; }
+ public string ClosureSpacesSha256Produced { get; set; }
+ public string ClosureSpacesSha256Regenerated { get; set; }
+
+ public List ProductionKeys { get; set; } = new List();
+ public List SamI { get; set; } = new List();
+ public List SamIDiagnostics { get; set; } = new List