diff --git a/WPF/SAM.Analytical.UI.WPF.Tests/CanonicalSnapshotTests.cs b/WPF/SAM.Analytical.UI.WPF.Tests/CanonicalSnapshotTests.cs
new file mode 100644
index 00000000..cd174ed0
--- /dev/null
+++ b/WPF/SAM.Analytical.UI.WPF.Tests/CanonicalSnapshotTests.cs
@@ -0,0 +1,376 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020-2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+#nullable enable
+
+using SAM.Analytical.Benchmark;
+using SAM.Core;
+using SAM.Geometry.UI;
+using SAM.Weather;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json.Nodes;
+using System.Threading;
+using Xunit;
+
+namespace SAM.Analytical.UI.WPF.Tests
+{
+ ///
+ /// S04 (finding F7 of SAM_UI#239): the frozen simulation snapshot - the file both Run Simulation > Both producers hash and
+ /// OpenStudio Simulation converts - leaves out the one UI-only parameter SAM_UI keeps on a model, "UI Geometry Settings" (its
+ /// views, regenerated with new GUIDs every time a model is opened), so the same engineering model gets the same snapshot and the
+ /// same canonical model hash in every session, while every engineering edit still changes it.
+ ///
+ /// The canonical hash is computed here exactly as the producers compute it (load the snapshot into an AnalyticalModel, then
+ /// SAM_Validation's canonical JSON v1 of its JSON); the models are saved, reopened through as the
+ /// window opens them, and frozen by the real .
+ ///
+ ///
+ [Collection(WpfCollection.Name)]
+ public class CanonicalSnapshotTests : IDisposable
+ {
+ private readonly string directory;
+
+ public CanonicalSnapshotTests()
+ {
+ directory = Path.Combine(Path.GetTempPath(), "SAM-UI-S04-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ foreach (string path in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories))
+ {
+ File.SetAttributes(path, FileAttributes.Normal);
+ }
+
+ Directory.Delete(directory, true);
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+
+ /// A. The same saved model, opened twice as the window opens it: the UI views differ, the snapshots and hashes do not.
+ [WpfFact]
+ public void SameSavedModel_OpenedTwice_GivesTheSameSnapshotAndCanonicalHash()
+ {
+ string path = Save(Model(), "model.json");
+
+ AnalyticalModel opened_1 = Open(path);
+ AnalyticalModel opened_2 = Open(path);
+
+ //The F7 precondition: opening regenerated the UI view state, with different GUIDs each time.
+ Assert.NotEqual(ViewGuids(opened_1), ViewGuids(opened_2));
+
+ string snapshot_1 = Freeze(opened_1, "1");
+ string snapshot_2 = Freeze(opened_2, "2");
+
+ Assert.Equal(Query.Sha256(snapshot_1), Query.Sha256(snapshot_2));
+ Assert.Equal(ProducerCanonicalHash(snapshot_1), ProducerCanonicalHash(snapshot_2));
+ }
+
+ /// B. The same model with different UI-only view settings (new GUIDs, another view, a hidden object): same hash.
+ [WpfFact]
+ public void DifferentUiViewState_GivesTheSameCanonicalHash()
+ {
+ AnalyticalModel analyticalModel_1 = Model();
+ AnalyticalModel analyticalModel_2 = new AnalyticalModel(analyticalModel_1);
+ analyticalModel_1.SetValue(AnalyticalModelParameter.UIGeometrySettings, ViewState("3D View"));
+ analyticalModel_2.SetValue(AnalyticalModelParameter.UIGeometrySettings, ViewState("3D View", "Plan"));
+
+ Assert.NotEqual(Core.Convert.ToString(analyticalModel_1), Core.Convert.ToString(analyticalModel_2));
+ Assert.Equal(ProducerCanonicalHash(Freeze(analyticalModel_1, "1")), ProducerCanonicalHash(Freeze(analyticalModel_2, "2")));
+ }
+
+ /// C. Every kind of engineering edit still changes the snapshot and the canonical hash.
+ [WpfTheory]
+ [InlineData("construction")]
+ [InlineData("space")]
+ [InlineData("weather")]
+ [InlineData("modelParameter")]
+ [InlineData("otherUiAssemblyParameterName")]
+ public void AnEngineeringEdit_ChangesTheCanonicalHash(string edit)
+ {
+ AnalyticalModel analyticalModel = Model();
+ analyticalModel.SetValue(AnalyticalModelParameter.UIGeometrySettings, ViewState("3D View"));
+ string hash_Before = ProducerCanonicalHash(Freeze(analyticalModel, "before"));
+
+ AnalyticalModel analyticalModel_Edited = Edit(analyticalModel, edit);
+ analyticalModel_Edited.SetValue(AnalyticalModelParameter.UIGeometrySettings, ViewState("3D View"));
+
+ Assert.NotEqual(hash_Before, ProducerCanonicalHash(Freeze(analyticalModel_Edited, "after")));
+ }
+
+ ///
+ /// The snapshot is the source model less exactly one parameter: nothing else is dropped, reordered or regenerated (the omission
+ /// direction of the mutation check).
+ ///
+ [WpfFact]
+ public void Snapshot_IsTheModelLessOnlyTheUiViewState()
+ {
+ AnalyticalModel analyticalModel = Model();
+ analyticalModel.SetValue(AnalyticalModelParameter.UIGeometrySettings, ViewState("3D View"));
+
+ //Expected: the model's JSON less the one parameter, and less its set because that leaves it empty.
+ JsonObject expected = analyticalModel.ToJsonObject();
+ JsonArray parameterSets = expected["ParameterSets"]!.AsArray();
+ JsonObject uiParameterSet = parameterSets.Select(x => x!.AsObject()).Single(x => (string)x["Name"]! == "SAM.Analytical.UI");
+ JsonArray parameters = uiParameterSet["Parameters"]!.AsArray();
+ parameters.Remove(parameters.Single(x => (string)x!["Name"]! == "UI Geometry Settings"));
+ Assert.Empty(parameters);
+ parameterSets.Remove(uiParameterSet);
+ int count_ParameterSets = parameterSets.Count;
+
+ AnalyticalModel snapshot = Core.Convert.ToSAM(Freeze(analyticalModel, "snapshot")).Single();
+
+ Assert.Equal(new AnalyticalModel(expected).ToJsonObject().ToJsonString(), snapshot.ToJsonObject().ToJsonString());
+ Assert.False(snapshot.HasValue(AnalyticalModelParameter.UIGeometrySettings));
+ Assert.True(snapshot.HasValue(Analytical.AnalyticalModelParameter.WeatherData));
+ Assert.Equal(count_ParameterSets, snapshot.GetParameterSets().Count);
+ }
+
+ ///
+ /// A model whose view state was removed keeps an empty SAM.Analytical.UI set - written with no "Parameters" at all, and still carrying
+ /// the per-build GUID. It is left out like an emptied one, so it freezes exactly as a model that never had the set.
+ ///
+ [WpfFact]
+ public void AnAlreadyEmptyUiParameterSet_IsLeftOutToo()
+ {
+ AnalyticalModel analyticalModel_Never = Model();
+ AnalyticalModel analyticalModel = new AnalyticalModel(analyticalModel_Never);
+ analyticalModel.SetValue(AnalyticalModelParameter.UIGeometrySettings, ViewState("3D View"));
+ Assert.True(analyticalModel.RemoveValue(AnalyticalModelParameter.UIGeometrySettings));
+ Assert.Contains(analyticalModel.ToJsonObject()["ParameterSets"]!.AsArray(), x => (string)x!["Name"]! == "SAM.Analytical.UI");
+
+ Assert.Equal(File.ReadAllBytes(Freeze(analyticalModel_Never, "never")), File.ReadAllBytes(Freeze(analyticalModel, "emptied")));
+ }
+
+ /// A model with no view state is frozen byte for byte as before S04 (Core.Convert.ToFile's JSON).
+ [WpfFact]
+ public void Snapshot_OfAModelWithNoViewState_IsTheSameBytesAsBefore()
+ {
+ AnalyticalModel analyticalModel = Model();
+ Assert.False(analyticalModel.HasValue(AnalyticalModelParameter.UIGeometrySettings));
+
+ string path_Before = Path.Combine(directory, "pre-s04.json");
+ Assert.True(Core.Convert.ToFile(analyticalModel, path_Before));
+
+ Assert.Equal(File.ReadAllBytes(path_Before), File.ReadAllBytes(Freeze(analyticalModel, "s04")));
+ }
+
+ ///
+ /// The view state's set carries the SAM.Analytical.UI assembly's GUID - its module version id, new with every build of SAM_UI. Two
+ /// builds' copies of one model (simulated by giving that set another GUID) still give the same snapshot and canonical hash.
+ ///
+ [WpfFact]
+ public void ViewStateFromAnotherBuildOfSamUi_GivesTheSameCanonicalHash()
+ {
+ AnalyticalModel analyticalModel = Model();
+ analyticalModel.SetValue(AnalyticalModelParameter.UIGeometrySettings, ViewState("3D View"));
+
+ JsonObject jsonObject = analyticalModel.ToJsonObject();
+ JsonObject uiParameterSet = jsonObject["ParameterSets"]!.AsArray().Select(x => x!.AsObject()).Single(x => (string)x["Name"]! == "SAM.Analytical.UI");
+ Assert.NotEqual(Guid.Empty.ToString(), (string)uiParameterSet["Guid"]!);
+ uiParameterSet["Guid"] = Guid.NewGuid().ToString();
+ AnalyticalModel analyticalModel_OtherBuild = new AnalyticalModel(jsonObject);
+
+ string snapshot_1 = Freeze(analyticalModel, "1");
+ string snapshot_2 = Freeze(analyticalModel_OtherBuild, "2");
+
+ Assert.Equal(Query.Sha256(snapshot_1), Query.Sha256(snapshot_2));
+ Assert.Equal(ProducerCanonicalHash(snapshot_1), ProducerCanonicalHash(snapshot_2));
+ }
+
+ /// G. Freezing never changes the model given - not its view state, not anything else.
+ [WpfFact]
+ public void Freezing_DoesNotChangeTheSourceModel()
+ {
+ AnalyticalModel analyticalModel = Model();
+ analyticalModel.SetValue(AnalyticalModelParameter.UIGeometrySettings, ViewState("3D View"));
+ string json_Before = Core.Convert.ToString(analyticalModel);
+
+ Freeze(analyticalModel, "1");
+
+ Assert.Equal(json_Before, Core.Convert.ToString(analyticalModel));
+ Assert.True(analyticalModel.HasValue(AnalyticalModelParameter.UIGeometrySettings));
+ }
+
+ ///
+ /// D, E. Two Both runs of the same model opened in two sessions feed their producers byte-identical snapshots: the snapshot hash
+ /// in each manifest is its file's, and both producers of one run read that one file.
+ ///
+ [WpfFact]
+ public void TwoBothRuns_OfTheSameModelOpenedTwice_FeedIdenticalSnapshots()
+ {
+ string path = Save(Model(), "model.json");
+ string epw = Path.Combine(directory, "test.epw");
+ File.WriteAllText(epw, "LOCATION,Test,,,TMY,000000,0,0,0,0\n");
+
+ List runs = new List();
+ foreach (string session in new[] { "session-1", "session-2" })
+ {
+ List models = new List();
+ EngineComparisonRun run = Modify.RunEngineComparison(Open(path), Path.Combine(directory, session), epw, Producer("benchmark-tas.exe"), Producer("benchmark-openstudio.exe"), path, null, CancellationToken.None, (producerRun, workDirectory, cancellationToken) =>
+ {
+ models.Add(producerRun.Arguments[producerRun.Arguments.IndexOf("--model") + 1]);
+ return 5;
+ });
+
+ Assert.Equal(new[] { run.SnapshotPath, run.SnapshotPath }, models);
+ Assert.Equal(Query.Sha256(run.SnapshotPath), run.SnapshotSha256);
+ Assert.Equal(run.SnapshotSha256, (string)JsonNode.Parse(File.ReadAllText(run.ManifestPath!))!["snapshot"]!["sha256"]!);
+ runs.Add(run);
+ }
+
+ Assert.NotEqual(runs[0].SnapshotPath, runs[1].SnapshotPath);
+ Assert.Equal(runs[0].SnapshotSha256, runs[1].SnapshotSha256);
+ Assert.Equal(ProducerCanonicalHash(runs[0].SnapshotPath!), ProducerCanonicalHash(runs[1].SnapshotPath!));
+ }
+
+ /// F. The OpenStudio result model, built from the frozen copy, still carries the open model's view state, as before S04.
+ [WpfFact]
+ public void OpenStudioResultModel_KeepsTheSourceViewState()
+ {
+ AnalyticalModel analyticalModel = Model();
+ UIGeometrySettings uIGeometrySettings = ViewState("3D View", "Plan");
+ analyticalModel.SetValue(AnalyticalModelParameter.UIGeometrySettings, uIGeometrySettings);
+
+ AnalyticalModel analyticalModel_Frozen = Core.Convert.ToSAM(Freeze(analyticalModel, "frozen")).Single();
+ Assert.False(analyticalModel_Frozen.HasValue(AnalyticalModelParameter.UIGeometrySettings));
+
+ //Copied at freeze time: a view edited afterwards on the open model does not reach the result model.
+ UIGeometrySettings? viewState = Query.ViewState(analyticalModel);
+ analyticalModel.SetValue(AnalyticalModelParameter.UIGeometrySettings, ViewState("Edited after the freeze"));
+
+ AnalyticalModel analyticalModel_Result = Modify.WithViewState(analyticalModel_Frozen, viewState);
+
+ Assert.True(analyticalModel_Result.TryGetValue(AnalyticalModelParameter.UIGeometrySettings, out UIGeometrySettings uIGeometrySettings_Result));
+ Assert.Equal(uIGeometrySettings.ToJsonObject().ToJsonString(), uIGeometrySettings_Result.ToJsonObject().ToJsonString());
+ Assert.NotSame(uIGeometrySettings, uIGeometrySettings_Result);
+ }
+
+ // ---------------------------------------------------------------- helpers
+
+ /// As benchmark-tas and benchmark-openstudio compute canonicalModelHash: the snapshot loaded, then canonical JSON v1 of its JSON.
+ private static string ProducerCanonicalHash(string path)
+ {
+ AnalyticalModel analyticalModel = Core.Convert.ToSAM(path).First();
+ return BenchmarkCanonicalJson.ComputeSha256(analyticalModel.ToJsonObject().ToJsonString());
+ }
+
+ private string Freeze(AnalyticalModel analyticalModel, string name)
+ {
+ string path = Path.Combine(directory, "snapshot-" + name + ".json");
+ Assert.NotNull(analyticalModel.FreezeAnalyticalModel(path));
+ return path;
+ }
+
+ private string Save(AnalyticalModel analyticalModel, string name)
+ {
+ string path = Path.Combine(directory, name);
+ Assert.True(Core.Convert.ToFile(new IJSAMObject[] { analyticalModel }, path));
+ return path;
+ }
+
+ ///
+ /// Opens a saved model as the window does: , then what AnalyticalWindow.SetDefaultViewSettings
+ /// does on Opened - a model with no views gets UI.Query.DefaultViewSettings, whose GUID is new every time (F7).
+ ///
+ private static AnalyticalModel Open(string path)
+ {
+ UIAnalyticalModel uIAnalyticalModel = new UIAnalyticalModel(path);
+ Assert.True(uIAnalyticalModel.Open());
+
+ AnalyticalModel analyticalModel = uIAnalyticalModel.JSAMObject;
+ if (!analyticalModel.TryGetValue(AnalyticalModelParameter.UIGeometrySettings, out UIGeometrySettings uIGeometrySettings) || uIGeometrySettings == null)
+ {
+ uIGeometrySettings = new UIGeometrySettings();
+ }
+
+ if (uIGeometrySettings.GetViewSettings() is not { Count: > 0 })
+ {
+ ViewSettings viewSettings = UI.Query.DefaultViewSettings(analyticalModel);
+ uIGeometrySettings.AddViewSettings(viewSettings);
+ uIGeometrySettings.ActiveGuid = viewSettings.Guid;
+ analyticalModel.SetValue(AnalyticalModelParameter.UIGeometrySettings, uIGeometrySettings);
+ }
+
+ return analyticalModel;
+ }
+
+ private string Producer(string fileName)
+ {
+ string path = Path.Combine(directory, "producers", fileName);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, string.Empty);
+ return path;
+ }
+
+ private static string ViewGuids(AnalyticalModel analyticalModel)
+ {
+ Assert.True(analyticalModel.TryGetValue(AnalyticalModelParameter.UIGeometrySettings, out UIGeometrySettings uIGeometrySettings));
+ return uIGeometrySettings.ToJsonObject().ToJsonString();
+ }
+
+ /// A realistic multizone model: the Part O fixture's flats (spaces, panels, apertures, constructions, internal conditions), with weather.
+ private static AnalyticalModel Model()
+ {
+ AnalyticalModel analyticalModel = PartOMixedDesignFixture.Baseline(2);
+ analyticalModel.SetValue(Analytical.AnalyticalModelParameter.WeatherData, new WeatherData("Fixture", "Fixture", 51.5, -0.1, 25));
+ return analyticalModel;
+ }
+
+ private static AnalyticalModel Edit(AnalyticalModel analyticalModel, string edit)
+ {
+ AnalyticalModel result = new AnalyticalModel(analyticalModel, true);
+ switch (edit)
+ {
+ case "construction":
+ AdjacencyCluster adjacencyCluster = result.AdjacencyCluster;
+ Construction construction = new Construction(Guid.NewGuid(), "Thicker", new List { new ConstructionLayer("Plasterboard", 0.2) });
+ Panel panel = adjacencyCluster.GetPanels().First();
+ adjacencyCluster.AddObject(Analytical.Create.Panel(panel, construction));
+ return new AnalyticalModel(result, adjacencyCluster);
+ case "space":
+ AdjacencyCluster adjacencyCluster_Space = result.AdjacencyCluster;
+ Space space = adjacencyCluster_Space.GetSpaces().First();
+ Space space_Edited = new Space(space);
+ space_Edited.SetValue(SpaceParameter.Volume, 123.0);
+ adjacencyCluster_Space.AddObject(space_Edited);
+ return new AnalyticalModel(result, adjacencyCluster_Space);
+ case "weather":
+ result.SetValue(Analytical.AnalyticalModelParameter.WeatherData, new WeatherData("Fixture", "Fixture", 52.5, -0.1, 25));
+ return result;
+ case "modelParameter":
+ result.SetValue(Analytical.AnalyticalModelParameter.NorthAngle, 0.5);
+ return result;
+ case "otherUiAssemblyParameterName":
+ //Anything else stored in the SAM.Analytical.UI set is kept: only the named view state is UI-only.
+ result.SetValue(AnalyticalModelParameter.UIGeometrySettings, ViewState("3D View"));
+ Assert.True(result.GetParameterSet(typeof(AnalyticalModelParameter).Assembly).Add("Another UI parameter", "kept"));
+ return result;
+ }
+
+ throw new ArgumentOutOfRangeException(nameof(edit));
+ }
+
+ private static UIGeometrySettings ViewState(params string[] names)
+ {
+ UIGeometrySettings result = new UIGeometrySettings();
+ foreach (string name in names)
+ {
+ result.AddViewSettings(new ThreeDimensionalViewSettings(Guid.NewGuid(), name, null, null, null));
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/WPF/SAM.Analytical.UI.WPF.Tests/OpenStudioSimulationTests.cs b/WPF/SAM.Analytical.UI.WPF.Tests/OpenStudioSimulationTests.cs
index e92ef2f1..48ac00a1 100644
--- a/WPF/SAM.Analytical.UI.WPF.Tests/OpenStudioSimulationTests.cs
+++ b/WPF/SAM.Analytical.UI.WPF.Tests/OpenStudioSimulationTests.cs
@@ -239,6 +239,12 @@ public void RealRun_SimulatesAFrozenCopy_AndSavesAResultModelWithOnlyOpenStudioR
}
AnalyticalModel analyticalModel = Core.Convert.ToSAM(modelPath).First();
+
+ //The view state SAM_UI keeps on an open model (S04: left out of the snapshot, given back to the result model).
+ SAM.Geometry.UI.UIGeometrySettings uIGeometrySettings = new SAM.Geometry.UI.UIGeometrySettings();
+ uIGeometrySettings.AddViewSettings(SAM.Analytical.UI.Query.DefaultViewSettings(analyticalModel));
+ analyticalModel.SetValue(SAM.Analytical.UI.AnalyticalModelParameter.UIGeometrySettings, uIGeometrySettings);
+
string json_Before = Core.Convert.ToString(analyticalModel);
OpenStudioSimulationRun run = analyticalModel.RunOpenStudioSimulation(directory, epwPath, modelPath);
@@ -258,6 +264,13 @@ public void RealRun_SimulatesAFrozenCopy_AndSavesAResultModelWithOnlyOpenStudioR
Assert.All(results, x => Assert.Equal(AnalyticalModelSimulationResult.OpenStudioSource, x.Source));
Assert.Single(result.GetAnalyticalModelSimulationResults());
+ //S04: the snapshot carries no view state; the result model carries the open model's, as before.
+ Assert.False(Core.Convert.ToSAM(run.SnapshotPath).First().HasValue(SAM.Analytical.UI.AnalyticalModelParameter.UIGeometrySettings));
+ Assert.True(result.TryGetValue(SAM.Analytical.UI.AnalyticalModelParameter.UIGeometrySettings, out SAM.Geometry.UI.UIGeometrySettings uIGeometrySettings_Result));
+ //Compared after the same JSON round trip the saved file makes: UIGeometrySettings itself does not round-trip its default
+ //view's appearance dictionary, before S04 as after.
+ Assert.Equal(new SAM.Geometry.UI.UIGeometrySettings(JsonNode.Parse(uIGeometrySettings.ToJsonObject().ToJsonString()).AsObject()).ToJsonObject().ToJsonString(), uIGeometrySettings_Result.ToJsonObject().ToJsonString());
+
//S03: the provenance of a real run names what actually ran - the CLI by hash, EnergyPlus by its own eplusout.err.
JsonObject provenance = JsonNode.Parse(File.ReadAllText(run.ManifestPath))["provenance"].AsObject();
JsonObject engine = provenance["engine"].AsObject();
diff --git a/WPF/SAM.Analytical.UI.WPF/Modify/RunOpenStudioSimulation.cs b/WPF/SAM.Analytical.UI.WPF/Modify/RunOpenStudioSimulation.cs
index 0de519df..f0e06eea 100644
--- a/WPF/SAM.Analytical.UI.WPF/Modify/RunOpenStudioSimulation.cs
+++ b/WPF/SAM.Analytical.UI.WPF/Modify/RunOpenStudioSimulation.cs
@@ -76,9 +76,20 @@ public static OpenStudioSimulationRun RunOpenStudioSimulation(this AnalyticalMod
result.SnapshotSha256 = Query.Sha256(result.SnapshotPath);
+ //S04: the open model's view state, copied at the moment of the snapshot, for the result model; never a reason to fail.
+ Geometry.UI.UIGeometrySettings? viewState = null;
+ try
+ {
+ viewState = Query.ViewState(analyticalModel);
+ }
+ catch (Exception exception)
+ {
+ result.Diagnostics.Add("Warning: the view state could not be copied for the result model: " + exception.GetType().Name + ": " + exception.Message);
+ }
+
cancellationToken.ThrowIfCancellationRequested();
- RunOpenStudioSimulation_Run(result, analyticalModel_Frozen, epwPath, progress, cancellationToken);
+ RunOpenStudioSimulation_Run(result, analyticalModel_Frozen, viewState, epwPath, progress, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -102,15 +113,31 @@ public static OpenStudioSimulationRun RunOpenStudioSimulation(this AnalyticalMod
///
/// Writes to and returns the model read back from that file: an
/// independent copy whose content is exactly the file's. Null when either step fails.
+ ///
+ /// The file is of the model (S04): everything except SAM_UI's view state, which is
+ /// regenerated on every open and would otherwise give the same model a different snapshot and canonical hash in each session.
+ /// A JSON snapshot is written exactly as Core.Convert.ToFile writes a JSON file, so a model with no view state gives the
+ /// same bytes as before S04.
+ ///
///
public static AnalyticalModel? FreezeAnalyticalModel(this AnalyticalModel analyticalModel, string path)
{
- if (analyticalModel == null || string.IsNullOrWhiteSpace(path))
+ if (analyticalModel == null || string.IsNullOrWhiteSpace(path) || !System.IO.Directory.Exists(Path.GetDirectoryName(path)))
+ {
+ return null;
+ }
+
+ JsonObject? jsonObject = Query.SimulationSnapshotJsonObject(analyticalModel);
+ if (jsonObject == null)
{
return null;
}
- if (!Core.Convert.ToFile(analyticalModel, path))
+ if (Core.Query.SAMFileType(path) == Core.SAMFileType.Json)
+ {
+ File.WriteAllText(path, new JsonArray(jsonObject).ToJsonString(Core.Convert.SerializerOptions(Core.Formatting.Indented)));
+ }
+ else if (!Core.Convert.ToFile(new AnalyticalModel(jsonObject), path))
{
return null;
}
@@ -154,7 +181,7 @@ public static AnalyticalModel AddOpenStudioResults(this AnalyticalModel analytic
// Its own method, never inlined: every OpenStudio type is reached here, so a missing SAM_OpenStudio or OpenStudio assembly fails
// this method's compilation inside the caller's try, and is recorded on the run like any other failure.
[MethodImpl(MethodImplOptions.NoInlining)]
- private static void RunOpenStudioSimulation_Run(OpenStudioSimulationRun openStudioSimulationRun, AnalyticalModel analyticalModel_Frozen, string? epwPath, IProgress? progress, CancellationToken cancellationToken)
+ private static void RunOpenStudioSimulation_Run(OpenStudioSimulationRun openStudioSimulationRun, AnalyticalModel analyticalModel_Frozen, Geometry.UI.UIGeometrySettings? viewState, string? epwPath, IProgress? progress, CancellationToken cancellationToken)
{
openStudioSimulationRun.SamOpenStudioVersion = typeof(OpenStudioConversionResult).Assembly.GetCustomAttribute()?.InformationalVersion ?? typeof(OpenStudioConversionResult).Assembly.GetName().Version?.ToString();
@@ -265,6 +292,17 @@ private static void RunOpenStudioSimulation_Run(OpenStudioSimulationRun openStud
openStudioSimulationRun.ResultDiagnostics.AddRange(diagnostics);
}
+ //S04: the snapshot left the open model's view state out; the result model, which a person opens, gets back the copy taken
+ //with the snapshot. A failure here is a warning, never a failed run.
+ try
+ {
+ WithViewState(analyticalModel_Result, viewState);
+ }
+ catch (Exception exception)
+ {
+ openStudioSimulationRun.Diagnostics.Add("Warning: the result model was saved without the view state: " + exception.GetType().Name + ": " + exception.Message);
+ }
+
string resultModelPath = Path.Combine(openStudioSimulationRun.Directory!, Query.OpenStudioResultModelFileName(analyticalModel_Frozen.Name));
if (!Core.Convert.ToFile(analyticalModel_Result, resultModelPath))
{
diff --git a/WPF/SAM.Analytical.UI.WPF/Query/SimulationSnapshot.cs b/WPF/SAM.Analytical.UI.WPF/Query/SimulationSnapshot.cs
new file mode 100644
index 00000000..e06a9810
--- /dev/null
+++ b/WPF/SAM.Analytical.UI.WPF/Query/SimulationSnapshot.cs
@@ -0,0 +1,105 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020-2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using System.Linq;
+using System.Text.Json.Nodes;
+
+namespace SAM.Analytical.UI.WPF
+{
+ public static partial class Query
+ {
+ ///
+ /// The JSON of as a frozen simulation snapshot holds it (roadmap S04, finding F7 of SAM_UI#239):
+ /// the model's own JSON without UI Geometry Settings, the view state SAM_UI keeps on a model (its views, their visibility,
+ /// cameras and legends).
+ ///
+ /// That parameter never reaches a simulation, and SAM_UI regenerates it - with new GUIDs - every time a model is opened, so a
+ /// snapshot that carried it gave the same engineering model a different snapshot hash and canonical model hash in every session.
+ /// It is the only parameter left out; the same parameter is already left out of SimulationResultProvenance.Fingerprint in
+ /// SAM. Its parameter set (SAM.Analytical.UI) goes too, but only when that leaves it empty: an empty set carries nothing but its
+ /// GUID, which is the assembly's module version id and so changes with every build of SAM_UI. Anything else stored in that set
+ /// stays, as does every other parameter set.
+ ///
+ ///
+ /// The model given is never changed: this edits the freshly built JSON tree, not the model. Null for no model.
+ ///
+ ///
+ public static JsonObject? SimulationSnapshotJsonObject(AnalyticalModel analyticalModel)
+ {
+ JsonObject? result = analyticalModel?.ToJsonObject();
+ if (result?["ParameterSets"] is not JsonArray parameterSets)
+ {
+ return result;
+ }
+
+ string name_ParameterSet = Core.Query.Name(typeof(UI.AnalyticalModelParameter).Assembly);
+ string? name_Parameter = Core.Attributes.ParameterProperties.Get(UI.AnalyticalModelParameter.UIGeometrySettings)?.Name;
+
+ foreach (JsonObject parameterSet in parameterSets.OfType().Where(x => Text(x["Name"]) == name_ParameterSet).ToList())
+ {
+ //An empty set is written without "Parameters" at all: it is dropped like an emptied one.
+ if (parameterSet["Parameters"] is not JsonArray parameters)
+ {
+ if (parameterSet["Parameters"] == null)
+ {
+ parameterSets.Remove(parameterSet);
+ }
+
+ continue;
+ }
+
+ foreach (JsonNode? parameter in parameters.Where(x => x is JsonObject jsonObject && Text(jsonObject["Name"]) == name_Parameter).ToList())
+ {
+ parameters.Remove(parameter);
+ }
+
+ if (parameters.Count == 0)
+ {
+ parameterSets.Remove(parameterSet);
+ }
+ }
+
+ return result;
+ }
+
+ private static string? Text(JsonNode? jsonNode)
+ {
+ return jsonNode is JsonValue jsonValue && jsonValue.TryGetValue(out string? value) ? value : null;
+ }
+ }
+
+ public static partial class Query
+ {
+ ///
+ /// A copy of 's view state (UI Geometry Settings), taken now - at freeze time, so that later
+ /// edits to the open model cannot reach it - for ; null when it has none.
+ ///
+ public static Geometry.UI.UIGeometrySettings? ViewState(AnalyticalModel analyticalModel)
+ {
+ return analyticalModel != null
+ && analyticalModel.TryGetValue(UI.AnalyticalModelParameter.UIGeometrySettings, out Geometry.UI.UIGeometrySettings uIGeometrySettings)
+ && uIGeometrySettings != null
+ ? new Geometry.UI.UIGeometrySettings(uIGeometrySettings)
+ : null;
+ }
+ }
+
+ public static partial class Modify
+ {
+ ///
+ /// Gives - a model derived from a snapshot, such as the OpenStudio result model - a copy of
+ /// ( of the open model when it was frozen), which
+ /// left out of the snapshot. So a result model opens with the views it opened with
+ /// before S04. Returns .
+ ///
+ public static AnalyticalModel WithViewState(AnalyticalModel analyticalModel, Geometry.UI.UIGeometrySettings? viewState)
+ {
+ if (analyticalModel != null && viewState != null)
+ {
+ analyticalModel.SetValue(UI.AnalyticalModelParameter.UIGeometrySettings, new Geometry.UI.UIGeometrySettings(viewState));
+ }
+
+ return analyticalModel!;
+ }
+ }
+}
diff --git a/documentation/S04-StableCanonicalSnapshot-PR.md b/documentation/S04-StableCanonicalSnapshot-PR.md
new file mode 100644
index 00000000..b0c018ae
--- /dev/null
+++ b/documentation/S04-StableCanonicalSnapshot-PR.md
@@ -0,0 +1,182 @@
+# S04 - Stable canonical simulation snapshot - PR record
+
+Roadmap: SAM `documentation/SIMULATION_ROADMAP_Q4_2026.md`, task S04 (Phase A), finding F7 of SAM_UI#239. SAM_UI only;
+SAM_Validation's canonical JSON v1 and `canonicalizationVersion` are unchanged.
+
+Base: `sow/2026-Q4` `4d7f5bd8`. **Independent of S03** (SAM_UI#242): neither needs the other. Both append assertions to the end of
+`OpenStudioSimulationTests.RealRun_…`, so whichever merges second has a textual conflict there; keep both blocks (done in a
+local integration merge that passed the full suite, see the PR description).
+Branch: `feature/s04-stable-canonical-snapshot`. Draft PR, not merged. **Validated content SHA: `29ca1d1a`** (code and tests, after the review fixes; first content `0594e708`).
+Record commits follow it. The final PR head and hosted checks are in the PR description. `PROJECT_PROGRESS.md` is not touched.
+
+## Status
+Implementation and local tests complete on 2026-10-19 (overnight owner-authorised S03+S04 session). On 2026-10-20 S03
+merged first (`1e460bbe`); this branch was refreshed with `sow/2026-Q4` and re-validated (see "Integration with S03").
+**Integrated content SHA: `3bbba54b`.** Awaiting its hosted CI, then merge under the owner's 2026-10-20 authorisation.
+
+## Problem (baseline `4d7f5bd8`), established from real evidence
+- **Two sessions, one model.** S01b runs B2 and B3 (machine-local in `C:\TasOut\s01b\runs`) each froze the same
+ `box-aperture.json`. The two snapshots, 3,442,974 bytes each, differ in exactly two values: the `Guid` and `ActiveGuid` of
+ the "3D View" inside `ParameterSets[Name="SAM.Analytical.UI"].Parameters[Name="UI Geometry Settings"]`.
+- **Four runs, four hashes.** The committed S01b B1a documents for four real Both runs of that one model carry four
+ different `canonicalModelHash` values: `6d455480…`, `fcb0cd9b…`, `a3df983c…` and `5e56751e…`.
+- **Cause.** `AnalyticalWindow.SetDefaultViewSettings` runs on `Opened`. A model with no views gets
+ `UI.Query.DefaultViewSettings`, whose GUID is `Guid.NewGuid()`.
+- **What the parameter is.** SAM_UI's view state: views, visibility, cameras and legends. It never reaches a simulation.
+ SAM already excludes it from `SimulationResultProvenance.Fingerprint` for the same reason.
+- **Found while fixing it, with the real producers.** Once that parameter is removed, its set is left empty, and the
+ empty set still carries a GUID: `Core.Query.Guid(assembly)`, the SAM.Analytical.UI module version id. That id changes with
+ every SAM_UI build. The test build had `afeedf2b…` and the S01b app build `29968788…`, so two builds still disagreed.
+
+## Change
+- **`Query/SimulationSnapshot.cs` (new):** `Query.SimulationSnapshotJsonObject(model)` takes the model's own freshly built
+ JSON tree and changes only that tree, never the model:
+ - it removes the `UI Geometry Settings` parameter;
+ - it removes the SAM.Analytical.UI parameter set only if that leaves it empty. Anything else in that set stays, as does
+ every other parameter set, GUID and piece of metadata.
+- **`Modify.FreezeAnalyticalModel`:** writes that JSON for the one snapshot both routes use:
+ - Run Simulation > Both: the file both producers hash (`sourceFileHash`, `canonicalModelHash`) and simulate;
+ - OpenStudio Simulation: the frozen copy is read back from it and converted.
+
+ A `.json` snapshot is written exactly as `Core.Convert.ToFile` writes JSON: a one-element array, with
+ `Core.Convert.SerializerOptions(Indented)`. A model with no view state is therefore frozen byte for byte as before. Other
+ file types go through `Core.Convert.ToFile`, as before.
+- **`Query.ViewState` / `Modify.WithViewState`:** the OpenStudio result model is built from the frozen copy.
+ - A copy of the open model's view state is taken at the moment of the snapshot, on the run's thread, as the snapshot
+ itself is.
+ - It is attached to the result model before saving, so the result model opens with the views it had before S04.
+ - Both steps are inside try/catch. A failure only adds a warning to the run's diagnostics and never changes the outcome.
+- **Unchanged:**
+ - geometry, constructions, schedules, weather, design days, simulation options and native-model conversion;
+ - the producers, SAM_Validation and its `canonicalizationVersion`;
+ - the manifests and their schemas;
+ - the Tas-only route, which writes no snapshot.
+
+## Decisions and assumptions
+- **Fix the input, not the hash rule.** The canonical hash is computed by the producers (SAM_Tas, SAM_OpenStudio) from the
+ snapshot SAM_UI writes, using SAM_Validation's generic canonical JSON v1. Excluding a SAM_UI parameter inside that generic
+ rule would be a canonicalization change across three more repositories. Keeping UI-only state out of the engine input is
+ the smallest correction, and it also stabilises `sourceFileHash` and the snapshot SHA-256.
+- **Deny one named parameter, not an allow list.** This is the same name and the same reasoning as SAM's Fingerprint
+ exclusion. A parameter added later that bears on a simulation is kept automatically.
+- **The emptied set is dropped, a non-empty one is kept.** An empty set carries only its per-build GUID. A set holding
+ anything else is left alone: removing it would be indiscriminate.
+- **Historical documents keep their hashes.** Earlier B1a documents and manifests keep their session-dependent hashes. A new
+ snapshot of the same model has a canonical hash that differs from every pre-S04 one, just as pre-S04 hashes already
+ differed from session to session. `canonicalizationVersion` is unchanged because the algorithm is unchanged; only the
+ input lost its UI-only state.
+
+## Tests
+New `CanonicalSnapshotTests.cs`: 14 tests in the WPF collection, on the Part O multizone fixture (2 flats plus a corridor,
+with spaces, panels, apertures, constructions, internal conditions and weather).
+- **Method:** models are saved, then reopened as the window opens them: `UIAnalyticalModel.Open`, then the window's
+ default-view step using the production `UI.Query.DefaultViewSettings`. The canonical hash is computed as the producers
+ compute it: the snapshot is loaded into an AnalyticalModel, then canonical JSON v1 is taken of its JSON.
+- **A:** the same saved model opened twice. The opened models' view GUIDs differ (the F7 precondition). The snapshot
+ SHA-256 and the canonical hash are equal.
+- **B:** different UI-only view state (another view, new GUIDs) gives the same canonical hash. The view state's set with
+ another build's GUID also gives the same snapshot and hash.
+- **C (theory):** each of these changes the hash:
+ - a construction;
+ - a space parameter;
+ - the weather;
+ - a model parameter;
+ - another parameter in the SAM.Analytical.UI set (only the named view state is dropped).
+- **Snapshot is the source less exactly the view state:** its JSON equals the source's JSON with only the parameter and its
+ emptied set removed. A model with no view state is frozen byte for byte as `Core.Convert.ToFile` freezes it.
+- **D/E:** two Run Simulation > Both runs of the model, opened in two sessions, feed their producers byte-identical
+ snapshots in different run folders. Each manifest's `snapshot.sha256` is its own file's hash, and both producers of a run
+ read that one file.
+- **F:** `WithViewState` gives the frozen result model a copy (not the instance) of the source's view state.
+ - The existing `OpenStudioSimulationTests.RealRun_…` now gives the model a view state. It checks that the real snapshot has
+ none and the real result model has it, compared after the JSON round trip the saved file makes.
+ - That test runs only where OpenStudio is installed.
+- **G:** freezing leaves the source model byte-identical, and the source keeps its view state.
+- Existing snapshot consumers pass unchanged: `EngineComparisonTests`, `OpenStudioSimulationTests`,
+ `TasFailureReportingTests`, `SimulationConcurrencyGuardTests`.
+
+## Validation
+Local results on this laptop. Dependencies: SAM `62b54156`, SAM_Tas `e84aeff6`, SAM_OpenStudio `d4216f3` and
+SAM_Validation `68eded0`, all on `sow/2026-Q4` and clean.
+- `dotnet build WPF/SAM.Analytical.UI.WPF.Tests -c Release`: 0 errors.
+- Focused (`CanonicalSnapshotTests|OpenStudioSimulationTests|EngineComparisonTests|WpfCollectionTests`):
+ **45/45 PASS** on `29ca1d1a`, including the real OpenStudio run.
+- **Mutation checks on `0594e708`** (each reverted), all caught:
+
+ | Mutation | Tests failed |
+ |---|---|
+ | Snapshot written as before S04 | 7 |
+ | Every SAM.Analytical.UI set dropped (over-removal) | 1 |
+ | Emptied set kept | 2 |
+ | Result model not given its view state back | 1 (the real OpenStudio run) |
+
+ An earlier variant was also caught: removing the weather too failed 2 tests, on `1f24f8af`.
+- Full `SAM.Analytical.UI.WPF.Tests` (real `APPDATA`): `0594e708` **2873/2873 PASS**; after the review fixes `29ca1d1a` **2874/2874 PASS** (2860 + 14 new).
+
+## Independent review (2026-10-19) and fixes in `29ca1d1a`
+A separate regression-minded review of the combined S03+S04 diff confirmed two S04 defects, both fixed:
+1. **Medium.** `WithViewState` read the live open model at the **end** of the OpenStudio run, on the worker thread, with no
+ guard. A collection change during the run could throw and turn a successful run into Failed, and it would have picked up
+ view edits made while the run was going.
+ - The view state is now copied at freeze time and attached inside a try/catch.
+ - The test now edits the source after the freeze and proves that the edit does not reach the result model.
+2. **Low.** An already-empty SAM.Analytical.UI set, written with no `"Parameters"` key, was kept along with its per-build
+ GUID.
+ - It is now dropped, like an emptied set.
+ - New test: such a model freezes byte for byte as one that never had the set. Mutation check: keeping the set → 1 fails.
+
+The review also confirmed:
+- the live model is never mutated, because `ToJsonObject` deep-clones parameter sets;
+- `.json` snapshots are byte-identical to `Core.Convert.ToFile`;
+- a missing directory returns null, as before;
+- duplicate same-name sets are all processed.
+
+## Evidence (real producers, licensed laptop)
+Evidence is in `documentation/evidence/s04-20261019/`: `real-producers.txt`, plus a manifest and both B1a documents for each
+session. The harness is `tools/S04EvidenceHarness.cs.txt`; it is not compiled.
+
+**E1: two sessions, real producers.** The model is opened afresh as the window opens it, and run through real Both,
+benchmark-tas and benchmark-openstudio.
+
+| | Session 1 | Session 2 |
+|---|---|---|
+| View GUID | `cc6f50a9…` | `d72e130c…` |
+| Snapshot | `0c30db14…` | `0c30db14…` |
+| `sourceFileHash` | `0c30db14…` | `0c30db14…` |
+| `canonicalModelHash`, both producers | `05c1e334…` | `05c1e334…` |
+| Status | Compared | Compared |
+
+**E2: the four historical S01b snapshots, refrozen under S04.** These were made by two different app builds, and before
+S04 they had four canonical hashes (`6d455480…`, `fcb0cd9b…`, `a3df983c…`, `5e56751e…`). All four now give snapshot
+`0c30db14…` and canonical hash `05c1e334…`, the same values as E1, which came from the test build.
+
+## Integration with S03 (2026-10-20)
+- `3bbba54b` merges `sow/2026-Q4` `56e67d09` (S03 SAM_UI#242 merged as `1e460bbe`, plus its docs-only closeout) into
+ `b19b80ab`. The one conflict, in `OpenStudioSimulationTests.RealRun_…`, keeps both the S04 view-state block and the S03
+ provenance block. The resolved file and every code file equal the local integration tree `8aafcb06`; the only difference
+ from `8aafcb06` is `PROJECT_PROGRESS.md` (the S03 closeout, from the base).
+- S04 behaviour kept, checked in the merged tree: the snapshot drops only `UI Geometry Settings` and an emptied or empty
+ SAM.Analytical.UI set (`Query/SimulationSnapshot.cs`); every other parameter set, model parameter and the weather stay;
+ the view state is copied at freeze time and attached to the result model in a try/catch.
+- Full `dotnet test WPF/SAM.Analytical.UI.WPF.Tests -c Release` on `3bbba54b` (real `APPDATA`; dependency builds as above,
+ local, machine-local logs `C:\TasOut\s05\logs\fulltest-3bbba54b*.txt`):
+ - run 1: **2925/2926**. `TasRunManifestTests.Succeeded_RecordsTheWorkflowSettingsThatRan_AndTheEmbeddedWeather` (S03)
+ reported `Cancelled`, after exactly 5 s. Cause: under full-suite load, `ProgressWindowHost` did not confirm its dialog
+ thread's shutdown within its 5 s join, and `Simulate` then treats the run as possibly cancelled by design
+ (`!progressWindowHost.ShutdownCompleted`, unchanged since 2026-10-06). Timing, not a merge or S04 effect.
+ - run 2, same build: **2926/2926 PASS**.
+ - Recorded, not fixed here: on a heavily loaded machine an ordinary Tas run can be reported Cancelled when the progress
+ window's thread is slow to stop. Pre-existing product behaviour, outside S04.
+- **Local dependency caveat (found 2026-10-20):** the machine's `SAM\build` predates SAM#212 (F1), so these local runs used
+ a pre-F1 `SAM.Weather.dll`; hosted CI builds the dependencies from source and is the authoritative check. S05 acceptance
+ rebuilds the dependency stack from the `sow/2026-Q4` tips.
+
+## Unresolved, risks
+- `UIGeometrySettings` does not round-trip its default view's appearance dictionary through JSON, because its keys are
+ Guids. This is pre-existing, behaves the same before and after S04, and was found while writing the result-model test.
+- No licensed real-UI run has been done; that is roadmap S05.
+- The Both runs in the evidence needed `SAM_COMMIT` for the developer-built producers. See S03 (SAM_UI#242).
+
+## Next step
+Merge once the hosted Build and SPDX checks pass at the final head (owner authorisation 2026-10-20). After merge, add
+the post-merge `PROJECT_PROGRESS.md` closeout on `sow/2026-Q4`; S05 formal acceptance follows on the merged content.
diff --git a/documentation/evidence/s04-20261019/README.md b/documentation/evidence/s04-20261019/README.md
new file mode 100644
index 00000000..34ce3f94
--- /dev/null
+++ b/documentation/evidence/s04-20261019/README.md
@@ -0,0 +1,15 @@
+# S04 stable canonical snapshot - real-producer evidence (2026-10-19)
+
+Licensed development laptop. SAM_UI S04 content `0594e708`. Producers are developer builds: SAM_Tas `e84aeff6` and
+SAM_OpenStudio `d4216f3`, with `SAM_COMMIT` set by hand (see S03, SAM_UI#242). The model is `box-aperture.json` and the
+weather `Budapest.epw` (machine-local, `C:\TasOut\s01b\inputs`). The SAM_UI code paths were driven by
+`tools/S04EvidenceHarness.cs.txt`, a temporary xUnit harness that is not compiled, and not by the ribbon.
+
+- `real-producers.txt`: the output of the harness.
+ - **E1:** two Both runs, each on the model opened afresh as the window opens it, with different view GUIDs. Both runs
+ gave snapshot `0c30db14…`, and both real producers reported `canonicalModelHash` `05c1e334…`.
+ - **E2:** the four historical S01b snapshots were frozen again under S04. They came from two app builds and had four
+ pre-S04 canonical hashes. All four now give the same snapshot and hash as E1.
+- `session-1/` and `session-2/`: each run's `engine-comparison-run.json` and both B1a documents.
+
+Paths inside the files are machine-local.
diff --git a/documentation/evidence/s04-20261019/real-producers.txt b/documentation/evidence/s04-20261019/real-producers.txt
new file mode 100644
index 00000000..a5185c51
--- /dev/null
+++ b/documentation/evidence/s04-20261019/real-producers.txt
@@ -0,0 +1,12 @@
+Passed SAM.Analytical.UI.WPF.Tests.S04EvidenceHarness.E1_TwoRealBothRuns_TwoSessions [58 s]
+session-1: status=Compared view=cc6f50a9-8d0e-49de-8a94-c404fbee7374 snapshot=sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05
+Tas: exit=0 canonicalModelHash=sha256:05c1e334af00f339d36d5b97828692d3d7d27269d2168a945dfc4a35919c9500 sourceFileHash=sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05
+OpenStudio: exit=0 canonicalModelHash=sha256:05c1e334af00f339d36d5b97828692d3d7d27269d2168a945dfc4a35919c9500 sourceFileHash=sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05
+session-2: status=Compared view=d72e130c-1306-4b66-b297-8c365e069134 snapshot=sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05
+Tas: exit=0 canonicalModelHash=sha256:05c1e334af00f339d36d5b97828692d3d7d27269d2168a945dfc4a35919c9500 sourceFileHash=sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05
+OpenStudio: exit=0 canonicalModelHash=sha256:05c1e334af00f339d36d5b97828692d3d7d27269d2168a945dfc4a35919c9500 sourceFileHash=sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05
+Passed SAM.Analytical.UI.WPF.Tests.S04EvidenceHarness.E2_HistoricalSnapshots_RefrozenUnderS04 [2 s]
+B0-base-both-tas-refused\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-183629_fba4e40f\snapshot.json: pre-S04 canonical sha256:6d455480d148b86d2c3a452255d451d06244ce84e2db01f2ef0d3102fd15fe51 -> S04 canonical sha256:05c1e334af00f339d36d5b97828692d3d7d27269d2168a945dfc4a35919c9500, snapshot sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05
+B1-cand-both-tas-refused\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-183527_70cca5f6\snapshot.json: pre-S04 canonical sha256:fcb0cd9bcdabb33ded9aaf0f9a3b8d0d50005025dd2959068b03682d0efb3940 -> S04 canonical sha256:05c1e334af00f339d36d5b97828692d3d7d27269d2168a945dfc4a35919c9500, snapshot sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05
+B2-cand-both-success\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-183722_2f78f5e3\snapshot.json: pre-S04 canonical sha256:a3df983c3d8d7a83c5059c2146d0649ba593f88d0a1e4a37d22dbd270f354835 -> S04 canonical sha256:05c1e334af00f339d36d5b97828692d3d7d27269d2168a945dfc4a35919c9500, snapshot sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05
+B3-base-both-success\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-183816_0afec0ff\snapshot.json: pre-S04 canonical sha256:5e56751ee754eed85c0ad2844562a37e2337a19e556206ff80bae2744f947679 -> S04 canonical sha256:05c1e334af00f339d36d5b97828692d3d7d27269d2168a945dfc4a35919c9500, snapshot sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05
diff --git a/documentation/evidence/s04-20261019/session-1/benchmark-OpenStudio.json b/documentation/evidence/s04-20261019/session-1/benchmark-OpenStudio.json
new file mode 100644
index 00000000..961b8352
--- /dev/null
+++ b/documentation/evidence/s04-20261019/session-1/benchmark-OpenStudio.json
@@ -0,0 +1,140 @@
+{
+ "schemaVersion": "1.0.0",
+ "provenance": {
+ "sourceModelName": "000000_SAM_AnalyticalModel",
+ "sourceModelGuid": "1988f7b93ec047d0b87bf9cb345d9eab",
+ "sourceFileHash": "sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05",
+ "canonicalModelHash": "sha256:05c1e334af00f339d36d5b97828692d3d7d27269d2168a945dfc4a35919c9500",
+ "canonicalizationVersion": "1.0.0",
+ "samCommit": "62b5415697d1a5af143c6b581b7d42fbd6551ca8",
+ "runnerCommit": "d4216f3525c01ca762c1b3e9b6d099733d037f54",
+ "engine": {
+ "kind": "OpenStudio",
+ "name": "EnergyPlus",
+ "version": "25.1.0",
+ "sdkVersion": "3.10.0"
+ },
+ "route": "Native-OpenStudio",
+ "weather": {
+ "identity": "weather",
+ "hash": "sha256:472ffb4e2f10867600949a392ce46891fc1bd05f2fc8d8917a99bca7d26209c3"
+ },
+ "designDaySource": "None",
+ "runTimestampUtc": "2026-10-19T21:01:17.1338121Z",
+ "durationSeconds": 5.586659,
+ "state": "Success",
+ "resultSources": [
+ "SAM.Analytical.OpenStudio"
+ ],
+ "warnings": [
+ "[SAM-OS-IC-001] Warning: Pollutant generation is not converted (EnergyPlus generic contaminant modelling is out of scope) (SAM InternalCondition 9e54af09-65fb-4f64-98d7-200db3be2457) \u2192 SAM_InternalCondition_S39_OfficeOpen_30b69a1f"
+ ],
+ "notes": [
+ "[SAM-OS-RUN-002] Information: Annual weather source: explicit EPW (weather.epw)",
+ "[SAM-OS-RUN-002] Information: Ground temperatures taken from the SAM model WeatherData (depth 0.5 m set, 12 monthly values)",
+ "[SAM-OS-RUN-002] Information: No design-day source supplied; sizing-period runs stay disabled unless OpenStudioConversionOptions.RunSizingPeriods overrides"
+ ]
+ },
+ "model": {
+ "consumptionHeating": {
+ "value": 2173.9185033546732,
+ "unit": "kWh",
+ "available": true
+ },
+ "consumptionCooling": {
+ "value": 4556.226212241192,
+ "unit": "kWh",
+ "available": true
+ },
+ "peakHeatingLoad": {
+ "value": 3.2591834042717536,
+ "unit": "kW",
+ "available": true
+ },
+ "peakHeatingHour": {
+ "value": 8455,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "peakCoolingLoad": {
+ "value": 5.332620570657522,
+ "unit": "kW",
+ "available": true
+ },
+ "peakCoolingHour": {
+ "value": 5029,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "floorArea": {
+ "value": 50,
+ "unit": "m2",
+ "available": true
+ },
+ "volume": {
+ "value": 150,
+ "unit": "m3",
+ "available": true
+ }
+ },
+ "spaces": [
+ {
+ "guid": "cb68bed195af496bb255a13ffa5c58e9",
+ "name": "Office",
+ "area": {
+ "value": 50,
+ "unit": "m2",
+ "available": true
+ },
+ "volume": {
+ "value": 150,
+ "unit": "m3",
+ "available": true
+ },
+ "heating": {
+ "designLoad": {
+ "value": null,
+ "unit": "W",
+ "available": false
+ },
+ "peakLoad": {
+ "value": 3259.1834042717537,
+ "unit": "W",
+ "available": true
+ },
+ "peakHour": {
+ "value": 8455,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "unmetHours": {
+ "value": 0,
+ "unit": "h",
+ "available": true
+ }
+ },
+ "cooling": {
+ "designLoad": {
+ "value": null,
+ "unit": "W",
+ "available": false
+ },
+ "peakLoad": {
+ "value": 5332.620570657522,
+ "unit": "W",
+ "available": true
+ },
+ "peakHour": {
+ "value": 5029,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "unmetHours": {
+ "value": 0,
+ "unit": "h",
+ "available": true
+ }
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/documentation/evidence/s04-20261019/session-1/benchmark-TAS.json b/documentation/evidence/s04-20261019/session-1/benchmark-TAS.json
new file mode 100644
index 00000000..bb9c77ab
--- /dev/null
+++ b/documentation/evidence/s04-20261019/session-1/benchmark-TAS.json
@@ -0,0 +1,136 @@
+{
+ "schemaVersion": "1.0.0",
+ "provenance": {
+ "sourceModelName": "000000_SAM_AnalyticalModel",
+ "sourceModelGuid": "1988f7b93ec047d0b87bf9cb345d9eab",
+ "sourceFileHash": "sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05",
+ "canonicalModelHash": "sha256:05c1e334af00f339d36d5b97828692d3d7d27269d2168a945dfc4a35919c9500",
+ "canonicalizationVersion": "1.0.0",
+ "samCommit": "62b5415697d1a5af143c6b581b7d42fbd6551ca8",
+ "runnerCommit": "e84aeff687033354e3f17891635fdf10d42ad68f",
+ "engine": {
+ "kind": "TAS",
+ "name": "Tas",
+ "version": "9.5.7.0",
+ "sdkVersion": null
+ },
+ "route": "Native-TAS",
+ "weather": {
+ "identity": "weather",
+ "hash": "sha256:472ffb4e2f10867600949a392ce46891fc1bd05f2fc8d8917a99bca7d26209c3"
+ },
+ "designDaySource": "None",
+ "runTimestampUtc": "2026-10-19T21:00:56.8285942Z",
+ "durationSeconds": 18.3678412,
+ "state": "Success",
+ "resultSources": [
+ "SAM.Analytical.Tas"
+ ],
+ "warnings": [],
+ "notes": [
+ "Shared gbXML was exported from the source model (no --gbxml supplied)."
+ ]
+ },
+ "model": {
+ "consumptionHeating": {
+ "value": 220.62682055425643,
+ "unit": "kWh",
+ "available": true
+ },
+ "consumptionCooling": {
+ "value": 5540.314465834558,
+ "unit": "kWh",
+ "available": true
+ },
+ "peakHeatingLoad": {
+ "value": 1.4238231201171876,
+ "unit": "kW",
+ "available": true
+ },
+ "peakHeatingHour": {
+ "value": 775,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "peakCoolingLoad": {
+ "value": 3.126279052734375,
+ "unit": "kW",
+ "available": true
+ },
+ "peakCoolingHour": {
+ "value": 3901,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "floorArea": {
+ "value": 50,
+ "unit": "m2",
+ "available": true
+ },
+ "volume": {
+ "value": 150,
+ "unit": "m3",
+ "available": true
+ }
+ },
+ "spaces": [
+ {
+ "guid": "cb68bed195af496bb255a13ffa5c58e9",
+ "name": "Office",
+ "area": {
+ "value": 50,
+ "unit": "m2",
+ "available": true
+ },
+ "volume": {
+ "value": 150,
+ "unit": "m3",
+ "available": true
+ },
+ "heating": {
+ "designLoad": {
+ "value": 1927.0106201171875,
+ "unit": "W",
+ "available": true
+ },
+ "peakLoad": {
+ "value": 1926.94873046875,
+ "unit": "W",
+ "available": true
+ },
+ "peakHour": {
+ "value": 936,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "unmetHours": {
+ "value": 0,
+ "unit": "h",
+ "available": true
+ }
+ },
+ "cooling": {
+ "designLoad": {
+ "value": 3415.618896484375,
+ "unit": "W",
+ "available": true
+ },
+ "peakLoad": {
+ "value": 3415.45556640625,
+ "unit": "W",
+ "available": true
+ },
+ "peakHour": {
+ "value": 4358,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "unmetHours": {
+ "value": 0,
+ "unit": "h",
+ "available": true
+ }
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/documentation/evidence/s04-20261019/session-1/engine-comparison-run.json b/documentation/evidence/s04-20261019/session-1/engine-comparison-run.json
new file mode 100644
index 00000000..023c929a
--- /dev/null
+++ b/documentation/evidence/s04-20261019/session-1/engine-comparison-run.json
@@ -0,0 +1,68 @@
+{
+ "schema": "sam-ui.engine-comparison-run/1",
+ "id": "5abf6f80",
+ "status": "Compared",
+ "startedUtc": "2026-10-19T21:00:55.6685544Z",
+ "finishedUtc": "2026-10-19T21:01:24.6641845Z",
+ "sourcePath": "C:\\TasOut\\s04\\box-aperture.json",
+ "modelName": "000000_SAM_AnalyticalModel",
+ "snapshot": {
+ "path": "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\snapshot.json",
+ "sha256": "sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05"
+ },
+ "weather": {
+ "source": "File",
+ "path": "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\weather.epw",
+ "sha256": "sha256:472ffb4e2f10867600949a392ce46891fc1bd05f2fc8d8917a99bca7d26209c3"
+ },
+ "producers": [
+ {
+ "engine": "Tas",
+ "executable": "C:\\Users\\michal\\Documents\\GitHub\\SAM-BIM\\SAM_Tas\\build\\benchmark-tas.exe",
+ "arguments": [
+ "--model",
+ "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\snapshot.json",
+ "--weather",
+ "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\weather.epw",
+ "--tbd",
+ "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\tas\\000000_SAM_AnalyticalModel.tbd",
+ "--out",
+ "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\benchmark-TAS.json"
+ ],
+ "exitCode": 0,
+ "durationSeconds": 20.287,
+ "log": "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\benchmark-tas.log",
+ "benchmark": "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\benchmark-TAS.json",
+ "error": null,
+ "succeeded": true
+ },
+ {
+ "engine": "OpenStudio",
+ "executable": "C:\\Users\\michal\\Documents\\GitHub\\SAM-BIM\\SAM_OpenStudio\\benchmark\\SAM.Analytical.OpenStudio.Benchmark\\bin\\x64\\Release\\net8.0\\benchmark-openstudio.exe",
+ "arguments": [
+ "--model",
+ "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\snapshot.json",
+ "--weather",
+ "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\weather.epw",
+ "--out",
+ "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\benchmark-OpenStudio.json",
+ "--work",
+ "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\openstudio"
+ ],
+ "exitCode": 0,
+ "durationSeconds": 8.139,
+ "log": "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\benchmark-openstudio.log",
+ "benchmark": "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\benchmark-OpenStudio.json",
+ "error": null,
+ "succeeded": true
+ }
+ ],
+ "gate": "Fail",
+ "reports": [
+ "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\comparison.md",
+ "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\comparison.csv",
+ "C:\\TasOut\\s04\\session-1\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210055_5abf6f80\\comparison-summary.json"
+ ],
+ "diagnostics": [],
+ "error": null
+}
\ No newline at end of file
diff --git a/documentation/evidence/s04-20261019/session-2/benchmark-OpenStudio.json b/documentation/evidence/s04-20261019/session-2/benchmark-OpenStudio.json
new file mode 100644
index 00000000..0ba008b3
--- /dev/null
+++ b/documentation/evidence/s04-20261019/session-2/benchmark-OpenStudio.json
@@ -0,0 +1,140 @@
+{
+ "schemaVersion": "1.0.0",
+ "provenance": {
+ "sourceModelName": "000000_SAM_AnalyticalModel",
+ "sourceModelGuid": "1988f7b93ec047d0b87bf9cb345d9eab",
+ "sourceFileHash": "sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05",
+ "canonicalModelHash": "sha256:05c1e334af00f339d36d5b97828692d3d7d27269d2168a945dfc4a35919c9500",
+ "canonicalizationVersion": "1.0.0",
+ "samCommit": "62b5415697d1a5af143c6b581b7d42fbd6551ca8",
+ "runnerCommit": "d4216f3525c01ca762c1b3e9b6d099733d037f54",
+ "engine": {
+ "kind": "OpenStudio",
+ "name": "EnergyPlus",
+ "version": "25.1.0",
+ "sdkVersion": "3.10.0"
+ },
+ "route": "Native-OpenStudio",
+ "weather": {
+ "identity": "weather",
+ "hash": "sha256:472ffb4e2f10867600949a392ce46891fc1bd05f2fc8d8917a99bca7d26209c3"
+ },
+ "designDaySource": "None",
+ "runTimestampUtc": "2026-10-19T21:01:46.7457631Z",
+ "durationSeconds": 5.3043344,
+ "state": "Success",
+ "resultSources": [
+ "SAM.Analytical.OpenStudio"
+ ],
+ "warnings": [
+ "[SAM-OS-IC-001] Warning: Pollutant generation is not converted (EnergyPlus generic contaminant modelling is out of scope) (SAM InternalCondition 9e54af09-65fb-4f64-98d7-200db3be2457) \u2192 SAM_InternalCondition_S39_OfficeOpen_30b69a1f"
+ ],
+ "notes": [
+ "[SAM-OS-RUN-002] Information: Annual weather source: explicit EPW (weather.epw)",
+ "[SAM-OS-RUN-002] Information: Ground temperatures taken from the SAM model WeatherData (depth 0.5 m set, 12 monthly values)",
+ "[SAM-OS-RUN-002] Information: No design-day source supplied; sizing-period runs stay disabled unless OpenStudioConversionOptions.RunSizingPeriods overrides"
+ ]
+ },
+ "model": {
+ "consumptionHeating": {
+ "value": 2173.9185033546732,
+ "unit": "kWh",
+ "available": true
+ },
+ "consumptionCooling": {
+ "value": 4556.226212241192,
+ "unit": "kWh",
+ "available": true
+ },
+ "peakHeatingLoad": {
+ "value": 3.2591834042717536,
+ "unit": "kW",
+ "available": true
+ },
+ "peakHeatingHour": {
+ "value": 8455,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "peakCoolingLoad": {
+ "value": 5.332620570657522,
+ "unit": "kW",
+ "available": true
+ },
+ "peakCoolingHour": {
+ "value": 5029,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "floorArea": {
+ "value": 50,
+ "unit": "m2",
+ "available": true
+ },
+ "volume": {
+ "value": 150,
+ "unit": "m3",
+ "available": true
+ }
+ },
+ "spaces": [
+ {
+ "guid": "cb68bed195af496bb255a13ffa5c58e9",
+ "name": "Office",
+ "area": {
+ "value": 50,
+ "unit": "m2",
+ "available": true
+ },
+ "volume": {
+ "value": 150,
+ "unit": "m3",
+ "available": true
+ },
+ "heating": {
+ "designLoad": {
+ "value": null,
+ "unit": "W",
+ "available": false
+ },
+ "peakLoad": {
+ "value": 3259.1834042717537,
+ "unit": "W",
+ "available": true
+ },
+ "peakHour": {
+ "value": 8455,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "unmetHours": {
+ "value": 0,
+ "unit": "h",
+ "available": true
+ }
+ },
+ "cooling": {
+ "designLoad": {
+ "value": null,
+ "unit": "W",
+ "available": false
+ },
+ "peakLoad": {
+ "value": 5332.620570657522,
+ "unit": "W",
+ "available": true
+ },
+ "peakHour": {
+ "value": 5029,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "unmetHours": {
+ "value": 0,
+ "unit": "h",
+ "available": true
+ }
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/documentation/evidence/s04-20261019/session-2/benchmark-TAS.json b/documentation/evidence/s04-20261019/session-2/benchmark-TAS.json
new file mode 100644
index 00000000..c2a1b381
--- /dev/null
+++ b/documentation/evidence/s04-20261019/session-2/benchmark-TAS.json
@@ -0,0 +1,136 @@
+{
+ "schemaVersion": "1.0.0",
+ "provenance": {
+ "sourceModelName": "000000_SAM_AnalyticalModel",
+ "sourceModelGuid": "1988f7b93ec047d0b87bf9cb345d9eab",
+ "sourceFileHash": "sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05",
+ "canonicalModelHash": "sha256:05c1e334af00f339d36d5b97828692d3d7d27269d2168a945dfc4a35919c9500",
+ "canonicalizationVersion": "1.0.0",
+ "samCommit": "62b5415697d1a5af143c6b581b7d42fbd6551ca8",
+ "runnerCommit": "e84aeff687033354e3f17891635fdf10d42ad68f",
+ "engine": {
+ "kind": "TAS",
+ "name": "Tas",
+ "version": "9.5.7.0",
+ "sdkVersion": null
+ },
+ "route": "Native-TAS",
+ "weather": {
+ "identity": "weather",
+ "hash": "sha256:472ffb4e2f10867600949a392ce46891fc1bd05f2fc8d8917a99bca7d26209c3"
+ },
+ "designDaySource": "None",
+ "runTimestampUtc": "2026-10-19T21:01:25.8629255Z",
+ "durationSeconds": 18.7940658,
+ "state": "Success",
+ "resultSources": [
+ "SAM.Analytical.Tas"
+ ],
+ "warnings": [],
+ "notes": [
+ "Shared gbXML was exported from the source model (no --gbxml supplied)."
+ ]
+ },
+ "model": {
+ "consumptionHeating": {
+ "value": 220.62682055425643,
+ "unit": "kWh",
+ "available": true
+ },
+ "consumptionCooling": {
+ "value": 5540.314465834558,
+ "unit": "kWh",
+ "available": true
+ },
+ "peakHeatingLoad": {
+ "value": 1.4238231201171876,
+ "unit": "kW",
+ "available": true
+ },
+ "peakHeatingHour": {
+ "value": 775,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "peakCoolingLoad": {
+ "value": 3.126279052734375,
+ "unit": "kW",
+ "available": true
+ },
+ "peakCoolingHour": {
+ "value": 3901,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "floorArea": {
+ "value": 50,
+ "unit": "m2",
+ "available": true
+ },
+ "volume": {
+ "value": 150,
+ "unit": "m3",
+ "available": true
+ }
+ },
+ "spaces": [
+ {
+ "guid": "cb68bed195af496bb255a13ffa5c58e9",
+ "name": "Office",
+ "area": {
+ "value": 50,
+ "unit": "m2",
+ "available": true
+ },
+ "volume": {
+ "value": 150,
+ "unit": "m3",
+ "available": true
+ },
+ "heating": {
+ "designLoad": {
+ "value": 1927.0106201171875,
+ "unit": "W",
+ "available": true
+ },
+ "peakLoad": {
+ "value": 1926.94873046875,
+ "unit": "W",
+ "available": true
+ },
+ "peakHour": {
+ "value": 936,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "unmetHours": {
+ "value": 0,
+ "unit": "h",
+ "available": true
+ }
+ },
+ "cooling": {
+ "designLoad": {
+ "value": 3415.618896484375,
+ "unit": "W",
+ "available": true
+ },
+ "peakLoad": {
+ "value": 3415.45556640625,
+ "unit": "W",
+ "available": true
+ },
+ "peakHour": {
+ "value": 4358,
+ "unit": "hourOfYear",
+ "available": true
+ },
+ "unmetHours": {
+ "value": 0,
+ "unit": "h",
+ "available": true
+ }
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/documentation/evidence/s04-20261019/session-2/engine-comparison-run.json b/documentation/evidence/s04-20261019/session-2/engine-comparison-run.json
new file mode 100644
index 00000000..570da4cb
--- /dev/null
+++ b/documentation/evidence/s04-20261019/session-2/engine-comparison-run.json
@@ -0,0 +1,68 @@
+{
+ "schema": "sam-ui.engine-comparison-run/1",
+ "id": "ab36d659",
+ "status": "Compared",
+ "startedUtc": "2026-10-19T21:01:24.7556487Z",
+ "finishedUtc": "2026-10-19T21:01:53.9613744Z",
+ "sourcePath": "C:\\TasOut\\s04\\box-aperture.json",
+ "modelName": "000000_SAM_AnalyticalModel",
+ "snapshot": {
+ "path": "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\snapshot.json",
+ "sha256": "sha256:0c30db14a9b10f5e651248e70c942bf875ca93dcee9436998b9960e47c0a6d05"
+ },
+ "weather": {
+ "source": "File",
+ "path": "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\weather.epw",
+ "sha256": "sha256:472ffb4e2f10867600949a392ce46891fc1bd05f2fc8d8917a99bca7d26209c3"
+ },
+ "producers": [
+ {
+ "engine": "Tas",
+ "executable": "C:\\Users\\michal\\Documents\\GitHub\\SAM-BIM\\SAM_Tas\\build\\benchmark-tas.exe",
+ "arguments": [
+ "--model",
+ "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\snapshot.json",
+ "--weather",
+ "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\weather.epw",
+ "--tbd",
+ "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\tas\\000000_SAM_AnalyticalModel.tbd",
+ "--out",
+ "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\benchmark-TAS.json"
+ ],
+ "exitCode": 0,
+ "durationSeconds": 20.809,
+ "log": "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\benchmark-tas.log",
+ "benchmark": "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\benchmark-TAS.json",
+ "error": null,
+ "succeeded": true
+ },
+ {
+ "engine": "OpenStudio",
+ "executable": "C:\\Users\\michal\\Documents\\GitHub\\SAM-BIM\\SAM_OpenStudio\\benchmark\\SAM.Analytical.OpenStudio.Benchmark\\bin\\x64\\Release\\net8.0\\benchmark-openstudio.exe",
+ "arguments": [
+ "--model",
+ "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\snapshot.json",
+ "--weather",
+ "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\weather.epw",
+ "--out",
+ "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\benchmark-OpenStudio.json",
+ "--work",
+ "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\openstudio"
+ ],
+ "exitCode": 0,
+ "durationSeconds": 8.045,
+ "log": "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\benchmark-openstudio.log",
+ "benchmark": "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\benchmark-OpenStudio.json",
+ "error": null,
+ "succeeded": true
+ }
+ ],
+ "gate": "Fail",
+ "reports": [
+ "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\comparison.md",
+ "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\comparison.csv",
+ "C:\\TasOut\\s04\\session-2\\000000_SAM_AnalyticalModel_TasOpenStudio_20261019-210124_ab36d659\\comparison-summary.json"
+ ],
+ "diagnostics": [],
+ "error": null
+}
\ No newline at end of file
diff --git a/documentation/evidence/s04-20261019/tools/S04EvidenceHarness.cs.txt b/documentation/evidence/s04-20261019/tools/S04EvidenceHarness.cs.txt
new file mode 100644
index 00000000..311ea28b
--- /dev/null
+++ b/documentation/evidence/s04-20261019/tools/S04EvidenceHarness.cs.txt
@@ -0,0 +1,108 @@
+// Temporary, NOT committed: S04 evidence with real producers on a licensed machine.
+#nullable enable
+
+using SAM.Analytical.Benchmark;
+using SAM.Core;
+using SAM.Geometry.UI;
+using System;
+using System.IO;
+using System.Linq;
+using System.Text.Json.Nodes;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace SAM.Analytical.UI.WPF.Tests
+{
+ [Collection(WpfCollection.Name)]
+ public class S04EvidenceHarness
+ {
+ private const string Inputs = @"C:\TasOut\s01b\inputs";
+ private const string Root = @"C:\TasOut\s04";
+ private readonly ITestOutputHelper output;
+
+ public S04EvidenceHarness(ITestOutputHelper output) { this.output = output; }
+
+ private static bool Enabled => Environment.GetEnvironmentVariable("S04_EVIDENCE") == "1";
+
+ /// Two real Both runs, each on the model opened afresh as the window opens it.
+ [WpfFact]
+ public void E1_TwoRealBothRuns_TwoSessions()
+ {
+ if (!Enabled) return;
+ Directory.CreateDirectory(Root);
+ string modelPath = Path.Combine(Root, "box-aperture.json");
+ File.Copy(Path.Combine(Inputs, "box-aperture.json"), modelPath, true);
+
+ string? samCommit_Before = Environment.GetEnvironmentVariable("SAM_COMMIT");
+ string? path_Before = Environment.GetEnvironmentVariable("PATH");
+ Environment.SetEnvironmentVariable("SAM_COMMIT", "62b5415697d1a5af143c6b581b7d42fbd6551ca8");
+ Environment.SetEnvironmentVariable("PATH", @"C:\Users\michal\Documents\GitHub\SAM-BIM\.icon-migration\runtime\OpenStudio-3.10.0+86d7e215a1-Windows\bin;" + path_Before);
+ try
+ {
+ foreach (string session in new[] { "session-1", "session-2" })
+ {
+ AnalyticalModel analyticalModel = OpenAsTheWindowDoes(modelPath);
+ EngineComparisonRun run = analyticalModel.RunEngineComparison(Path.Combine(Root, session), Path.Combine(Inputs, "Budapest.epw"),
+ @"C:\Users\michal\Documents\GitHub\SAM-BIM\SAM_Tas\build\benchmark-tas.exe",
+ @"C:\Users\michal\Documents\GitHub\SAM-BIM\SAM_OpenStudio\benchmark\SAM.Analytical.OpenStudio.Benchmark\bin\x64\Release\net8.0\benchmark-openstudio.exe",
+ modelPath);
+ output.WriteLine("{0}: status={1} view={2} snapshot={3}", session, run.Status, ViewGuid(analyticalModel), run.SnapshotSha256);
+ foreach (EngineComparisonProducerRun producerRun in run.ProducerRuns)
+ {
+ JsonNode? provenance = producerRun.BenchmarkPath == null ? null : JsonNode.Parse(File.ReadAllText(producerRun.BenchmarkPath))?["provenance"];
+ output.WriteLine(" {0}: exit={1} canonicalModelHash={2} sourceFileHash={3}", producerRun.Engine, producerRun.ExitCode, provenance?["canonicalModelHash"], provenance?["sourceFileHash"]);
+ }
+ }
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("SAM_COMMIT", samCommit_Before);
+ Environment.SetEnvironmentVariable("PATH", path_Before);
+ }
+ }
+
+ /// The four historical S01b snapshots (pre-S04, four different canonical hashes) frozen again under S04.
+ [WpfFact]
+ public void E2_HistoricalSnapshots_RefrozenUnderS04()
+ {
+ if (!Enabled) return;
+ Directory.CreateDirectory(Root);
+ foreach (string path in Directory.GetFiles(@"C:\TasOut\s01b\runs", "snapshot.json", SearchOption.AllDirectories).OrderBy(x => x))
+ {
+ AnalyticalModel analyticalModel = Core.Convert.ToSAM(path).First();
+ string before = BenchmarkCanonicalJson.ComputeSha256(analyticalModel.ToJsonObject().ToJsonString());
+ string path_Refrozen = Path.Combine(Root, "refrozen-" + Path.GetFileName(Path.GetDirectoryName(Path.GetDirectoryName(path))!) + ".json");
+ AnalyticalModel? refrozen = analyticalModel.FreezeAnalyticalModel(path_Refrozen);
+ string after = BenchmarkCanonicalJson.ComputeSha256(Core.Convert.ToSAM(path_Refrozen).First().ToJsonObject().ToJsonString());
+ output.WriteLine("{0}: pre-S04 canonical {1} -> S04 canonical {2}, snapshot {3}", path.Replace(@"C:\TasOut\s01b\runs\", ""), before, after, Query.Sha256(path_Refrozen));
+ }
+ }
+
+ private static AnalyticalModel OpenAsTheWindowDoes(string path)
+ {
+ UIAnalyticalModel uIAnalyticalModel = new UIAnalyticalModel(path);
+ Assert.True(uIAnalyticalModel.Open());
+ AnalyticalModel analyticalModel = uIAnalyticalModel.JSAMObject;
+ if (!analyticalModel.TryGetValue(AnalyticalModelParameter.UIGeometrySettings, out UIGeometrySettings uIGeometrySettings) || uIGeometrySettings == null)
+ {
+ uIGeometrySettings = new UIGeometrySettings();
+ }
+
+ if (uIGeometrySettings.GetViewSettings() is not { Count: > 0 })
+ {
+ ViewSettings viewSettings = UI.Query.DefaultViewSettings(analyticalModel);
+ uIGeometrySettings.AddViewSettings(viewSettings);
+ uIGeometrySettings.ActiveGuid = viewSettings.Guid;
+ analyticalModel.SetValue(AnalyticalModelParameter.UIGeometrySettings, uIGeometrySettings);
+ }
+
+ return analyticalModel;
+ }
+
+ private static string ViewGuid(AnalyticalModel analyticalModel)
+ {
+ analyticalModel.TryGetValue(AnalyticalModelParameter.UIGeometrySettings, out UIGeometrySettings uIGeometrySettings);
+ return uIGeometrySettings?.ActiveGuid.ToString() ?? "(none)";
+ }
+ }
+}