diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c9f3144e..b469a661 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -108,6 +108,10 @@ jobs:
# CS0234. Same slot as BuildAlls_v4.csproj uses locally. Needs only SAM and SAM_Mollier,
# both already above.
'SAM_Validation'
+ # Simulate > OpenStudio Simulation (SAM_UI#229) references SAM_OpenStudio\build (its libraries need only SAM and
+ # SAM_SQLite; its benchmark needs SAM_Validation, above). Same order as SAM_Deploy's BuildAll.
+ 'SAM_SQLite'
+ 'SAM_OpenStudio'
'SAM_Tas'
'SAM_Excel'
'SAM_GEM'
@@ -199,6 +203,8 @@ jobs:
# CS0234. Same slot as BuildAlls_v4.csproj uses locally. Needs only SAM and SAM_Mollier,
# both already above.
'SAM_Validation'
+ 'SAM_SQLite'
+ 'SAM_OpenStudio'
'SAM_Tas'
'SAM_Excel'
'SAM_GEM'
@@ -207,6 +213,13 @@ jobs:
$repo
)
+ # Platform overrides, only where a solution needs one: the OpenStudio package's targets refuse AnyCPU, so
+ # SAM_OpenStudio is built x64 (as in its own CI and SAM_Deploy); that build puts the native OpenStudio files in
+ # SAM_OpenStudio\build, where SAM.Analytical.UI.WPF picks them up.
+ $platformBySolution = @{
+ 'SAM_OpenStudio' = 'x64'
+ }
+
# Convert build order -> solution paths (robust: first .sln in each folder)
$solutions = foreach ($r in $buildOrder) {
$expected = Join-Path $r "$r.sln"
@@ -220,15 +233,23 @@ jobs:
$sln.FullName
}
+ function Platform-Args([string]$sln) {
+ $name = Split-Path (Split-Path $sln -Parent) -Leaf
+ if ($platformBySolution.ContainsKey($name)) { return @("/p:Platform=$($platformBySolution[$name])") }
+ return @()
+ }
+
foreach ($sln in $solutions) {
- Write-Host "==> Restore: $sln"
- & msbuild $sln /t:Restore @common
+ $platformArgs = @(Platform-Args $sln)
+ Write-Host "==> Restore: $sln $platformArgs"
+ & msbuild $sln /t:Restore @common @platformArgs
if ($LASTEXITCODE -ne 0) { throw "MSBuild restore failed: $sln" }
}
foreach ($sln in $solutions) {
- Write-Host "==> Rebuild: $sln"
- & msbuild $sln /t:Rebuild @common
+ $platformArgs = @(Platform-Args $sln)
+ Write-Host "==> Rebuild: $sln $platformArgs"
+ & msbuild $sln /t:Rebuild @common @platformArgs
if ($LASTEXITCODE -ne 0) { throw "MSBuild rebuild failed: $sln" }
}
diff --git a/WPF/SAM.Analytical.UI.WPF.Tests/OpenStudioSimulationTests.cs b/WPF/SAM.Analytical.UI.WPF.Tests/OpenStudioSimulationTests.cs
new file mode 100644
index 00000000..7d63fe6f
--- /dev/null
+++ b/WPF/SAM.Analytical.UI.WPF.Tests/OpenStudioSimulationTests.cs
@@ -0,0 +1,262 @@
+// 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 Xunit;
+using Xunit.Abstractions;
+
+namespace SAM.Analytical.UI.WPF.Tests
+{
+ ///
+ /// Simulate > OpenStudio Simulation (SAM_UI#229). Everything except the last test runs without OpenStudio: the snapshot/copy
+ /// guarantee, the run folder and file names, the result model holding only this run's results, the manifest, and a failed run
+ /// leaving the open model as it was. The last test is the real run, and returns early on a machine without openstudio.exe.
+ ///
+ public class OpenStudioSimulationTests : IDisposable
+ {
+ private readonly ITestOutputHelper output;
+ private readonly string directory;
+
+ public OpenStudioSimulationTests(ITestOutputHelper output)
+ {
+ this.output = output;
+ directory = Path.Combine(Path.GetTempPath(), "SAM-UI-229-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(directory, true);
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+
+ private static AnalyticalModel Model(bool withTasResult = false)
+ {
+ AnalyticalModel analyticalModel = new AnalyticalModel(Guid.NewGuid(), "Test model");
+ Assert.True(analyticalModel.AddSpace(new Space("Room 1", new Geometry.Spatial.Point3D(1, 1, 1))));
+
+ if (withTasResult)
+ {
+ AnalyticalModelSimulationResult tasResult = new AnalyticalModelSimulationResult(analyticalModel.Name, AnalyticalModelSimulationResult.TasSource, analyticalModel.Guid.ToString("N"));
+ tasResult.SetValue(AnalyticalModelSimulationResultParameter.ConsumptionHeating, 1234.0);
+ Assert.True(analyticalModel.AddResult(tasResult));
+ }
+
+ return analyticalModel;
+ }
+
+ [Fact]
+ public void Freeze_WritesTheSnapshot_AndReturnsAnIndependentCopyOfIt()
+ {
+ AnalyticalModel analyticalModel = Model(withTasResult: true);
+ string json_Before = Core.Convert.ToString(analyticalModel);
+ string path = Path.Combine(directory, OpenStudioSimulationRun.SnapshotFileName);
+
+ AnalyticalModel frozen = analyticalModel.FreezeAnalyticalModel(path);
+
+ Assert.NotNull(frozen);
+ Assert.True(File.Exists(path));
+ Assert.NotSame(analyticalModel, frozen);
+ Assert.Equal(analyticalModel.Guid, frozen.Guid);
+ Assert.Equal(json_Before, Core.Convert.ToString(frozen));
+
+ //Changing the copy leaves the original alone.
+ frozen.Remove(frozen.GetResults());
+ Assert.Empty(frozen.GetResults());
+ Assert.Equal(json_Before, Core.Convert.ToString(analyticalModel));
+ }
+
+ [Fact]
+ public void AddOpenStudioResults_RemovesEveryEarlierResult_SoTheResultModelHoldsOnlyThisRunsResults()
+ {
+ AnalyticalModel frozen = Model(withTasResult: true);
+ Assert.Single(frozen.GetResults());
+
+ //No SQL: SAM_OpenStudio's AddResults attaches nothing, and no result set means no model-level result. What is left is
+ //the removal, which must hold whatever the run produced.
+ AnalyticalModel result = frozen.AddOpenStudioResults(Path.Combine(directory, "missing.sql"), null, out int removedCount, out int addedCount, out List _);
+
+ Assert.Equal(1, removedCount);
+ Assert.Equal(0, addedCount);
+ Assert.Empty(result.GetResults());
+ Assert.Empty(result.GetResults(AnalyticalModelSimulationResult.TasSource));
+ }
+
+ [Fact]
+ public void RunDirectory_IsUnderTheChosenFolder_NamedForTheModel_AndUniquePerRun()
+ {
+ DateTime startedUtc = new DateTime(2026, 10, 17, 9, 5, 7, DateTimeKind.Utc);
+
+ string first = Query.OpenStudioRunDirectory(directory, "Block A: Level 1", startedUtc, "0123abcd");
+ string second = Query.OpenStudioRunDirectory(directory, "Block A: Level 1", startedUtc, "89abcdef");
+
+ Assert.Equal(directory, Path.GetDirectoryName(first));
+ Assert.Equal("Block_A__Level_1_OpenStudio_20261017-090507_0123abcd", Path.GetFileName(first));
+ Assert.NotEqual(first, second);
+ Assert.Equal("Model_OpenStudio_20261017-090507_0123abcd", Path.GetFileName(Query.OpenStudioRunDirectory(directory, null, startedUtc, "0123abcd")));
+ Assert.Equal("Block_A__Level_1_OpenStudio.json", Query.OpenStudioResultModelFileName("Block A: Level 1"));
+ Assert.Equal(64, Path.GetFileNameWithoutExtension(Query.OpenStudioResultModelFileName(new string('x', 200))).Length - "_OpenStudio".Length);
+ }
+
+ [Fact]
+ public void Sha256_IsTheB1aNotationOfTheFileBytes()
+ {
+ string path = Path.Combine(directory, "abc.txt");
+ File.WriteAllText(path, "abc");
+
+ Assert.Equal("sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", Query.Sha256(path));
+ Assert.Null(Query.Sha256(Path.Combine(directory, "none.txt")));
+ }
+
+ [Fact]
+ public void LoadFailure_IsRecognised_ThroughWrappers_AndOtherFailuresAreNot()
+ {
+ Assert.True(Query.IsOpenStudioLoadFailure(new DllNotFoundException("openstudio_csharp")));
+ Assert.True(Query.IsOpenStudioLoadFailure(new TypeInitializationException("OpenStudio.OpenStudioUtilitiesCorePINVOKE", new DllNotFoundException("openstudiolib"))));
+ Assert.True(Query.IsOpenStudioLoadFailure(new FileNotFoundException("missing", "SAM.Analytical.OpenStudio, Version=1.0.0.0")));
+ Assert.False(Query.IsOpenStudioLoadFailure(new FileNotFoundException("missing", @"C:\weather\city.epw")));
+ Assert.False(Query.IsOpenStudioLoadFailure(new InvalidOperationException("model")));
+ Assert.False(Query.IsOpenStudioLoadFailure(null));
+
+ Assert.Contains("openstudiolib.dll", Query.OpenStudioLoadFailure(new DllNotFoundException("openstudiolib")));
+ }
+
+ [Fact]
+ public void Manifest_WritesPathsInsideTheRunFolderRelatively_AndKeepsOthersAbsolute()
+ {
+ string runDirectory = Path.Combine(directory, "run1");
+ string cliPath = Path.Combine(directory, "OpenStudio", "bin", "openstudio.exe");
+ OpenStudioSimulationRun run = new OpenStudioSimulationRun()
+ {
+ Id = "0123abcd",
+ Status = OpenStudioSimulationStatus.Succeeded,
+ StartedUtc = new DateTime(2026, 10, 17, 9, 0, 0, DateTimeKind.Utc),
+ FinishedUtc = new DateTime(2026, 10, 17, 9, 1, 30, DateTimeKind.Utc),
+ Directory = runDirectory,
+ SnapshotPath = Path.Combine(runDirectory, OpenStudioSimulationRun.SnapshotFileName),
+ SnapshotSha256 = "sha256:00",
+ SqlPath = Path.Combine(runDirectory, "run", "eplusout.sql"),
+ CliPath = cliPath,
+ ResultCount = 7,
+ };
+
+ JsonObject jsonObject = JsonNode.Parse(run.ToJson()).AsObject();
+
+ Assert.Equal(OpenStudioSimulationRun.Schema, (string)jsonObject["schema"]);
+ Assert.Equal("Succeeded", (string)jsonObject["status"]);
+ Assert.Equal(90.0, (double)jsonObject["durationSeconds"]);
+ Assert.Equal("snapshot.json", (string)jsonObject["snapshot"]["path"]);
+ Assert.Equal("run/eplusout.sql", (string)jsonObject["outputs"]["sql"]);
+ Assert.Equal(cliPath, (string)jsonObject["engine"]["cliPath"]);
+ Assert.Equal("Native-OpenStudio", (string)jsonObject["engine"]["route"]);
+ Assert.Equal(7, (int)jsonObject["resultCount"]);
+ }
+
+ [Fact]
+ public void Manifest_WithDiagnosticsAndAnError_IsWritten()
+ {
+ //A real run nearly always has diagnostics; the manifest must still serialise (it once threw on the first diagnostic).
+ OpenStudioSimulationRun run = new OpenStudioSimulationRun() { Id = "0123abcd", Directory = directory, Exception = new InvalidOperationException("boom") };
+ run.Diagnostics.Add("Warning SAM-OS-RUN-001: a warning");
+ run.Diagnostics.Add("Error SAM-OS-RUN-001: an error");
+ run.ResultDiagnostics.Add("No simulation result found for space 'Cell 1'");
+
+ JsonObject jsonObject = JsonNode.Parse(run.ToJson()).AsObject();
+
+ Assert.Equal(2, jsonObject["diagnostics"].AsArray().Count);
+ Assert.Equal("Error SAM-OS-RUN-001: an error", (string)jsonObject["diagnostics"][1]);
+ Assert.Single(jsonObject["resultDiagnostics"].AsArray());
+ Assert.Equal("InvalidOperationException: boom", (string)jsonObject["error"]);
+ }
+
+ [Fact]
+ public void Run_WithoutAModelOrAFolder_FailsWithoutThrowing()
+ {
+ OpenStudioSimulationRun run = Modify.RunOpenStudioSimulation(null, directory);
+
+ Assert.Equal(OpenStudioSimulationStatus.Failed, run.Status);
+ Assert.Null(run.Directory);
+ Assert.NotEmpty(run.Diagnostics);
+ }
+
+ ///
+ /// A model with no weather and no EPW: SAM_OpenStudio blocks the run (on a machine with the OpenStudio stack) or the stack
+ /// cannot load (on one without). Either way the run fails, records why, keeps its snapshot and manifest - and the open model is
+ /// exactly as it was.
+ ///
+ [Fact]
+ public void FailedRun_LeavesTheModelUnchanged_AndRecordsItself()
+ {
+ AnalyticalModel analyticalModel = Model(withTasResult: true);
+ string json_Before = Core.Convert.ToString(analyticalModel);
+
+ OpenStudioSimulationRun run = analyticalModel.RunOpenStudioSimulation(directory, null, @"C:\models\source.json");
+
+ output.WriteLine(Query.OpenStudioSimulationSummary(run));
+
+ Assert.Equal(OpenStudioSimulationStatus.Failed, run.Status);
+ Assert.Null(run.ResultModelPath);
+ Assert.True(run.Exception != null || run.Diagnostics.Any(x => x.StartsWith("Error", StringComparison.Ordinal)));
+ Assert.Equal(json_Before, Core.Convert.ToString(analyticalModel));
+
+ Assert.True(File.Exists(run.SnapshotPath));
+ Assert.StartsWith("sha256:", run.SnapshotSha256);
+ Assert.True(File.Exists(run.ManifestPath));
+ JsonObject manifest = JsonNode.Parse(File.ReadAllText(run.ManifestPath)).AsObject();
+ Assert.Equal("Failed", (string)manifest["status"]);
+ Assert.Equal(@"C:\models\source.json", (string)manifest["sourcePath"]);
+ Assert.Contains("failed", Query.OpenStudioSimulationSummary(run));
+ }
+
+ ///
+ /// The real run (gate G1's automated part): SAM_OpenStudio's two-cell fixture with its embedded weather, through openstudio.exe.
+ /// Returns early - and says so - where OpenStudio is not installed or the SAM_OpenStudio checkout is not beside SAM_UI;
+ /// SAM_OPENSTUDIO_UI_TEST_MODEL / SAM_OPENSTUDIO_UI_TEST_EPW override the model and add an explicit EPW.
+ ///
+ [Fact]
+ [Trait("Category", "Simulation")]
+ public void RealRun_SimulatesAFrozenCopy_AndSavesAResultModelWithOnlyOpenStudioResults()
+ {
+ string cliPath = Core.OpenStudio.Query.OpenStudioCliPath(null);
+ string modelPath = Environment.GetEnvironmentVariable("SAM_OPENSTUDIO_UI_TEST_MODEL") ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, @"..\..\..\..\..\SAM_OpenStudio\tests\resources\models\rawModel-WindowsWeatherDataDDYAdiabaticAirPanel.sam"));
+ string epwPath = Environment.GetEnvironmentVariable("SAM_OPENSTUDIO_UI_TEST_EPW");
+ if (cliPath == null || !File.Exists(modelPath))
+ {
+ output.WriteLine(string.Format("NOT RUN: openstudio.exe {0}; model {1} {2}", cliPath ?? "not found", modelPath, File.Exists(modelPath) ? "found" : "not found"));
+ return;
+ }
+
+ AnalyticalModel analyticalModel = Core.Convert.ToSAM(modelPath).First();
+ string json_Before = Core.Convert.ToString(analyticalModel);
+
+ OpenStudioSimulationRun run = analyticalModel.RunOpenStudioSimulation(directory, epwPath, modelPath);
+
+ output.WriteLine(Query.OpenStudioSimulationSummary(run, int.MaxValue));
+ output.WriteLine(run.ToJson());
+
+ Assert.Equal(OpenStudioSimulationStatus.Succeeded, run.Status);
+ Assert.Equal(0, run.FatalCount);
+ Assert.True(File.Exists(run.SqlPath));
+ Assert.True(File.Exists(run.WeatherPath));
+ Assert.Equal(json_Before, Core.Convert.ToString(analyticalModel));
+
+ AnalyticalModel result = Core.Convert.ToSAM(run.ResultModelPath).First();
+ List results = result.GetResults();
+ Assert.NotEmpty(results);
+ Assert.All(results, x => Assert.Equal(AnalyticalModelSimulationResult.OpenStudioSource, x.Source));
+ Assert.Single(result.GetAnalyticalModelSimulationResults());
+ }
+ }
+}
diff --git a/WPF/SAM.Analytical.UI.WPF.Tests/SAM.Analytical.UI.WPF.Tests.csproj b/WPF/SAM.Analytical.UI.WPF.Tests/SAM.Analytical.UI.WPF.Tests.csproj
index 1e82b996..f01e3cfd 100644
--- a/WPF/SAM.Analytical.UI.WPF.Tests/SAM.Analytical.UI.WPF.Tests.csproj
+++ b/WPF/SAM.Analytical.UI.WPF.Tests/SAM.Analytical.UI.WPF.Tests.csproj
@@ -95,6 +95,14 @@
..\..\..\SAM\build\SAM.Core.Optimisation.dll
+
+
+ ..\..\..\SAM_OpenStudio\build\SAM.Analytical.OpenStudio.dll
+
+
+ ..\..\..\SAM_OpenStudio\build\SAM.Core.OpenStudio.dll
+
diff --git a/WPF/SAM.Analytical.UI.WPF/Classes/OpenStudio/OpenStudioSimulationRun.cs b/WPF/SAM.Analytical.UI.WPF/Classes/OpenStudio/OpenStudioSimulationRun.cs
new file mode 100644
index 00000000..d4750599
--- /dev/null
+++ b/WPF/SAM.Analytical.UI.WPF/Classes/OpenStudio/OpenStudioSimulationRun.cs
@@ -0,0 +1,208 @@
+// 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.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace SAM.Analytical.UI.WPF
+{
+ /// How one Simulate > OpenStudio run ended.
+ public enum OpenStudioSimulationStatus
+ {
+ /// EnergyPlus ran, and its results were attached to the result model and saved.
+ Succeeded,
+
+ /// The conversion, the run or reading the results failed; the diagnostics say where.
+ Failed,
+
+ /// The user cancelled; the CLI/EnergyPlus process tree was terminated and no result model was saved.
+ Cancelled,
+ }
+
+ ///
+ /// The record of one Simulate > OpenStudio run (SAM_UI#229): what was simulated, with which weather, where every output
+ /// is, and how it ended. It is written next to the outputs as , so a run folder explains
+ /// itself without SAM_UI open.
+ ///
+ /// It deliberately holds no OpenStudio type, so reading it never loads the OpenStudio bindings. It is a provenance record,
+ /// not a comparison: the snapshot and weather hashes are SHA-256 over the exact file bytes (the B1a sourceFileHash
+ /// sense); the canonical model hash and every Tas/OpenStudio comparison belong to SAM_Validation (SAM_UI#231).
+ ///
+ ///
+ public sealed class OpenStudioSimulationRun
+ {
+ /// Schema identifier written into the manifest.
+ public const string Schema = "sam-ui.openstudio-run/1";
+
+ /// The manifest's file name inside the run folder.
+ public const string ManifestFileName = "openstudio-run.json";
+
+ /// The frozen input model's file name inside the run folder.
+ public const string SnapshotFileName = "snapshot.json";
+
+ /// The copy of the weather file used by the run, inside the run folder.
+ public const string WeatherFileName = "weather.epw";
+
+ public string? Id { get; set; }
+
+ public OpenStudioSimulationStatus Status { get; set; } = OpenStudioSimulationStatus.Failed;
+
+ public DateTime StartedUtc { get; set; }
+
+ public DateTime FinishedUtc { get; set; }
+
+ /// The run folder: every file of this run is in it.
+ public string? Directory { get; set; }
+
+ /// The open model's file, when it had one. Never written to by the run.
+ public string? SourcePath { get; set; }
+
+ public string? ModelName { get; set; }
+
+ public Guid ModelGuid { get; set; }
+
+ public string? SnapshotPath { get; set; }
+
+ public string? SnapshotSha256 { get; set; }
+
+ /// "File" (chosen by the user), "Embedded" (the model's WeatherData) or "None".
+ public string? WeatherSource { get; set; }
+
+ /// The copy of the EPW used, in the run folder; null when there was none.
+ public string? WeatherPath { get; set; }
+
+ public string? WeatherSha256 { get; set; }
+
+ /// The SAM.Analytical.OpenStudio build that converted the model (its InformationalVersion).
+ public string? SamOpenStudioVersion { get; set; }
+
+ /// The openstudio.exe that ran the simulation; null when none was started.
+ public string? CliPath { get; set; }
+
+ public string? OsmPath { get; set; }
+
+ public string? OswPath { get; set; }
+
+ public string? SqlPath { get; set; }
+
+ public string? ErrorFilePath { get; set; }
+
+ public int? ExitCode { get; set; }
+
+ public int WarningCount { get; set; }
+
+ public int SevereCount { get; set; }
+
+ public int FatalCount { get; set; }
+
+ /// The saved result model: the frozen copy carrying this run's OpenStudio results and no other results.
+ public string? ResultModelPath { get; set; }
+
+ /// Number of results (model, space and surface) attached to the result model.
+ public int ResultCount { get; set; }
+
+ /// Number of results the frozen copy already carried (e.g. from Tas) and the result model does not.
+ public int RemovedResultCount { get; set; }
+
+ /// Conversion and run diagnostics from SAM_OpenStudio, one line each ("Severity Code: message").
+ public List Diagnostics { get; } = new List();
+
+ /// Diagnostics of attaching the SQL results to SAM objects (unmatched zones, spaces without a result).
+ public List ResultDiagnostics { get; } = new List();
+
+ /// An unexpected failure, kept for the caller; only its text is written to the manifest.
+ public Exception? Exception { get; set; }
+
+ public string? ManifestPath { get; set; }
+
+ public JsonObject ToJsonObject()
+ {
+ JsonObject result = new JsonObject
+ {
+ ["schema"] = Schema,
+ ["id"] = Id,
+ ["status"] = Status.ToString(),
+ ["startedUtc"] = StartedUtc.ToString("o"),
+ ["finishedUtc"] = FinishedUtc.ToString("o"),
+ ["durationSeconds"] = System.Math.Round((FinishedUtc - StartedUtc).TotalSeconds, 3),
+ ["sourcePath"] = SourcePath,
+ ["model"] = new JsonObject
+ {
+ ["name"] = ModelName,
+ ["guid"] = ModelGuid.ToString("N"),
+ },
+ ["snapshot"] = new JsonObject
+ {
+ ["path"] = RelativePath(SnapshotPath),
+ ["sha256"] = SnapshotSha256,
+ },
+ ["weather"] = new JsonObject
+ {
+ ["source"] = WeatherSource,
+ ["path"] = RelativePath(WeatherPath),
+ ["sha256"] = WeatherSha256,
+ },
+ ["engine"] = new JsonObject
+ {
+ ["route"] = "Native-OpenStudio",
+ ["samOpenStudioVersion"] = SamOpenStudioVersion,
+ ["cliPath"] = CliPath,
+ ["exitCode"] = ExitCode,
+ ["warningCount"] = WarningCount,
+ ["severeCount"] = SevereCount,
+ ["fatalCount"] = FatalCount,
+ },
+ ["outputs"] = new JsonObject
+ {
+ ["osm"] = RelativePath(OsmPath),
+ ["osw"] = RelativePath(OswPath),
+ ["sql"] = RelativePath(SqlPath),
+ ["err"] = RelativePath(ErrorFilePath),
+ ["resultModel"] = RelativePath(ResultModelPath),
+ },
+ ["resultCount"] = ResultCount,
+ ["removedResultCount"] = RemovedResultCount,
+ ["diagnostics"] = ToJsonArray(Diagnostics),
+ ["resultDiagnostics"] = ToJsonArray(ResultDiagnostics),
+ ["error"] = Exception == null ? null : Exception.GetType().Name + ": " + Exception.Message,
+ };
+
+ return result;
+ }
+
+ public string ToJson()
+ {
+ return ToJsonObject().ToJsonString(new JsonSerializerOptions { WriteIndented = true });
+ }
+
+ ///
+ /// Paths inside the run folder are written relative to it, so a copied or archived run folder stays self-consistent;
+ /// anything outside it (the CLI, say) stays absolute.
+ ///
+ private string? RelativePath(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(Directory))
+ {
+ return path;
+ }
+
+ string relativePath = System.IO.Path.GetRelativePath(Directory!, path!);
+ return relativePath.StartsWith("..", StringComparison.Ordinal) || System.IO.Path.IsPathRooted(relativePath) ? path : relativePath.Replace('\\', '/');
+ }
+
+ private static JsonArray ToJsonArray(IEnumerable values)
+ {
+ JsonArray result = new JsonArray();
+ foreach (string value in values)
+ {
+ //JsonValue.Create, not Add(value): the generic Add makes a value that ToJson's options (no TypeInfoResolver)
+ //cannot write, so any run with a diagnostic threw while writing its manifest.
+ result.Add(JsonValue.Create(value));
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/WPF/SAM.Analytical.UI.WPF/Modify/RunOpenStudioSimulation.cs b/WPF/SAM.Analytical.UI.WPF/Modify/RunOpenStudioSimulation.cs
new file mode 100644
index 00000000..e25ea7d9
--- /dev/null
+++ b/WPF/SAM.Analytical.UI.WPF/Modify/RunOpenStudioSimulation.cs
@@ -0,0 +1,299 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020-2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using SAM.Analytical.OpenStudio;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Threading;
+
+namespace SAM.Analytical.UI.WPF
+{
+ public static partial class Modify
+ {
+ ///
+ /// Runs an annual OpenStudio/EnergyPlus simulation of through SAM_OpenStudio's own pipeline
+ /// (ToOpenStudio: conversion, OSM/OSW, the openstudio.exe CLI, SQL results), into a new run folder under
+ /// (SAM_UI#229).
+ ///
+ /// The model given is never changed. It is frozen first to , and
+ /// everything after works on the copy read back from that file - so the input of the run is exactly the snapshot's bytes, and
+ /// edits made to the open model while the run is going cannot reach it.
+ ///
+ ///
+ /// On success the copy, with every result it already carried removed (Tas results, say) and this run's OpenStudio results
+ /// attached, is saved as the result model (). The results are attached by
+ /// SAM_OpenStudio's AddResults - the route its AddResultsBySQL component uses - plus the model-level
+ /// AnalyticalModelSimulationResult; nothing is extracted or mapped here.
+ ///
+ ///
+ /// Never throws for a failed run: the outcome, the diagnostics and any exception are on the returned record, which is also
+ /// written to the run folder as . Safe to call off the UI thread.
+ ///
+ ///
+ /// The model to simulate; read only.
+ /// The folder the run folder is created in.
+ /// An EPW chosen by the user; null to use the model's embedded WeatherData (SAM_OpenStudio's own precedence).
+ /// The open model's file, recorded for provenance only.
+ /// SAM_OpenStudio's stage progress (saving, CLI, reading results).
+ /// Cancels the run; the CLI/EnergyPlus process tree is killed.
+ public static OpenStudioSimulationRun RunOpenStudioSimulation(this AnalyticalModel analyticalModel, string directory, string? epwPath = null, string? sourcePath = null, IProgress? progress = null, CancellationToken cancellationToken = default)
+ {
+ OpenStudioSimulationRun result = new OpenStudioSimulationRun()
+ {
+ Id = Guid.NewGuid().ToString("N").Substring(0, 8),
+ StartedUtc = DateTime.UtcNow,
+ SourcePath = sourcePath,
+ ModelName = analyticalModel?.Name,
+ ModelGuid = analyticalModel?.Guid ?? Guid.Empty,
+ };
+
+ if (analyticalModel == null || string.IsNullOrWhiteSpace(directory))
+ {
+ result.Diagnostics.Add("Error: no model or no output folder was given; nothing was run");
+ result.FinishedUtc = DateTime.UtcNow;
+ return result;
+ }
+
+ result.Directory = Query.OpenStudioRunDirectory(directory, analyticalModel.Name, result.StartedUtc, result.Id!);
+
+ try
+ {
+ System.IO.Directory.CreateDirectory(result.Directory);
+
+ result.SnapshotPath = Path.Combine(result.Directory, OpenStudioSimulationRun.SnapshotFileName);
+ AnalyticalModel? analyticalModel_Frozen = FreezeAnalyticalModel(analyticalModel, result.SnapshotPath);
+ if (analyticalModel_Frozen == null)
+ {
+ result.Diagnostics.Add("Error: the model could not be written to and read back from the snapshot file; nothing was run");
+ return result;
+ }
+
+ result.SnapshotSha256 = Query.Sha256(result.SnapshotPath);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ RunOpenStudioSimulation_Run(result, analyticalModel_Frozen, epwPath, progress, cancellationToken);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ result.Status = OpenStudioSimulationStatus.Cancelled;
+ }
+ catch (Exception exception)
+ {
+ result.Status = OpenStudioSimulationStatus.Failed;
+ result.Exception = exception;
+ }
+ finally
+ {
+ result.FinishedUtc = DateTime.UtcNow;
+ WriteManifest(result);
+ }
+
+ return result;
+ }
+
+ ///
+ /// 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.
+ ///
+ public static AnalyticalModel? FreezeAnalyticalModel(this AnalyticalModel analyticalModel, string path)
+ {
+ if (analyticalModel == null || string.IsNullOrWhiteSpace(path))
+ {
+ return null;
+ }
+
+ if (!Core.Convert.ToFile(analyticalModel, path))
+ {
+ return null;
+ }
+
+ return Core.Convert.ToSAM(path)?.FirstOrDefault();
+ }
+
+ ///
+ /// The result model of a successful run: with every result it carried removed, then this
+ /// run's results attached from (SAM_OpenStudio AddResults) and the model-level result from
+ /// . Removing first keeps the file unambiguous: every result in it is OpenStudio's,
+ /// from this run.
+ ///
+ public static AnalyticalModel AddOpenStudioResults(this AnalyticalModel analyticalModel_Frozen, string sqlPath, OpenStudioSimulationResultSet? openStudioSimulationResultSet, out int removedCount, out int addedCount, out List diagnostics)
+ {
+ removedCount = 0;
+ addedCount = 0;
+
+ List? results_Existing = analyticalModel_Frozen.GetResults();
+ if (results_Existing != null && results_Existing.Count != 0)
+ {
+ removedCount = analyticalModel_Frozen.Remove(results_Existing)?.Count ?? 0;
+ }
+
+ AdjacencyCluster adjacencyCluster = analyticalModel_Frozen.AdjacencyCluster ?? new AdjacencyCluster();
+
+ List? results_Added = adjacencyCluster.AddResults(sqlPath, out diagnostics);
+ addedCount = results_Added?.Count ?? 0;
+
+ AnalyticalModel result = new AnalyticalModel(analyticalModel_Frozen, adjacencyCluster);
+
+ AnalyticalModelSimulationResult? analyticalModelSimulationResult = openStudioSimulationResultSet?.ToSAM(result);
+ if (analyticalModelSimulationResult != null && result.AddResult(analyticalModelSimulationResult))
+ {
+ addedCount++;
+ }
+
+ return result;
+ }
+
+ // 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)
+ {
+ openStudioSimulationRun.SamOpenStudioVersion = typeof(OpenStudioConversionResult).Assembly.GetCustomAttribute()?.InformationalVersion ?? typeof(OpenStudioConversionResult).Assembly.GetName().Version?.ToString();
+
+ //The weather the converter will use, by its own precedence: the user's EPW, else the model's embedded WeatherData. The user's
+ //path is passed on as given (null for embedded), so the conversion is exactly SAM_OpenStudio's; the resolved file is only
+ //copied into the run folder, so the run keeps its weather when the original or the temporary export is gone.
+ string? epwPath_User = !string.IsNullOrWhiteSpace(epwPath) && File.Exists(epwPath) ? epwPath : null;
+ string? epwPath_Effective = epwPath_User ?? analyticalModel_Frozen.EmbeddedAnnualWeatherPath();
+ openStudioSimulationRun.WeatherSource = epwPath_User != null ? "File" : epwPath_Effective != null ? "Embedded" : "None";
+ if (epwPath_Effective != null)
+ {
+ openStudioSimulationRun.WeatherPath = Path.Combine(openStudioSimulationRun.Directory!, OpenStudioSimulationRun.WeatherFileName);
+ File.Copy(epwPath_Effective, openStudioSimulationRun.WeatherPath, true);
+ openStudioSimulationRun.WeatherSha256 = Query.Sha256(openStudioSimulationRun.WeatherPath);
+ }
+
+ Core.OpenStudio.OpenStudioRunOptions openStudioRunOptions = new Core.OpenStudio.OpenStudioRunOptions()
+ {
+ //The run folder is already this run's own, so the OSM, OSW and run\ go straight into it.
+ UseUniqueRunDirectory = false,
+ };
+
+ OpenStudioProgress progress_Run = new OpenStudioProgress(openStudioSimulationRun, progress);
+
+ using OpenStudioConversionResult openStudioConversionResult = analyticalModel_Frozen.ToOpenStudio(epwPath_User, openStudioSimulationRun.Directory, null, openStudioRunOptions, true, progress_Run, cancellationToken);
+
+ if (openStudioConversionResult == null)
+ {
+ openStudioSimulationRun.Diagnostics.Add("Error: SAM_OpenStudio returned no conversion result");
+ return;
+ }
+
+ foreach (Core.OpenStudio.OpenStudioDiagnostic openStudioDiagnostic in openStudioConversionResult.Diagnostics)
+ {
+ if (openStudioDiagnostic != null)
+ {
+ openStudioSimulationRun.Diagnostics.Add(string.Format("{0} {1}: {2}", openStudioDiagnostic.Severity, openStudioDiagnostic.Code, openStudioDiagnostic.Message));
+ }
+ }
+
+ openStudioSimulationRun.OsmPath = openStudioConversionResult.OsmPath;
+ openStudioSimulationRun.OswPath = openStudioConversionResult.OswPath;
+
+ Core.OpenStudio.OpenStudioRunResult? openStudioRunResult = openStudioConversionResult.RunResult;
+ if (openStudioRunResult != null)
+ {
+ openStudioSimulationRun.ExitCode = openStudioRunResult.ExitCode;
+ openStudioSimulationRun.SqlPath = File.Exists(openStudioRunResult.SqlPath) ? openStudioRunResult.SqlPath : null;
+ openStudioSimulationRun.ErrorFilePath = File.Exists(openStudioRunResult.ErrorFilePath) ? openStudioRunResult.ErrorFilePath : null;
+ openStudioSimulationRun.SevereCount = openStudioRunResult.SevereErrors?.Count ?? 0;
+ openStudioSimulationRun.FatalCount = openStudioRunResult.FatalErrors?.Count ?? 0;
+ }
+
+ OpenStudioSimulationResultSet? openStudioSimulationResultSet = openStudioConversionResult.Results;
+ if (openStudioSimulationResultSet != null)
+ {
+ openStudioSimulationRun.WarningCount = openStudioSimulationResultSet.WarningCount;
+ openStudioSimulationRun.SevereCount = openStudioSimulationResultSet.SevereCount;
+ openStudioSimulationRun.FatalCount = openStudioSimulationResultSet.FatalCount;
+ }
+
+ //SAM_OpenStudio reports a cancel as a warning, not as an exception; the token is the authority.
+ if (cancellationToken.IsCancellationRequested)
+ {
+ openStudioSimulationRun.Status = OpenStudioSimulationStatus.Cancelled;
+ return;
+ }
+
+ if (openStudioRunResult == null || !openStudioRunResult.Success || openStudioSimulationRun.SqlPath == null)
+ {
+ openStudioSimulationRun.Status = OpenStudioSimulationStatus.Failed;
+ return;
+ }
+
+ progress?.Report(new Core.OpenStudio.OpenStudioSimulationProgress(Core.OpenStudio.OpenStudioSimulationStage.ReadingResults, "Attaching the results to the result model"));
+
+ AnalyticalModel analyticalModel_Result = analyticalModel_Frozen.AddOpenStudioResults(openStudioSimulationRun.SqlPath, openStudioSimulationResultSet, out int removedCount, out int addedCount, out List diagnostics);
+ openStudioSimulationRun.RemovedResultCount = removedCount;
+ openStudioSimulationRun.ResultCount = addedCount;
+ if (diagnostics != null)
+ {
+ openStudioSimulationRun.ResultDiagnostics.AddRange(diagnostics);
+ }
+
+ string resultModelPath = Path.Combine(openStudioSimulationRun.Directory!, Query.OpenStudioResultModelFileName(analyticalModel_Frozen.Name));
+ if (!Core.Convert.ToFile(analyticalModel_Result, resultModelPath))
+ {
+ openStudioSimulationRun.Diagnostics.Add("Error: the result model could not be saved to " + resultModelPath);
+ openStudioSimulationRun.Status = OpenStudioSimulationStatus.Failed;
+ return;
+ }
+
+ openStudioSimulationRun.ResultModelPath = resultModelPath;
+ openStudioSimulationRun.Status = OpenStudioSimulationStatus.Succeeded;
+ }
+
+ private static void WriteManifest(OpenStudioSimulationRun openStudioSimulationRun)
+ {
+ if (string.IsNullOrWhiteSpace(openStudioSimulationRun?.Directory) || !System.IO.Directory.Exists(openStudioSimulationRun!.Directory))
+ {
+ return;
+ }
+
+ string path = Path.Combine(openStudioSimulationRun.Directory!, OpenStudioSimulationRun.ManifestFileName);
+ try
+ {
+ File.WriteAllText(path, openStudioSimulationRun.ToJson());
+ openStudioSimulationRun.ManifestPath = path;
+ }
+ catch (Exception exception)
+ {
+ //Written from a finally on a worker thread: whatever goes wrong here must not take the run, or the application, down.
+ openStudioSimulationRun.Diagnostics.Add("Warning: the run manifest could not be written: " + exception.GetType().Name + ": " + exception.Message);
+ }
+ }
+
+ /// Passes SAM_OpenStudio's stage reports on, noting which openstudio.exe ran (the CLI stage's message is its path).
+ private sealed class OpenStudioProgress : IProgress
+ {
+ private readonly OpenStudioSimulationRun openStudioSimulationRun;
+ private readonly IProgress? progress;
+
+ public OpenStudioProgress(OpenStudioSimulationRun openStudioSimulationRun, IProgress? progress)
+ {
+ this.openStudioSimulationRun = openStudioSimulationRun;
+ this.progress = progress;
+ }
+
+ public void Report(Core.OpenStudio.OpenStudioSimulationProgress value)
+ {
+ if (value == null)
+ {
+ return;
+ }
+
+ if (value.Stage == Core.OpenStudio.OpenStudioSimulationStage.RunningCli)
+ {
+ openStudioSimulationRun.CliPath = value.Message;
+ }
+
+ progress?.Report(value);
+ }
+ }
+ }
+}
diff --git a/WPF/SAM.Analytical.UI.WPF/Modify/SimulateOpenStudio.cs b/WPF/SAM.Analytical.UI.WPF/Modify/SimulateOpenStudio.cs
new file mode 100644
index 00000000..c25650ef
--- /dev/null
+++ b/WPF/SAM.Analytical.UI.WPF/Modify/SimulateOpenStudio.cs
@@ -0,0 +1,179 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020-2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using SAM.Core.Windows.WPF;
+using SAM.Weather;
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+
+namespace SAM.Analytical.UI.WPF
+{
+ public static partial class Modify
+ {
+ ///
+ /// Simulate > OpenStudio (SAM_UI#229): asks for the output folder and the weather, then runs
+ ///
+ /// on a worker thread, with a progress window whose Cancel kills the OpenStudio/EnergyPlus process tree.
+ ///
+ /// The UI is not blocked while the run goes, and the open model is never changed: the run works on a frozen copy and
+ /// saves its own result model in its own run folder. The Tas workflow is not touched. Null when the user cancelled a dialog
+ /// before the run started.
+ ///
+ ///
+ public static async Task SimulateOpenStudioAsync(this UIAnalyticalModel uIAnalyticalModel, System.Windows.Window? owner = null)
+ {
+ //The getter returns a deep clone, so later edits of the open model cannot reach this one.
+ AnalyticalModel? analyticalModel = uIAnalyticalModel?.JSAMObject;
+ if (analyticalModel == null)
+ {
+ return null;
+ }
+
+ string? path = uIAnalyticalModel!.Path;
+
+ string? directory = null;
+ using (System.Windows.Forms.FolderBrowserDialog folderBrowserDialog = new()
+ {
+ Description = "Select the folder for the OpenStudio run (a new run folder is created in it)",
+ UseDescriptionForTitle = true,
+ SelectedPath = (string.IsNullOrWhiteSpace(path) ? null : Path.GetDirectoryName(path)) ?? Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
+ })
+ {
+ if (folderBrowserDialog.ShowDialog() != System.Windows.Forms.DialogResult.OK || string.IsNullOrWhiteSpace(folderBrowserDialog.SelectedPath))
+ {
+ return null;
+ }
+
+ directory = folderBrowserDialog.SelectedPath;
+ }
+
+ //SAM_OpenStudio's precedence: an EPW chosen here, else the model's embedded WeatherData.
+ string? epwPath = null;
+ bool embeddedWeather = analyticalModel.TryGetValue(Analytical.AnalyticalModelParameter.WeatherData, out WeatherData? weatherData) && weatherData != null;
+ if (embeddedWeather)
+ {
+ MessageBoxResult messageBoxResult = ShowOpenStudioMessage(owner, "Use the model's weather data for the OpenStudio run?\n\nYes: the model's weather data.\nNo: choose an EPW file.", MessageBoxButton.YesNoCancel, MessageBoxImage.Question);
+ if (messageBoxResult == MessageBoxResult.Cancel)
+ {
+ return null;
+ }
+
+ embeddedWeather = messageBoxResult == MessageBoxResult.Yes;
+ }
+
+ if (!embeddedWeather)
+ {
+ Microsoft.Win32.OpenFileDialog openFileDialog = new Microsoft.Win32.OpenFileDialog()
+ {
+ Title = "Select the EPW weather file for the OpenStudio run",
+ Filter = "epw files (*.epw)|*.epw|All files (*.*)|*.*",
+ };
+
+ bool? dialogResult = owner == null ? openFileDialog.ShowDialog() : openFileDialog.ShowDialog(owner);
+ if (dialogResult != true || string.IsNullOrWhiteSpace(openFileDialog.FileName))
+ {
+ return null;
+ }
+
+ epwPath = openFileDialog.FileName;
+ }
+
+ string directory_Run = directory;
+
+ OpenStudioSimulationRun openStudioSimulationRun;
+ using (CancellationTokenSource cancellationTokenSource = new CancellationTokenSource())
+ {
+ //The same host the Tas workflow uses: the dialog lives on its own thread, so Cancel always answers.
+ ProgressWindowHost progressWindowHost = new ProgressWindowHost("OpenStudio Simulation", 5, true, "Cancel stops the OpenStudio/EnergyPlus run. The open model is not changed.");
+ try
+ {
+ progressWindowHost.CancelRequested += (s, e) => cancellationTokenSource.Cancel();
+ progressWindowHost.Update("Freezing and converting the model", false);
+
+ OpenStudioProgressReport progress = new OpenStudioProgressReport(progressWindowHost);
+ CancellationToken cancellationToken = cancellationTokenSource.Token;
+
+ openStudioSimulationRun = await Task.Run(() => analyticalModel.RunOpenStudioSimulation(directory_Run, epwPath, path, progress, cancellationToken));
+ }
+ finally
+ {
+ progressWindowHost.Dispose();
+ }
+ }
+
+ ShowOpenStudioSimulationRun(openStudioSimulationRun, owner);
+
+ return openStudioSimulationRun;
+ }
+
+ private static void ShowOpenStudioSimulationRun(OpenStudioSimulationRun openStudioSimulationRun, System.Windows.Window? owner)
+ {
+ string text = Query.OpenStudioSimulationSummary(openStudioSimulationRun);
+
+ if (openStudioSimulationRun.Exception != null && Query.IsOpenStudioLoadFailure(openStudioSimulationRun.Exception))
+ {
+ text = Query.OpenStudioLoadFailure(openStudioSimulationRun.Exception) + Environment.NewLine + Environment.NewLine + text;
+ }
+
+ MessageBoxImage messageBoxImage = openStudioSimulationRun.Status switch
+ {
+ OpenStudioSimulationStatus.Succeeded => MessageBoxImage.Information,
+ OpenStudioSimulationStatus.Cancelled => MessageBoxImage.Warning,
+ _ => MessageBoxImage.Error,
+ };
+
+ bool directoryExists = !string.IsNullOrWhiteSpace(openStudioSimulationRun.Directory) && Directory.Exists(openStudioSimulationRun.Directory);
+ if (!directoryExists)
+ {
+ ShowOpenStudioMessage(owner, text, MessageBoxButton.OK, messageBoxImage);
+ return;
+ }
+
+ if (ShowOpenStudioMessage(owner, text + Environment.NewLine + Environment.NewLine + "Open the run folder?", MessageBoxButton.YesNo, messageBoxImage) == MessageBoxResult.Yes)
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(openStudioSimulationRun.Directory!) { UseShellExecute = true });
+ }
+ }
+
+ private static MessageBoxResult ShowOpenStudioMessage(System.Windows.Window? owner, string text, MessageBoxButton messageBoxButton, MessageBoxImage messageBoxImage)
+ {
+ //The window may have been closed while the run went on; a message box cannot be owned by a closed window.
+ return owner != null && owner.IsLoaded
+ ? MessageBox.Show(owner, text, "OpenStudio Simulation", messageBoxButton, messageBoxImage)
+ : MessageBox.Show(text, "OpenStudio Simulation", messageBoxButton, messageBoxImage);
+ }
+
+ /// SAM_OpenStudio's stages as progress-window steps. ProgressWindowHost marshals onto its own thread, so any thread may report.
+ private sealed class OpenStudioProgressReport : IProgress
+ {
+ private readonly ProgressWindowHost progressWindowHost;
+
+ public OpenStudioProgressReport(ProgressWindowHost progressWindowHost)
+ {
+ this.progressWindowHost = progressWindowHost;
+ }
+
+ public void Report(Core.OpenStudio.OpenStudioSimulationProgress value)
+ {
+ if (value == null)
+ {
+ return;
+ }
+
+ string description = value.Stage switch
+ {
+ Core.OpenStudio.OpenStudioSimulationStage.SavingOsm => "Saving the OpenStudio model (OSM)",
+ Core.OpenStudio.OpenStudioSimulationStage.WritingOsw => "Writing the OpenStudio workflow (OSW)",
+ Core.OpenStudio.OpenStudioSimulationStage.RunningCli => "Running OpenStudio/EnergyPlus",
+ Core.OpenStudio.OpenStudioSimulationStage.ReadingResults => "Reading the results",
+ _ => "Finishing",
+ };
+
+ progressWindowHost.Update(description);
+ }
+ }
+ }
+}
diff --git a/WPF/SAM.Analytical.UI.WPF/Query/OpenStudioSimulation.cs b/WPF/SAM.Analytical.UI.WPF/Query/OpenStudioSimulation.cs
new file mode 100644
index 00000000..085defb8
--- /dev/null
+++ b/WPF/SAM.Analytical.UI.WPF/Query/OpenStudioSimulation.cs
@@ -0,0 +1,163 @@
+// 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.Security.Cryptography;
+
+namespace SAM.Analytical.UI.WPF
+{
+ public static partial class Query
+ {
+ ///
+ /// The folder of one Simulate > OpenStudio run: <directory>\<model>_OpenStudio_<yyyyMMdd-HHmmss>_<id>. The
+ /// timestamp makes runs sort in order and the id makes two runs started in the same second distinct, so a run never
+ /// writes into another run's folder.
+ ///
+ public static string OpenStudioRunDirectory(string directory, string? modelName, DateTime startedUtc, string id)
+ {
+ return Path.Combine(directory, string.Format("{0}_OpenStudio_{1:yyyyMMdd-HHmmss}_{2}", FileNameSafe(modelName), startedUtc, id));
+ }
+
+ /// The result model's file name: the model name with an _OpenStudio suffix, so it is never mistaken for the source model.
+ public static string OpenStudioResultModelFileName(string? modelName)
+ {
+ return FileNameSafe(modelName) + "_OpenStudio.json";
+ }
+
+ /// "sha256:" and the lowercase hex SHA-256 of the file's bytes (the B1a hash notation); null when there is no file.
+ public static string? Sha256(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
+ {
+ return null;
+ }
+
+ using FileStream fileStream = File.OpenRead(path!);
+ return "sha256:" + System.Convert.ToHexString(SHA256.HashData(fileStream)).ToLowerInvariant();
+ }
+
+ ///
+ /// True when (or an inner one) means the OpenStudio stack could not be loaded: a missing or
+ /// stale SAM_OpenStudio assembly, OpenStudio.dll, or one of the x64 native libraries (openstudiolib.dll, openstudio_*csharp.dll,
+ /// SQLite.Interop.dll) next to the application. That is a deployment problem, not a problem with the model.
+ ///
+ public static bool IsOpenStudioLoadFailure(Exception? exception)
+ {
+ for (Exception? current = exception; current != null; current = current.InnerException)
+ {
+ if (current is DllNotFoundException || current is EntryPointNotFoundException || current is BadImageFormatException || current is TypeLoadException || current is MissingMemberException || current is FileLoadException)
+ {
+ return true;
+ }
+
+ if (current is FileNotFoundException fileNotFoundException && IsAssemblyName(fileNotFoundException.FileName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /// What to tell the user about an OpenStudio load failure. It references no OpenStudio type, so it works when those cannot be loaded.
+ public static string OpenStudioLoadFailure(Exception? exception)
+ {
+ List lines = new List
+ {
+ "Simulate > OpenStudio could not load the OpenStudio components. It needs SAM.Analytical.OpenStudio.dll, SAM.Core.OpenStudio.dll, SAM.Geometry.OpenStudio.dll, OpenStudio.dll and the 64-bit native openstudiolib.dll, openstudio_csharp.dll, openstudio_model_csharp.dll and openstudio_translators_csharp.dll beside the application (from the SAM_OpenStudio x64 build). This is a deployment problem, not a problem with the model; the open model was not changed.",
+ string.Empty,
+ "Error: " + Innermost(exception),
+ "Application folder: " + AppContext.BaseDirectory,
+ };
+
+ return string.Join(Environment.NewLine, lines);
+ }
+
+ ///
+ /// The user-facing account of one Simulate > OpenStudio run: how it ended, where its outputs are, the EnergyPlus error counts
+ /// and the first diagnostics that matter (errors before warnings). Engine results are reported as they are; nothing here
+ /// compares them with Tas.
+ ///
+ public static string OpenStudioSimulationSummary(OpenStudioSimulationRun? openStudioSimulationRun, int maxDiagnostics = 8)
+ {
+ if (openStudioSimulationRun == null)
+ {
+ return "The OpenStudio simulation did not run.";
+ }
+
+ List lines = new List();
+
+ switch (openStudioSimulationRun.Status)
+ {
+ case OpenStudioSimulationStatus.Succeeded:
+ lines.Add("The OpenStudio/EnergyPlus simulation finished. The open model was not changed.");
+ break;
+ case OpenStudioSimulationStatus.Cancelled:
+ lines.Add("The OpenStudio simulation was cancelled. No result model was saved and the open model was not changed.");
+ break;
+ default:
+ lines.Add("The OpenStudio simulation failed. No result model was saved and the open model was not changed.");
+ break;
+ }
+
+ lines.Add(string.Empty);
+
+ if (!string.IsNullOrWhiteSpace(openStudioSimulationRun.Directory))
+ {
+ lines.Add("Run folder: " + openStudioSimulationRun.Directory);
+ }
+
+ if (!string.IsNullOrWhiteSpace(openStudioSimulationRun.ResultModelPath))
+ {
+ lines.Add(string.Format("Result model: {0} ({1} results)", Path.GetFileName(openStudioSimulationRun.ResultModelPath), openStudioSimulationRun.ResultCount));
+ }
+
+ if (!string.IsNullOrWhiteSpace(openStudioSimulationRun.WeatherSource))
+ {
+ lines.Add("Weather: " + openStudioSimulationRun.WeatherSource);
+ }
+
+ if (openStudioSimulationRun.ExitCode != null)
+ {
+ lines.Add(string.Format("EnergyPlus: exit code {0}, {1} warnings, {2} severe, {3} fatal", openStudioSimulationRun.ExitCode, openStudioSimulationRun.WarningCount, openStudioSimulationRun.SevereCount, openStudioSimulationRun.FatalCount));
+ }
+
+ if (openStudioSimulationRun.Exception != null)
+ {
+ lines.Add("Error: " + openStudioSimulationRun.Exception.GetType().Name + ": " + openStudioSimulationRun.Exception.Message);
+ }
+
+ List diagnostics = openStudioSimulationRun.Diagnostics.Where(x => x.StartsWith("Error", StringComparison.Ordinal))
+ .Concat(openStudioSimulationRun.Diagnostics.Where(x => x.StartsWith("Warning", StringComparison.Ordinal)))
+ .ToList();
+
+ if (diagnostics.Count != 0)
+ {
+ lines.Add(string.Empty);
+ lines.Add("Diagnostics:");
+ lines.AddRange(diagnostics.Take(maxDiagnostics).Select(x => "- " + x));
+ if (diagnostics.Count > maxDiagnostics)
+ {
+ lines.Add(string.Format("- ... and {0} more (see {1})", diagnostics.Count - maxDiagnostics, OpenStudioSimulationRun.ManifestFileName));
+ }
+ }
+
+ return string.Join(Environment.NewLine, lines);
+ }
+
+ private static string FileNameSafe(string? name)
+ {
+ string result = string.IsNullOrWhiteSpace(name) ? "Model" : name!.Trim();
+
+ //Windows' invalid characters whatever the platform, so a run folder copied to Windows keeps its name.
+ HashSet invalidChars = new HashSet(Path.GetInvalidFileNameChars().Concat("<>:\"/\\|?*"));
+ result = new string(result.Select(x => invalidChars.Contains(x) || char.IsWhiteSpace(x) || char.IsControl(x) ? '_' : x).ToArray());
+
+ //Long names would push run outputs (run\eplusout.sql and the EnergyPlus files) towards MAX_PATH.
+ return result.Length > 64 ? result.Substring(0, 64) : result;
+ }
+ }
+}
diff --git a/WPF/SAM.Analytical.UI.WPF/SAM.Analytical.UI.WPF.csproj b/WPF/SAM.Analytical.UI.WPF/SAM.Analytical.UI.WPF.csproj
index 39501e4c..b91acc6d 100644
--- a/WPF/SAM.Analytical.UI.WPF/SAM.Analytical.UI.WPF.csproj
+++ b/WPF/SAM.Analytical.UI.WPF/SAM.Analytical.UI.WPF.csproj
@@ -167,7 +167,43 @@
..\..\..\SAM_Tas\build\SAM.Weather.Tas.dll
+
+
+ ..\..\..\SAM_OpenStudio\build\OpenStudio.dll
+
+
+ ..\..\..\SAM_OpenStudio\build\SAM.Analytical.OpenStudio.dll
+
+
+ ..\..\..\SAM_OpenStudio\build\SAM.Core.OpenStudio.dll
+
+
+ ..\..\..\SAM_OpenStudio\build\SAM.Geometry.OpenStudio.dll
+
+
+
+
+ $(MSBuildThisFileDirectory)..\..\..\SAM_OpenStudio\build\
+
+
+
+
+
+
+
+
+
+
+
+
2.27.0
diff --git a/WPF/SAM.Analytical.UI.WPF/Windows/AnalyticalWindow.xaml b/WPF/SAM.Analytical.UI.WPF/Windows/AnalyticalWindow.xaml
index c10a0988..c76e55c4 100644
--- a/WPF/SAM.Analytical.UI.WPF/Windows/AnalyticalWindow.xaml
+++ b/WPF/SAM.Analytical.UI.WPF/Windows/AnalyticalWindow.xaml
@@ -500,6 +500,10 @@
+
+
diff --git a/WPF/SAM.Analytical.UI.WPF/Windows/AnalyticalWindow.xaml.cs b/WPF/SAM.Analytical.UI.WPF/Windows/AnalyticalWindow.xaml.cs
index 486ac670..61ca8094 100644
--- a/WPF/SAM.Analytical.UI.WPF/Windows/AnalyticalWindow.xaml.cs
+++ b/WPF/SAM.Analytical.UI.WPF/Windows/AnalyticalWindow.xaml.cs
@@ -57,6 +57,12 @@ public partial class AnalyticalWindow : System.Windows.Window
///
///
private readonly PartORun partORun = new();
+
+ ///
+ /// True while a Simulate > OpenStudio run is going. The run works on a frozen copy and does not block the window, so this is
+ /// what keeps a second click from starting a second run; leaves the button off while it is set.
+ ///
+ private bool openStudioSimulationRunning;
public AnalyticalWindow()
{
@@ -703,6 +709,11 @@ private void InitializeWindow()
RibbonButton_EnergySimulation.LargeImageSource = SAM.Core.UI.WPF.Convert.ToBitmapSource(Properties.Resources.SAM_UI_ModelRun);
RibbonButton_EnergySimulation.Click += RibbonButton_EnergySimulation_Click;
+ RibbonButton_OpenStudioSimulation.LargeImageSource = SAM.Core.UI.WPF.Convert.ToBitmapSource(Properties.Resources.SAM_UI_ModelRun);
+ RibbonButton_OpenStudioSimulation.Click += RibbonButton_OpenStudioSimulation_Click;
+ RibbonButton_OpenStudioSimulation.ToolTipTitle = "OpenStudio Simulation";
+ RibbonButton_OpenStudioSimulation.ToolTipDescription = "Run an annual OpenStudio/EnergyPlus simulation of a frozen copy of the open model (SAM_OpenStudio). The results are saved as a separate model in a new run folder; the open model and the Tas workflow are not changed. Needs OpenStudio 3.10 (openstudio.exe) installed.";
+
RibbonButton_Optimisation.LargeImageSource = SAM.Core.UI.WPF.Convert.ToBitmapSource(Properties.Resources.SAM_UI_AlgorithmRun);
RibbonButton_Optimisation.Click += RibbonButton_Optimisation_Click;
RibbonButton_Optimisation.ToolTipTitle = "Optimisation";
@@ -2271,6 +2282,37 @@ private void RibbonButton_EnergySimulation_Click(object sender, RoutedEventArgs
RefreshPartOButtons();
}
+ private async void RibbonButton_OpenStudioSimulation_Click(object sender, RoutedEventArgs e)
+ {
+ if (openStudioSimulationRunning || uIAnalyticalModel == null)
+ {
+ return;
+ }
+
+ openStudioSimulationRunning = true;
+ RibbonButton_OpenStudioSimulation.IsEnabled = false;
+ try
+ {
+ await SimulateOpenStudio();
+ }
+ //A missing or stale SAM_OpenStudio assembly fails when SimulateOpenStudio is compiled; say what is missing instead of crashing.
+ catch (Exception exception) when (Query.IsOpenStudioLoadFailure(exception))
+ {
+ MessageBox.Show(this, Query.OpenStudioLoadFailure(exception), "OpenStudio Simulation", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ finally
+ {
+ openStudioSimulationRunning = false;
+ RibbonButton_OpenStudioSimulation.IsEnabled = uIAnalyticalModel != null;
+ }
+ }
+
+ [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
+ private System.Threading.Tasks.Task SimulateOpenStudio()
+ {
+ return uIAnalyticalModel!.SimulateOpenStudioAsync(this);
+ }
+
private void RibbonButton_Optimisation_Click(object sender, RoutedEventArgs e)
{
//A missing SAM.Analytical.Tas.GenOpt.dll or a stale SAM.Math.dll fails when the window's types are first
@@ -3202,6 +3244,7 @@ private void SetEnabled()
RibbonButton_OpenT3D.IsEnabled = false;
RibbonButton_EditLibrary.IsEnabled = false;
RibbonButton_EnergySimulation.IsEnabled = false;
+ RibbonButton_OpenStudioSimulation.IsEnabled = false;
RibbonButton_SolarSimulation.IsEnabled = false;
RibbonButton_ImportWeatherData.IsEnabled = false;
RibbonButton_EditWeatherData.IsEnabled = false;
@@ -3221,6 +3264,7 @@ private void SetEnabled()
//RibbonButton_CreateTBD.IsEnabled = false;
RibbonButton_EditLibrary.IsEnabled = false;
RibbonButton_EnergySimulation.IsEnabled = false;
+ RibbonButton_OpenStudioSimulation.IsEnabled = false;
RibbonButton_SolarSimulation.IsEnabled = false;
RibbonButton_ImportWeatherData.IsEnabled = false;
RibbonButton_EditWeatherData.IsEnabled = false;
@@ -3279,6 +3323,7 @@ private void SetEnabled()
RibbonButton_MapInternalConditions.IsEnabled = true;
RibbonButton_MapInternalConditionsByTM59.IsEnabled = true;
RibbonButton_EnergySimulation.IsEnabled = true;
+ RibbonButton_OpenStudioSimulation.IsEnabled = !openStudioSimulationRunning;
RibbonButton_SolarSimulation.IsEnabled = true;
RibbonButton_ImportWeatherData.IsEnabled = true;
RibbonButton_EditWeatherData.IsEnabled = true;
diff --git a/documentation/OpenStudio-Simulation-PR229.md b/documentation/OpenStudio-Simulation-PR229.md
new file mode 100644
index 00000000..bd08f213
--- /dev/null
+++ b/documentation/OpenStudio-Simulation-PR229.md
@@ -0,0 +1,186 @@
+# Simulate > OpenStudio Simulation - minimal OpenStudio/EnergyPlus run from SAM_UI (SAM_UI#229)
+
+Status: implemented on `claude/openstudio-mcp-server-1jmjlo`; HOLD on strict explicit-EPW equality (2026-10-18 evidence below). Not merged.
+Issue: SAM-BIM/SAM_UI#229 (parent SAM-BIM/SAM_UI#227, tracker SAM-BIM/SAM#206).
+Base: `sow/2026-Q4` at `ebe5e09b30b95e43e5d047b3aef2d8c5f01110e9`.
+PR: the live PR for this branch in `SAM-BIM/SAM_UI` (its number is not committed here).
+Tested content SHA: `3393331` (local Linux harness; hosted CI in the PR description). Earlier heads: `2b140f3` failed CI (a `build.yml` comment line split, pwsh parse error); `f8f10d6` failed CI (CS0117, SAM_UI's own `AnalyticalModelParameter` shadows SAM's); `722f40b` was green on hosted CI but carried the manifest bug fixed in `3393331` (see Validation). This record is the only change after the tested SHA.
+Final PR head and hosted check results: recorded in the live PR description.
+
+## What it does
+
+A new ribbon button, **Simulate > OpenStudio Simulation**, sits beside Energy Simulation (Tas). The existing Tas commands are not changed.
+
+1. The user picks an output folder. If the model has embedded weather, the user chooses between that weather and an EPW file; otherwise they must choose an EPW.
+2. `UIAnalyticalModel.JSAMObject` (a deep clone) is frozen to `snapshot.json`. Every later step works on the copy read back from that file.
+3. SAM_OpenStudio's own `AnalyticalModel.ToOpenStudio(epw, dir, null, runOptions, run: true, progress, token)` converts the model, writes OSM/OSW, runs `openstudio.exe` and reads the SQL. The weather precedence is SAM_OpenStudio's: the user's EPW, otherwise the embedded WeatherData.
+4. On success, the copy has every result it already carried removed (Tas results, for example). This run's results are then attached with SAM_OpenStudio's `AdjacencyCluster.AddResults(sql)`, plus the model-level `AnalyticalModelSimulationResult` (`resultSet.ToSAM`). The result is saved as `_OpenStudio.json`.
+5. `openstudio-run.json` is written in every case, including failure and cancel.
+
+Run folder: `\_OpenStudio__\`. It contains:
+- `snapshot.json`;
+- `weather.epw` (a copy of the EPW used);
+- `.osm` and `.osw`;
+- `run\eplusout.sql` and `.err`;
+- `_OpenStudio.json`;
+- `openstudio-run.json`, with schema `sam-ui.openstudio-run/1`, status, SHA-256 of the snapshot and weather (B1a `sourceFileHash` notation), the CLI path, exit code, warning/severe/fatal counts, the SAM.Analytical.OpenStudio InformationalVersion, and the conversion and result diagnostics.
+
+The run executes on a worker thread (`Task.Run`). Progress and Cancel use the existing `ProgressWindowHost`. Cancel triggers the token, and SAM_OpenStudio's job object kills the CLI/EnergyPlus process tree. The window stays usable during the run, and the button is disabled until the run ends.
+
+## Decisions
+
+- **Runs inside SAM_UI's process; no separate runner program.**
+ - The OpenStudio bindings (SWIG) are used in process only for conversion, saving and reading results. EnergyPlus already runs out of process under a job object.
+ - The same library already runs in process in Rhino (SAM_OpenStudio's Grasshopper components) and in the benchmark CLI.
+ - The model is frozen to disk before the native code is reached. A native crash would therefore lose no model state beyond what the snapshot already holds; unsaved edits made *after* the snapshot would still be lost with the process.
+ - A separate runner remains an option for Phase 2 (#234) if in-process native failures are seen in practice.
+- **No platform change in SAM_UI.**
+ - The OpenStudio NuGet targets refuse AnyCPU builds, and SAM_UI is AnyCPU throughout.
+ - `OpenStudio.dll` is IL-only (CLR flags `0x9`, identical bytes in the package's x86 and x64 folders). So `SAM.Analytical.UI.WPF` references it, and the three `SAM.*.OpenStudio` assemblies, from `..\..\..\SAM_OpenStudio\build\` by HintPath, like every other SAM dependency.
+ - The x64 native files are copied as `None` items, so they also reach the applications and the tests: `openstudiolib.dll`, `openstudio_csharp.dll`, `openstudio_model_csharp.dll`, `openstudio_translators_csharp.dll` and `runtimes\win-x64\native\SQLite.Interop.dll`.
+ - If any of them is missing, the build fails early with a clear message.
+ - At run time an AnyCPU process on x64 Windows is 64-bit, so the x64 natives load.
+- **CI**: `build.yml` clones and builds `SAM_SQLite` and `SAM_OpenStudio` after `SAM_Validation`, which SAM_OpenStudio's benchmark needs. SAM_OpenStudio is built with `/p:Platform=x64`, as in its own CI and in SAM_Deploy's BuildAll, which already builds SAM_SQLite and SAM_OpenStudio (x64) before SAM_UI. Every other solution keeps the default platform.
+- **Reuse, no duplication.** Conversion, the run, SQL extraction, result mapping and attachment are all SAM_OpenStudio's. No comparison code is added; comparison belongs to SAM_Validation (#231). The manifest's file hashes are provenance only, not `canonicalModelHash`.
+- **Result model holds only this run's results**, so a later B1a producer or comparison cannot pick up stale Tas results from it.
+- **Namespace traps** caught by the local compile check:
+ - inside `SAM.Analytical.*`, a bare `Window` is `SAM.Analytical.Window` (the aperture type), so the WPF window is written `System.Windows.Window`;
+ - `Math` may resolve to `SAM.Math`, so the code uses `System.Math`.
+
+## Files changed
+
+- `WPF/SAM.Analytical.UI.WPF/Classes/OpenStudio/OpenStudioSimulationRun.cs` (new): the run record and manifest. It holds no OpenStudio type.
+- `WPF/SAM.Analytical.UI.WPF/Modify/RunOpenStudioSimulation.cs` (new):
+ - `RunOpenStudioSimulation` (no UI, never throws for a failed run);
+ - `FreezeAnalyticalModel`;
+ - `AddOpenStudioResults`;
+ - the OpenStudio-touching code sits in a `NoInlining` method, so a load failure is recorded on the run.
+- `WPF/SAM.Analytical.UI.WPF/Modify/SimulateOpenStudio.cs` (new): the dialogs, the progress/cancel host, the worker thread and the summary message.
+- `WPF/SAM.Analytical.UI.WPF/Query/OpenStudioSimulation.cs` (new): run folder and result file names, SHA-256, load-failure detection and message, and the summary text.
+- `WPF/SAM.Analytical.UI.WPF/Windows/AnalyticalWindow.xaml(.cs)`: the button, its tooltip, the click handler (catches load failures and blocks a second run) and the enable logic.
+- `WPF/SAM.Analytical.UI.WPF/SAM.Analytical.UI.WPF.csproj`: the SAM_OpenStudio references, the native file items and the missing-file check.
+- `WPF/SAM.Analytical.UI.WPF.Tests/SAM.Analytical.UI.WPF.Tests.csproj`: SAM_OpenStudio references for compilation.
+- `WPF/SAM.Analytical.UI.WPF.Tests/OpenStudioSimulationTests.cs` (new): 9 tests.
+- `.github/workflows/build.yml`: SAM_SQLite and SAM_OpenStudio (x64) added to the clone and build order.
+
+## Validation
+
+Local results are from a Linux cloud container (no Windows, Tas or OpenStudio CLI), using the .NET SDK 10.0.112 compiler; .NET 8.0.131's compiler cannot build SAM `sow/2026-Q4`, which uses a C# 12 collection expression it rejects. Dependencies were built from `sow/2026-Q4`: SAM `618db819`, SAM_SQLite `ec2eb86d`, SAM_OpenStudio `d4216f35` (`-p:Platform=x64 -p:PostBuildEvent=`).
+
+| Check | Where | Result |
+|---|---|---|
+| `openstudio-run.json` threw `InvalidOperationException` when a run had any diagnostic: `JsonArray.Add(string)` binds to the generic `Add`, which `ToJson`'s options cannot write. The exception was raised from a `finally` on the worker thread, so a real run with a warning would have failed or crashed SAM_UI. Found by the comparison work (SAM_UI#231). Fixed with `JsonValue.Create`, and the manifest write can no longer throw. Regression test `Manifest_WithDiagnosticsAndAnError_IsWritten`: fails before the fix, passes after (harness 10/10) | Local | FIXED in `3393331` |
+| Hosted CI on `f8f10d6` failed: `SimulateOpenStudio.cs(55,89) CS0117 'AnalyticalModelParameter' does not contain a definition for 'WeatherData'`. SAM_UI's own `SAM.Analytical.UI.AnalyticalModelParameter` shadows SAM's enum. Reproduced locally by adding that enum as a stub; fixed by qualifying it as `Analytical.AnalyticalModelParameter`, as `Modify/Simulate.cs` does. The new files were swept for other names shadowed by SAM_UI types: none found | Local | FIXED |
+| New production files compiled against the real SAM/SAM_OpenStudio builds (net8.0-windows, nullable enabled, `EnableWindowsTargeting`). Machine-local harness; only `UIAnalyticalModel`, `ProgressWindowHost` and two existing private `Query` helpers stubbed | Local | PASS (0 errors, 0 warnings) |
+| `OpenStudioSimulationTests` (9) with the non-WPF production files, net8.0 harness | Local | PASS 9/9. The real-run test returned early (no `openstudio.exe`), so it is **NOT RUN** |
+| Failure path for real: no weather, and the OpenStudio natives cannot load on Linux (`TypeInitializationException` from `OpenStudioModelCorePINVOKE`). Run recorded as Failed, model JSON unchanged, snapshot and manifest written | Local | PASS |
+| Native-file items copied into a referencing project, including `runtimes/win-x64/native/SQLite.Interop.dll`; missing files give the clear early error | Local, scratch MSBuild projects | PASS |
+| Full SAM_UI solution build, including the XAML and `AnalyticalWindow`, and the CI dependency changes | GitHub CI (Windows) | See the PR description |
+| Full WPF test suite on Windows (`dotnet test WPF/SAM.Analytical.UI.WPF.Tests -c Release`) | Windows | NOT RUN |
+| **G1**: real OpenStudio run from the SAM_UI app | Windows + OpenStudio 3.10 | NOT RUN |
+| G1: results identical to `benchmark-openstudio` for the same model and EPW | Windows + OpenStudio 3.10 | NOT RUN |
+| G1: Tas not regressed (same model, Energy Simulation before and after) | Licensed Tas | NOT RUN |
+| G1: Cancel kills `openstudio.exe`/`energyplus.exe` and saves no result model | Windows, manual | NOT RUN |
+
+Commands, all machine-local; the harness projects are not committed:
+
+```bash
+# deps (Linux; SAM_OpenStudio post-build is a Windows copy command)
+dotnet build SAM/SAM/SAM.Analytical/SAM.Analytical.csproj -c Release -p:EnableWindowsTargeting=true
+dotnet build SAM_SQLite/SAM_SQLite/SAM.Core.SQLite/SAM.Core.SQLite.csproj -c Release
+dotnet build SAM_OpenStudio/SAM_OpenStudio/SAM.Analytical.OpenStudio/SAM.Analytical.OpenStudio.csproj -c Release -p:Platform=x64 -p:PostBuildEvent=
+# harness: compiles the new files plus OpenStudioSimulationTests.cs against those builds
+dotnet test /Tests.csproj -c Release
+```
+
+On Windows, the real run test is `dotnet test WPF/SAM.Analytical.UI.WPF.Tests -c Release --filter "FullyQualifiedName~OpenStudioSimulationTests"`. It needs OpenStudio 3.10 (`openstudio.exe` on PATH or in a standard install) and SAM_OpenStudio checked out beside SAM_UI. `SAM_OPENSTUDIO_UI_TEST_MODEL` and `SAM_OPENSTUDIO_UI_TEST_EPW` override the model and the EPW.
+
+## Not claimed
+
+- No engineering equivalence with Tas.
+- No claim that the OpenStudio results are correct beyond SAM_OpenStudio's own validation.
+- Comparison is #231; benchmark acceptance is #232/#233.
+
+## Unresolved / risks
+
+- SAM_Deploy installer contents were not checked. The deploy build order already builds SAM_SQLite and SAM_OpenStudio (x64) before SAM_UI, and SAM_OpenStudio's Grasshopper post-build copies its build folder, natives included, to `%APPDATA%\SAM`. Confirm on an installed machine that the button loads.
+- OpenStudio CLI (3.10) must be installed by the user. A missing CLI is reported through SAM_OpenStudio's `RunCliFailed` diagnostic.
+- The CI build-artifact upload grows by about 150 MB of OpenStudio natives.
+- The SWIG bindings run in process. See Decisions.
+
+## Historical gated acceptance reconciliation - 2026-10-17
+
+Status: HOLD for G1 acceptance. This documentation-only correction does not authorize a merge or waive a required check. The owner-authorized merge candidate was `95135ceb0ac6efb877f3c17735659084bdbc2aa3`; any record correction creates a new candidate requiring a new owner-approved pin. Product content remains `3393331`.
+
+The original NOT RUN entries above describe the Linux investigation. Later Windows evidence on #238 (build `0e1980a9d5e302da22fc32db488a5ccefd81d037`, incorporating #237 `95135ce`) supports a real standalone run and Both integration; it does not establish every G1 requirement. #238's live description records owner acceptance for integration, with numerical equivalence left open. No explicit owner deferral of the remaining G1 checks was found in the inspected records, PR comments or current merge instruction.
+
+Machine-local evidence (must be copied/provisioned on another machine):
+
+- Report: `C:\TasOut\pr238-ui\PR238-BOTH-REPORT.md`.
+- Standalone: `C:\TasOut\pr238-ui\input-box\box-aperture_OpenStudio_20261017-191359_3beea496`. `openstudio-run.json`: Succeeded, exit 0, 10 warnings, 0 severe, 0 fatal, 13 results, embedded weather, OpenStudio 3.10. The result model, snapshot, EPW, OSM/OSW and SQL are present.
+- Successful Both: `C:\TasOut\pr238-ui\box-both\000000_SAM_AnalyticalModel_TasOpenStudio_20261017-191647_1eb45853`. Both producer exits 0; Compared; provenance Pass. Overall/Numerical Fail, Coverage Warn; reports retain 6 Match / 0 Warn / 10 Fail / 2 N/A. This proves orchestration, not engine equivalence.
+- Partial: `C:\TasOut\pr238-ui\both-3\000000_SAM_AnalyticalModel_TasOpenStudio_20261017-185627_e65f7d4c`. Tas exit 5, OpenStudio exit 0; PartiallyCompleted, no comparison reports; failed Tas document is not substituted. The report records the UI banner.
+
+Read-only audit during this merge session (no new simulation): compared the standalone `box-aperture_OpenStudio.json` AnalyticalModelSimulationResult parameters with `box-both`'s `benchmark-OpenStudio.json` model fields. All four stored values are exactly equal: heating 2810.0182949034006 kWh; cooling 3832.503031616723 kWh; peak heating 3.343728936987215 kW; peak cooling 5.206164980706354 kW. Model GUID is `1988f7b93ec047d0b87bf9cb345d9eab`; both weather files hash to `E1353BD2600A401F76262D52A8E16176A15DC45A4967E58D9107C374704ABF7C`. Equality is evidenced for this box scenario, not for the prescribed two-cell explicit-EPW G1 procedure. Snapshot file hashes differ; no claim of byte-identical snapshots is made.
+
+| G1 requirement | Current evidence / decision |
+|---|---|
+| Standalone OpenStudio real run | Evidenced on box, as above; original full fixture/UI step checklist not supplied |
+| Standalone vs benchmark equality | Four model metrics exactly equal for the saved box; prescribed explicit-EPW fixture not run/evidenced |
+| Cancel and process cleanup | NOT EVIDENCED for a real run; fake-producer tests are not native-process acceptance; no explicit owner deferral |
+| Concurrent-edit isolation | NOT EVIDENCED for a real UI run; frozen-copy tests do not demonstrate concurrent UI editing; no explicit owner deferral |
+| Tas baseline comparison | NOT EVIDENCED: no same-model before/after Energy Simulation comparison against Q4; successful benchmark-tas is a different workflow; no explicit owner deferral |
+| Full WPF suite | Existing #238 record reports 2835/2835; not rerun in this merge session |
+| Opt-in RealRun actually executed | NOT established by 2835/2835: `OpenStudioSimulationTests.RealRun_...` returns early with NOT RUN when CLI or model is missing |
+
+Exact next step: owner supplies missing G1 evidence or explicitly accepts each deferral (including whether the box equality substitutes for the prescribed fixture), reviews this correction and approves its new exact head. Refresh final-head CI against merged SAM_Validation Q4, check dependency logs, and recheck all merge gates. #238 remains dependent on #237. Do not merge either UI PR under the original pins after changing its record. Numerical disagreement, HungaryHouse Tas crash, unavailable per-engine input manifests and producer deployment remain separate unresolved follow-ups.
+
+## Windows G1 acceptance execution - 2026-10-18
+
+**Current status: HOLD on strict explicit-EPW equality.** The owner instruction of 2026-10-18 authorizes focused acceptance work and later merge of documentation-only evidence heads once all gates pass; another approval of such pins is not required. It does not waive the failed equality requirement. No product code, engine physics, units, tolerances, comparison arithmetic or deployment scope changed. The earlier dated HOLD and pin-approval wording above is historical and superseded by this section.
+
+Freshly tested UI content: `b7483443f53dd53a4d16f159d6bc327c901a7770` (#237). Pre-PR UI Q4 baseline: `ebe5e09b30b95e43e5d047b3aef2d8c5f01110e9`. Both Release builds passed; candidate and baseline run from isolated copied build folders. All 14 dependency source pins are in `documentation/evidence/g1-20261018/candidate-dependency-pins.csv`; these dependencies were unchanged for both builds. Eight relevant engine/core/interop DLLs are SHA-256 identical across builds (`dependency-binary-hashes.csv`); UI DLL hashes are in `ui-build-hashes.csv`. SAM `618db819300df8b698b3bbe5174a203244134738`, SAM_OpenStudio `d4216f3525c01ca762c1b3e9b6d099733d037f54`, SAM_Tas `a34a298dde2803de514081a0ed72ac3ff790997b`, SAM_Validation Q4 `68eded0060047af714853a02a152fb9c960bfeb1` (product merge `912ae94e`). Runtime: OpenStudio 3.10 / EnergyPlus 25.1, Tas 9.5.7, Windows x64. These local builds use default informational version 1.0.0; `SAM_COMMIT=618db819300df8b698b3bbe5174a203244134738` was set for benchmark provenance.
+
+| Required check | Newly executed evidence and result |
+|---|---|
+| Real Cancel/process cleanup | **PASS.** Cancel pressed during the observed Running OpenStudio/EnergyPlus stage while run-owned CLI PID 49008 and EnergyPlus child PID 45448 were alive. Both disappeared immediately around the click; manifest `Cancelled`, engine exit -1, resultModel null, no completed result model. No external kill was used. `cancellation-assertions.json` and screenshots. A first attempt clicked after native completion (exit 0) and is retained locally but excluded from this pass. |
+| Concurrent-edit isolation | **PASS.** A real HungaryHouse OpenStudio run froze its snapshot; the ordinary Rename Spaces UI then committed `GF_07 LivingRoom` -> `G1_ISOLATION_EDIT` at 08:56:09.092Z, before run completion at 08:56:14.164Z. Snapshot and result retain the old name for space `c7df54b3c0064fc5a7c6e6012dbb9b8e`; the saved disposable open model retains the edit. Snapshot SHA-256 unchanged. `isolation-assertions.json`, screenshot. |
+| Tas before/after Q4 baseline | **PASS for the exercised box case.** Existing Energy Simulation button used on the same original box model, internal weather, TAS solar/gbXML route, Simulate + Sizing + Full Year 1-365 + Unmet Hours + Update Construction Layers enabled; other options off. Settings screenshots committed. Both workflows completed, wrote TBD/TSD/result JSON, and adopted results. **608/608 persisted numerical values match exactly**, aligned by result type/name/load type and surface reference; matching 2 space, 28 surface and 2 adjacency-cluster results. Criterion is exact equality, no numerical tolerance, because platform, inputs, settings, dependencies and engine binaries are held fixed. Run-generated GUIDs/timestamps are metadata, excluded from numerical comparison. This does not claim byte-identical TSDs or audit every hourly native record. `tas-comparison.json`. `Modify/Simulate.cs` is identical at the two UI pins. |
+| Prescribed two-cell explicit EPW | **Executed; strict equality FAIL/HOLD.** Untouched copy of `rawModel-WindowsWeatherDataDDYAdiabaticAirPanel.sam`, prescribed Boston EPW; UI explicitly chose No to embedded weather and selected the file. Manifest Succeeded, Weather=File, exit 0, 13 warnings, zero severe/fatal, 31 OpenStudio results. Benchmark uses the same source model/EPW and default conversion settings and succeeds. Weather SHA-256 and model GUID match; fixture preserved. Four model metrics differ by tiny nonzero amounts; values below. |
+| Opt-in real-run test | **PASS, actually executed (1/1).** Explicit valid CLI/model/EPW supplied; detailed output contains Succeeded, no NOT RUN, TRX has exactly one Passed test. No broad suite rerun. |
+
+Explicit-EPW values, in stored units (standalone / benchmark / difference):
+
+- Heating kWh: `31072.86245449682` / `31072.862454810253` / `-3.134337021037936e-7`.
+- Cooling kWh: `5764.530483239447` / `5764.530483144583` / `9.486393537372351e-8`.
+- Peak heating kW: `19.82144246485972` / `19.821442464872938` / `-1.3216094885137863e-11`.
+- Peak cooling kW: `14.079292685507252` / `14.07929268578085` / `-2.735980331181054e-10`.
+
+Investigation: an independent second **CLI-only** run on the identical source/EPW also varies (heating difference `-6.598711479455233e-7 kWh` versus CLI run 1). All three generated IDFs have the same 163 objects and identical engineering fields after matching regenerated aperture/frame names by their complete physical content and sorting object order: normalized SHA-256 `3b0dbe8f96a18c8b99b311811b2cccdc15c7b34046a549a0981a385cdf0da397`. The audit does not round or omit physical fields. Raw IDs/order and raw IDF hashes differ; expected source weather hash `sha256:eded5c982c1da37e35930622badf7f0e0a0a82b30fd9410723ea3f1c4b52b80b`, source model GUID `b74990da73b240978846cc12560be0aa`, design-day source EmbeddedModel. Evidence supports existing conversion/name-order reproducibility, not a newly established UI mapping defect. It does **not** satisfy the stated bit-exact equality, and no tolerance/acceptance deferral was silently introduced. See `equality-observed.json`, `benchmark-repeat-deltas.json`, `idf-semantic-audit.json`, `provenance-audit.json`.
+
+Reproduce on this machine (PowerShell, quoted paths):
+
+```powershell
+$env:PATH='C:\Users\michal\Documents\GitHub\SAM-BIM\.icon-migration\runtime\OpenStudio-3.10.0+86d7e215a1-Windows\bin;'+$env:PATH
+$env:SAM_COMMIT='618db819300df8b698b3bbe5174a203244134738'
+$env:SAM_OPENSTUDIO_UI_TEST_MODEL='C:\TasOut\pr237-g1-20261018\inputs\two-cell.sam'
+$env:SAM_OPENSTUDIO_UI_TEST_EPW='C:\TasOut\pr237-g1-20261018\inputs\Boston.epw'
+dotnet test WPF/SAM.Analytical.UI.WPF.Tests/SAM.Analytical.UI.WPF.Tests.csproj -c Release --filter 'FullyQualifiedName~RealRun_SimulatesAFrozenCopy_AndSavesAResultModelWithOnlyOpenStudioResults' --logger 'console;verbosity=detailed' --logger 'trx;LogFileName=real-run.trx' --results-directory C:\TasOut\pr237-g1-20261018\opt-in
+& '..\SAM_OpenStudio\benchmark\SAM.Analytical.OpenStudio.Benchmark\bin\x64\Release\net8.0\benchmark-openstudio.exe' --model $env:SAM_OPENSTUDIO_UI_TEST_MODEL --weather $env:SAM_OPENSTUDIO_UI_TEST_EPW --out C:\TasOut\pr237-g1-20261018\explicit\benchmark-openstudio.json --work C:\TasOut\pr237-g1-20261018\explicit\benchmark-work
+```
+
+The existing paths above are preserved evidence: use fresh output paths for a repeat. Release builds used VS 2026 MSBuild 18.10.1: `MSBuild.exe SAM_UI.sln /t:Restore;Rebuild /m:1 /nr:false /v:m /p:Configuration=Release /p:UseSharedCompilation=false` (quote `/t:Restore;Rebuild` in PowerShell).
+
+Machine-local evidence root: `C:\TasOut\pr237-g1-20261018` (must be copied/provisioned elsewhere). `cancel/...084042_454bf00b`; `isolation/...085502_021b72af`; `explicit/...084639_45a832b9`; `explicit/benchmark-work`, `explicit/benchmark-repeat-work`; `tas-baseline`, `tas-candidate`; `opt-in/real-run.trx`; logs/builds/process CSV/audit scripts. Portable summaries, verifier scripts and selected screenshots are committed under `documentation/evidence/g1-20261018/`; the verifiers use this evidence root and require the preserved full run artifacts. Originals remain untouched; `inputs/HungaryHouse.json` is the intentionally edited disposable live model. The original `C:\TasOut\pr238-ui\inputs\HungaryHouse.json` still hashes to `E16CCBD2BFE5ABA710D6C2EE88C68019A3265A75C41D50483E7867018E28B2CD`. The two-cell original/copy and EPW hashes are recorded in `input-hashes.csv`. Existing successful Both/partial runs, 2835/2835 suite and presentation evidence were inspected, not rerun.
+
+Exact next step: resolve the strict equality specification with the owner/domain reviewer, or prepare a separately reviewed deterministic conversion fix if bit identity is required. Do not merge #237 or dependent #238 while this required gate fails. Do not change physics, comparison tolerances/arithmetic or deployment to satisfy it. Documentation-only final heads are authorized by the 2026-10-18 instruction once all gates pass; the current hold is **acceptance**, not a request to reapprove documentation pins. Numerical Tas/OpenStudio disagreement, HungaryHouse Tas crash, unavailable input manifests and producer deployment remain separate open follow-ups. No PROJECT_PROGRESS.md change or UI branch cleanup is permitted before merge.
+
+## Owner acceptance of explicit-EPW reproducibility - 2026-10-18
+
+**Status: ACCEPTED for integration; merge authorized by the owner in the current review conversation.** This decision supersedes the strict-equality HOLD above. It accepts the observed numerical residuals for this prescribed two-cell/Boston explicit-EPW G1 scenario; it does not claim bit-exact reproducibility or introduce a general numerical tolerance.
+
+Independent review inspected the committed equality, CLI-repeat, semantic-IDF, cancellation, isolation and Tas-baseline evidence. The four standalone/benchmark metrics have maximum absolute energy difference `3.134337021037936e-7 kWh`, maximum absolute peak difference `2.735980331181054e-10 kW`, and maximum relative difference approximately `1.9433e-11`. A CLI-only repeat also varies (heating `6.598711479455233e-7 kWh`), and the recorded semantic audit matches all 163 IDF objects without rounding/omitting engineering fields. Matching source/weather provenance and these observations support numerical equivalence for G1 integration. The exact origin of run-to-run residuals is not claimed proven. Deterministic conversion is a separate task if bit identity is later required.
+
+The original instruction that standalone/benchmark outputs be "identical" is therefore satisfied for this integration acceptance by the documented numerical equivalence, not literal bit identity. No product code, engine/conversion physics, units, comparator tolerance, gate, arithmetic or deployment changed. The real Cancel/process cleanup, concurrent-edit isolation, 608/608 exact persisted Tas baseline comparison and actually executed opt-in real-run test remain PASS as recorded above. No additional simulation or local suite was run for this acceptance-only documentation update. Tested product/build content remains `b7483443f53dd53a4d16f159d6bc327c901a7770` (standalone product code `3393331`).
+
+Next: pass final-head hosted Build/SPDX, recheck exact head/base/reviews and merge #237 with an expected-head guard; then verify post-merge Q4 checks and refresh #238 against merged Q4. This is integration acceptance only. Numerical Tas/OpenStudio disagreement, the HungaryHouse Tas crash, unavailable input manifests and producer deployment remain open.
diff --git a/documentation/evidence/g1-20261018/audit-idf.py b/documentation/evidence/g1-20261018/audit-idf.py
new file mode 100644
index 00000000..ccc980ca
--- /dev/null
+++ b/documentation/evidence/g1-20261018/audit-idf.py
@@ -0,0 +1,26 @@
+import json, pathlib, re, hashlib, collections
+root=pathlib.Path(r'C:\TasOut\pr237-g1-20261018')
+def objects(path):
+ text=re.sub(r'!.*$', '', path.read_text(encoding='utf-8-sig'),flags=re.M)
+ return [[x.strip() for x in obj.split(',')] for obj in text.split(';') if obj.strip()]
+def canonical_idf(path):
+ rows=objects(path); mapping={}
+ for row in rows:
+ if row[0].lower()=='windowproperty:frameanddivider':
+ mapping[row[1]]='FRAME_'+hashlib.sha256(json.dumps(row[2:]).encode()).hexdigest()
+ rows=[[mapping.get(x,x) for x in row] for row in rows]
+ for row in rows:
+ if row[0].lower()=='fenestrationsurface:detailed':
+ mapping[row[1]]='OPENING_'+hashlib.sha256(json.dumps(row[2:]).encode()).hexdigest()
+ rows=sorted(tuple(mapping.get(x,x) for x in row) for row in rows)
+ return rows
+files=[p for p in (root/'explicit').rglob('in.idf')]
+ref=next(p for p in files if 'benchmark-work' in str(p))
+base=canonical_idf(ref)
+results=[]
+for path in files:
+ normalized=canonical_idf(path)
+ diff=collections.Counter(normalized)-collections.Counter(base)
+ results.append({'path':str(path),'objects':len(normalized),'normalizedSha256':hashlib.sha256(json.dumps(normalized).encode()).hexdigest(),'sameEngineeringFields':normalized==base,'differentObjects':list(diff)[:10]})
+(root/'explicit'/'idf-semantic-audit.json').write_text(json.dumps(results,indent=2))
+print(json.dumps(results,indent=2)[:6000])
\ No newline at end of file
diff --git a/documentation/evidence/g1-20261018/audit-isolation.py b/documentation/evidence/g1-20261018/audit-isolation.py
new file mode 100644
index 00000000..78e7a44c
--- /dev/null
+++ b/documentation/evidence/g1-20261018/audit-isolation.py
@@ -0,0 +1,11 @@
+import json,pathlib,hashlib
+r=pathlib.Path(r'C:\TasOut\pr237-g1-20261018')
+def model(path):
+ j=json.loads(path.read_text(encoding='utf-8-sig'));return j[0] if isinstance(j,list) else j
+def spaces(j):return {v['Value']['Guid'].replace('-',''):v['Value']['Name'] for g in j['AdjacencyCluster']['Objects'] if g['Key']=='SAM.Analytical.Space' for v in g['Value']}
+run=next((r/'isolation').glob('*_OpenStudio_*'));m=json.loads((run/'openstudio-run.json').read_text())
+a=spaces(model(run/'snapshot.json'));b=spaces(model(run/m['outputs']['resultModel']));c=spaces(model(r/'inputs'/'HungaryHouse.json'));keys=[k for k,v in a.items() if v=='GF_07 LivingRoom'];assert len(keys)==1;key=keys[0]
+assert a[key]==b[key]=='GF_07 LivingRoom';assert c[key]=='G1_ISOLATION_EDIT'
+h=hashlib.sha256((run/'snapshot.json').read_bytes()).hexdigest();assert h.upper()=='8247570EDFE72EBC801F97A048FFD7E53207F949BAA378C591090716D001491B'
+report={'status':m['status'],'startedUtc':m['startedUtc'],'finishedUtc':m['finishedUtc'],'spaceGuid':key,'snapshotName':a[key],'resultName':b[key],'liveSavedName':c[key],'snapshotSha256':h,'passed':True}
+(r/'isolation'/'isolation-assertions.json').write_text(json.dumps(report,indent=2));print(json.dumps(report,indent=2))
\ No newline at end of file
diff --git a/documentation/evidence/g1-20261018/audit-provenance.py b/documentation/evidence/g1-20261018/audit-provenance.py
new file mode 100644
index 00000000..d6e660c4
--- /dev/null
+++ b/documentation/evidence/g1-20261018/audit-provenance.py
@@ -0,0 +1,4 @@
+import json,pathlib,hashlib,xml.etree.ElementTree as ET
+r=pathlib.Path(r'C:\TasOut\pr237-g1-20261018');run=next((r/'explicit').glob('*_OpenStudio_*'));m=json.loads((run/'openstudio-run.json').read_text());j=json.loads((run/m['outputs']['resultModel']).read_text());j=j[0] if isinstance(j,list) else j;b=json.loads((r/'explicit'/'benchmark-openstudio.json').read_text());h='sha256:'+hashlib.sha256((r/'inputs'/'Boston.epw').read_bytes()).hexdigest();assert m['status']=='Succeeded' and m['weather']['source']=='File';assert h==m['weather']['sha256']==b['provenance']['weather']['hash'];assert j['Guid'].replace('-','')==b['provenance']['sourceModelGuid'].replace('-','');assert hashlib.sha256((r/'inputs'/'two-cell.sam').read_bytes()).hexdigest().upper()=='B2AB6826E085D621CFD219D58D924F38CC02A3406852D39BFAD8AEB205ABF32D'
+p={'fixturePreserved':True,'weatherSource':'File','weatherSha256':h,'modelGuid':j['Guid'],'benchmarkCanonicalModelHash':b['provenance']['canonicalModelHash'],'samCommit':b['provenance']['samCommit'],'openStudioCommit':b['provenance']['runnerCommit'],'designDaySource':b['provenance']['designDaySource'],'strictMetricEquality':False,'gate':'HOLD: required bit-exact standalone/benchmark equality not achieved; same-engine CLI repeats also vary. No tolerance change or deferral authorized.'};(r/'explicit'/'provenance-audit.json').write_text(json.dumps(p,indent=2));print(json.dumps(p,indent=2))
+tree=ET.parse(r/'opt-in'/'real-run.trx');ns={'t':'http://microsoft.com/schemas/VisualStudio/TeamTest/2010'};tests=tree.findall('.//t:UnitTestResult',ns);assert len(tests)==1 and tests[0].get('outcome')=='Passed';text=(r/'logs'/'opt-in-real-run.log').read_text(encoding='utf-8-sig');assert 'NOT RUN:' not in text and 'Succeeded' in text and 'Test Run Successful' in text;print('Opt-in: one actual real run, Passed; no NOT RUN output.')
\ No newline at end of file
diff --git a/documentation/evidence/g1-20261018/audit-tas.py b/documentation/evidence/g1-20261018/audit-tas.py
new file mode 100644
index 00000000..f07a6571
--- /dev/null
+++ b/documentation/evidence/g1-20261018/audit-tas.py
@@ -0,0 +1,36 @@
+import json,pathlib,hashlib
+root=pathlib.Path(r'C:\TasOut\pr237-g1-20261018')
+def numeric(value,path=''):
+ if isinstance(value,bool):return {}
+ if isinstance(value,(int,float)):return {path:value}
+ if isinstance(value,list):
+ out={}
+ for i,v in enumerate(value):out.update(numeric(v,path+'/'+str(i)))
+ return out
+ if isinstance(value,dict):
+ out={}
+ for k,v in value.items():
+ if k not in ('Guid','Reference','DateTime'):out.update(numeric(v,path+'/'+k))
+ return out
+ return {}
+def extract(folder):
+ j=json.loads((root/folder/'000000_SAM_AnalyticalModel.json').read_text(encoding='utf-8-sig')); j=j[0] if isinstance(j,list) and len(j)==1 else j;out={};strings={};counts={}
+ for group in j['AdjacencyCluster']['Objects']:
+ if not group['Key'].endswith('SimulationResult'):continue
+ counts[group['Key']]=len(group['Value'])
+ for entry in group['Value']:
+ obj=entry['Value'];params={}
+ for ps in obj.get('ParameterSets',[]):
+ for p in ps.get('Parameters',[]):
+ if p.get('Name') and p['Name'] not in ('Zone Guid','ZoneSurfaceReference'):params[p['Name']]=p.get('Value')
+ key=group['Key']+'/'+obj.get('Name','')+'/'+str(params.get('Load Type'))
+ if group['Key'].endswith('SurfaceSimulationResult'):key+='/'+str(obj.get('Reference'))
+ if key in counts:raise Exception('duplicate result identity '+key)
+ counts[key]=1
+ out.update(numeric(params,key))
+ return out,{k:v for k,v in counts.items() if '/' not in k}
+a,ac=extract('tas-baseline');b,bc=extract('tas-candidate')
+diffs=[{'path':k,'baseline':a.get(k),'candidate':b.get(k)} for k in sorted(a.keys()|b.keys()) if a.get(k)!=b.get(k)]
+report={'criterion':'Exact equality of every numeric leaf of persisted simulation parameters, aligned by result type/name/load type and surface reference. No numerical tolerance. Run-generated result/zone identities and timestamps are metadata, not numerical outputs.','baselineCounts':ac,'candidateCounts':bc,'baselineNumbers':len(a),'candidateNumbers':len(b),'exactMatch':not diffs,'differences':diffs}
+(root/'tas-comparison.json').write_text(json.dumps(report,indent=2))
+print(json.dumps(report,indent=2)[:7000])
\ No newline at end of file
diff --git a/documentation/evidence/g1-20261018/benchmark-repeat-deltas.json b/documentation/evidence/g1-20261018/benchmark-repeat-deltas.json
new file mode 100644
index 00000000..58edd851
--- /dev/null
+++ b/documentation/evidence/g1-20261018/benchmark-repeat-deltas.json
@@ -0,0 +1,26 @@
+[
+ {
+ "Metric": "consumptionHeating",
+ "First": 31072.862454810253,
+ "Second": 31072.86245415038,
+ "Delta": -6.598711479455233E-07
+ },
+ {
+ "Metric": "consumptionCooling",
+ "First": 5764.530483144583,
+ "Second": 5764.530483304694,
+ "Delta": 1.6011108527891338E-07
+ },
+ {
+ "Metric": "peakHeatingLoad",
+ "First": 19.821442464872938,
+ "Second": 19.821442464656073,
+ "Delta": -2.1686474838134018E-10
+ },
+ {
+ "Metric": "peakCoolingLoad",
+ "First": 14.07929268578085,
+ "Second": 14.079292685701251,
+ "Delta": -7.959854997352522E-11
+ }
+]
diff --git a/documentation/evidence/g1-20261018/cancel-retry-click-utc.txt b/documentation/evidence/g1-20261018/cancel-retry-click-utc.txt
new file mode 100644
index 00000000..20407961
--- /dev/null
+++ b/documentation/evidence/g1-20261018/cancel-retry-click-utc.txt
@@ -0,0 +1 @@
+2026-10-18T08:40:58.026Z
\ No newline at end of file
diff --git a/documentation/evidence/g1-20261018/cancel-retry-running.jpg b/documentation/evidence/g1-20261018/cancel-retry-running.jpg
new file mode 100644
index 00000000..5122ea06
Binary files /dev/null and b/documentation/evidence/g1-20261018/cancel-retry-running.jpg differ
diff --git a/documentation/evidence/g1-20261018/cancel-retry-summary.jpg b/documentation/evidence/g1-20261018/cancel-retry-summary.jpg
new file mode 100644
index 00000000..c255dd0b
Binary files /dev/null and b/documentation/evidence/g1-20261018/cancel-retry-summary.jpg differ
diff --git a/documentation/evidence/g1-20261018/cancellation-assertions.json b/documentation/evidence/g1-20261018/cancellation-assertions.json
new file mode 100644
index 00000000..d110cec4
--- /dev/null
+++ b/documentation/evidence/g1-20261018/cancellation-assertions.json
@@ -0,0 +1,13 @@
+{
+ "Run": "cancel/000000_SAM_AnalyticalModel_OpenStudio_20261018-084042_454bf00b",
+ "CompletedResultPublished": false,
+ "EngineExitCode": -1,
+ "LastObservedNativeUtc": "2026-10-18T08:40:57.8459393Z",
+ "EnergyPlusPid": 45448,
+ "Status": "Cancelled",
+ "ExternalKillUsed": false,
+ "FinalNativeProcessObservationEmpty": true,
+ "ClickUtc": "2026-10-18T08:40:58.026Z",
+ "OpenStudioPid": 49008,
+ "UiPid": 25072
+}
diff --git a/documentation/evidence/g1-20261018/candidate-dependency-pins.csv b/documentation/evidence/g1-20261018/candidate-dependency-pins.csv
new file mode 100644
index 00000000..f5a10a02
--- /dev/null
+++ b/documentation/evidence/g1-20261018/candidate-dependency-pins.csv
@@ -0,0 +1,16 @@
+"Repository","Head","Branch","Dirty"
+"SAM","618db819300df8b698b3bbe5174a203244134738","sow/2026-Q4",""
+"SAM_Mollier","7f61916adad9ad913904d18047ec6bfe835d1611","sow/2026-Q4",""
+"SAM_Systems","f0838ca3eba3e4b5f1cee008ba0831b6d7e95625","sow/2026-Q4",""
+"SAM_Windows","11bbecfa8aa854f4d9b2494c8bca737298982751","sow/2026-Q4",""
+"SAM_gbXML","43b5d018ac7a8a14f332e8bcde9cb0d613bf1efb","sow/2026-Q4",""
+"SAM_SolarCalculator","277a3a4864f2bf900504a64a4830cb9438312d78","sow/2026-Q4",""
+"SAM_Validation","68eded0060047af714853a02a152fb9c960bfeb1","sow/2026-Q4",""
+"SAM_SQLite","ec2eb86d00cac6e4cadf52b429f9d293eeabbd5e","sow/2026-Q4",""
+"SAM_OpenStudio","d4216f3525c01ca762c1b3e9b6d099733d037f54","sow/2026-Q4",""
+"SAM_Tas","a34a298dde2803de514081a0ed72ac3ff790997b","sow/2026-Q4",""
+"SAM_Excel","6669977d07cc6488c0dd91fe0a48ccd2dc833627","sow/2026-Q4",""
+"SAM_GEM","1bf41916fefa6dd5fe44a735c294619d3d8df8d9","sow/2026-Q4",""
+"SAM_LadybugTools","0ffbaecf1097a33de2044ee919c963eaeb0a0da2","sow/2026-Q4",""
+"SAM_Solver","fd63b4670cd4baeb20a74312aacc17eb177ecc18","sow/2026-Q4",""
+"SAM_UI","b7483443f53dd53a4d16f159d6bc327c901a7770","claude/openstudio-mcp-server-1jmjlo",""
diff --git a/documentation/evidence/g1-20261018/dependency-binary-hashes.csv b/documentation/evidence/g1-20261018/dependency-binary-hashes.csv
new file mode 100644
index 00000000..e8a47c46
--- /dev/null
+++ b/documentation/evidence/g1-20261018/dependency-binary-hashes.csv
@@ -0,0 +1,9 @@
+"File","SHA256","Match"
+"SAM.Analytical.dll","9576DCD158E3545D73F6B29706B73AF7D20C09325193257927BC46EE31119970","True"
+"SAM.Analytical.Tas.dll","DE942E4013CD65E181D5F4D4BFE3BAA9CC55C85F5ACAF9E3F8BE0492DBC90AEA","True"
+"SAM.Core.Tas.dll","F1D5D82BEE8B0C75B58B61652593AB936DC16980FC24C070A92D7F99F7F4632C","True"
+"SAM.Analytical.OpenStudio.dll","B1B5B9EFCE7EBB0938B8DDA3BF8BC497091EE79083DCD53F446EDD65E97402BB","True"
+"SAM.Core.OpenStudio.dll","09C77B85B9A54D192C23878E5956E9B41A7C4E9DB5ED37030591E9A51247A842","True"
+"Interop.TBD.dll","950B7A3B0F51AE5ECA103EAF51584F1377343ED7B8B19D2818141300EC90CE67","True"
+"Interop.TSD.dll","EA48ADB648040A9FB43A633D9EDB24BFD2C985434EE2D6C7F85C88F2B18A0524","True"
+"OpenStudio.dll","678BDACE11401E751FD661D9638944613B8802E6E8DC01809D1DBFA7FBEDE499","True"
diff --git a/documentation/evidence/g1-20261018/equality-observed.json b/documentation/evidence/g1-20261018/equality-observed.json
new file mode 100644
index 00000000..7b3b1d65
--- /dev/null
+++ b/documentation/evidence/g1-20261018/equality-observed.json
@@ -0,0 +1,30 @@
+[
+ {
+ "Metric": "Consumption Cooling",
+ "Standalone": 5764.530483239447,
+ "Benchmark": 5764.530483144583,
+ "Difference": 9.486393537372351E-08,
+ "Relative": 1.6456489500941057E-11
+ },
+ {
+ "Metric": "Peak Heating Load",
+ "Standalone": 19.82144246485972,
+ "Benchmark": 19.821442464872938,
+ "Difference": -1.3216094885137863E-11,
+ "Relative": -6.667574728004329E-13
+ },
+ {
+ "Metric": "Consumption Heating",
+ "Standalone": 31072.86245449682,
+ "Benchmark": 31072.862454810253,
+ "Difference": -3.134337021037936E-07,
+ "Relative": -1.0087055949854157E-11
+ },
+ {
+ "Metric": "Peak Cooling Load",
+ "Standalone": 14.079292685507252,
+ "Benchmark": 14.07929268578085,
+ "Difference": -2.735980331181054E-10,
+ "Relative": -1.943265469538972E-11
+ }
+]
diff --git a/documentation/evidence/g1-20261018/explicit-file-weather-summary.jpg b/documentation/evidence/g1-20261018/explicit-file-weather-summary.jpg
new file mode 100644
index 00000000..bd05ba8d
Binary files /dev/null and b/documentation/evidence/g1-20261018/explicit-file-weather-summary.jpg differ
diff --git a/documentation/evidence/g1-20261018/idf-semantic-audit.json b/documentation/evidence/g1-20261018/idf-semantic-audit.json
new file mode 100644
index 00000000..6fc65d74
--- /dev/null
+++ b/documentation/evidence/g1-20261018/idf-semantic-audit.json
@@ -0,0 +1,23 @@
+[
+ {
+ "path": "C:\\TasOut\\pr237-g1-20261018\\explicit\\000000_SAM_AnalyticalModel_OpenStudio_20261018-084639_45a832b9\\run\\in.idf",
+ "objects": 163,
+ "normalizedSha256": "3b0dbe8f96a18c8b99b311811b2cccdc15c7b34046a549a0981a385cdf0da397",
+ "sameEngineeringFields": true,
+ "differentObjects": []
+ },
+ {
+ "path": "C:\\TasOut\\pr237-g1-20261018\\explicit\\benchmark-repeat-work\\SAM_OpenStudio_1b6415a0\\run\\in.idf",
+ "objects": 163,
+ "normalizedSha256": "3b0dbe8f96a18c8b99b311811b2cccdc15c7b34046a549a0981a385cdf0da397",
+ "sameEngineeringFields": true,
+ "differentObjects": []
+ },
+ {
+ "path": "C:\\TasOut\\pr237-g1-20261018\\explicit\\benchmark-work\\SAM_OpenStudio_2e63e0bb\\run\\in.idf",
+ "objects": 163,
+ "normalizedSha256": "3b0dbe8f96a18c8b99b311811b2cccdc15c7b34046a549a0981a385cdf0da397",
+ "sameEngineeringFields": true,
+ "differentObjects": []
+ }
+]
\ No newline at end of file
diff --git a/documentation/evidence/g1-20261018/input-hashes.csv b/documentation/evidence/g1-20261018/input-hashes.csv
new file mode 100644
index 00000000..02223a4e
--- /dev/null
+++ b/documentation/evidence/g1-20261018/input-hashes.csv
@@ -0,0 +1,6 @@
+"Algorithm","Hash","Path"
+"SHA256","B2AB6826E085D621CFD219D58D924F38CC02A3406852D39BFAD8AEB205ABF32D","C:\Users\michal\Documents\GitHub\SAM-BIM\SAM_OpenStudio\tests\resources\models\rawModel-WindowsWeatherDataDDYAdiabaticAirPanel.sam"
+"SHA256","EDED5C982C1DA37E35930622BADF7F0E0A0A82B30FD9410723EA3F1C4B52B80B","C:\Users\michal\Documents\GitHub\SAM-BIM\SAM_OpenStudio\tests\resources\weather\USA_MA_Boston-Logan.Intl.AP.725090_TMYx.2004-2018.epw"
+"SHA256","B2AB6826E085D621CFD219D58D924F38CC02A3406852D39BFAD8AEB205ABF32D","C:\TasOut\pr237-g1-20261018\inputs\two-cell.sam"
+"SHA256","EDED5C982C1DA37E35930622BADF7F0E0A0A82B30FD9410723EA3F1C4B52B80B","C:\TasOut\pr237-g1-20261018\inputs\Boston.epw"
+"SHA256","286B1E6A98687F0CDF2975F4AFE8030206223A7C47F03B256BEAE8B04567AE43","C:\TasOut\pr237-g1-20261018\inputs\box-aperture.sam"
diff --git a/documentation/evidence/g1-20261018/isolation-assertions.json b/documentation/evidence/g1-20261018/isolation-assertions.json
new file mode 100644
index 00000000..f6035e71
--- /dev/null
+++ b/documentation/evidence/g1-20261018/isolation-assertions.json
@@ -0,0 +1,11 @@
+{
+ "status": "Succeeded",
+ "startedUtc": "2026-10-18T08:55:02.4628828Z",
+ "finishedUtc": "2026-10-18T08:56:14.1641905Z",
+ "spaceGuid": "c7df54b3c0064fc5a7c6e6012dbb9b8e",
+ "snapshotName": "GF_07 LivingRoom",
+ "resultName": "GF_07 LivingRoom",
+ "liveSavedName": "G1_ISOLATION_EDIT",
+ "snapshotSha256": "8247570edfe72ebc801f97a048ffd7e53207f949baa378c591090716d001491b",
+ "passed": true
+}
\ No newline at end of file
diff --git a/documentation/evidence/g1-20261018/isolation-completed-live-edit.jpg b/documentation/evidence/g1-20261018/isolation-completed-live-edit.jpg
new file mode 100644
index 00000000..9dc6c9ab
Binary files /dev/null and b/documentation/evidence/g1-20261018/isolation-completed-live-edit.jpg differ
diff --git a/documentation/evidence/g1-20261018/isolation-edit-commit-utc.txt b/documentation/evidence/g1-20261018/isolation-edit-commit-utc.txt
new file mode 100644
index 00000000..afd1b1d3
--- /dev/null
+++ b/documentation/evidence/g1-20261018/isolation-edit-commit-utc.txt
@@ -0,0 +1 @@
+2026-10-18T08:56:09.092Z
\ No newline at end of file
diff --git a/documentation/evidence/g1-20261018/provenance-audit.json b/documentation/evidence/g1-20261018/provenance-audit.json
new file mode 100644
index 00000000..a88101e3
--- /dev/null
+++ b/documentation/evidence/g1-20261018/provenance-audit.json
@@ -0,0 +1,12 @@
+{
+ "fixturePreserved": true,
+ "weatherSource": "File",
+ "weatherSha256": "sha256:eded5c982c1da37e35930622badf7f0e0a0a82b30fd9410723ea3f1c4b52b80b",
+ "modelGuid": "b74990da-73b2-4097-8846-cc12560be0aa",
+ "benchmarkCanonicalModelHash": "sha256:356dcbed711b91d47a0f391ff306aaa352b102581f6a8f062da4858578026317",
+ "samCommit": "618db819300df8b698b3bbe5174a203244134738",
+ "openStudioCommit": "d4216f3525c01ca762c1b3e9b6d099733d037f54",
+ "designDaySource": "EmbeddedModel",
+ "strictMetricEquality": false,
+ "gate": "HOLD: required bit-exact standalone/benchmark equality not achieved; same-engine CLI repeats also vary. No tolerance change or deferral authorized."
+}
\ No newline at end of file
diff --git a/documentation/evidence/g1-20261018/tas-baseline-settings.jpg b/documentation/evidence/g1-20261018/tas-baseline-settings.jpg
new file mode 100644
index 00000000..8fbb21e7
Binary files /dev/null and b/documentation/evidence/g1-20261018/tas-baseline-settings.jpg differ
diff --git a/documentation/evidence/g1-20261018/tas-candidate-settings.jpg b/documentation/evidence/g1-20261018/tas-candidate-settings.jpg
new file mode 100644
index 00000000..cd5c9533
Binary files /dev/null and b/documentation/evidence/g1-20261018/tas-candidate-settings.jpg differ
diff --git a/documentation/evidence/g1-20261018/tas-comparison.json b/documentation/evidence/g1-20261018/tas-comparison.json
new file mode 100644
index 00000000..d85c56ad
--- /dev/null
+++ b/documentation/evidence/g1-20261018/tas-comparison.json
@@ -0,0 +1,17 @@
+{
+ "criterion": "Exact equality of every numeric leaf of persisted simulation parameters, aligned by result type/name/load type and surface reference. No numerical tolerance. Run-generated result/zone identities and timestamps are metadata, not numerical outputs.",
+ "baselineCounts": {
+ "SAM.Analytical.SpaceSimulationResult": 2,
+ "SAM.Analytical.SurfaceSimulationResult": 28,
+ "SAM.Analytical.AdjacencyClusterSimulationResult": 2
+ },
+ "candidateCounts": {
+ "SAM.Analytical.SpaceSimulationResult": 2,
+ "SAM.Analytical.SurfaceSimulationResult": 28,
+ "SAM.Analytical.AdjacencyClusterSimulationResult": 2
+ },
+ "baselineNumbers": 608,
+ "candidateNumbers": 608,
+ "exactMatch": true,
+ "differences": []
+}
\ No newline at end of file
diff --git a/documentation/evidence/g1-20261018/ui-build-hashes.csv b/documentation/evidence/g1-20261018/ui-build-hashes.csv
new file mode 100644
index 00000000..ce439f34
--- /dev/null
+++ b/documentation/evidence/g1-20261018/ui-build-hashes.csv
@@ -0,0 +1,3 @@
+"Algorithm","Hash","Path"
+"SHA256","3B106B737BC9E4F7BD711F7F51C9CB551778DCDE4AA66710A37842AA4277A86F","C:\TasOut\pr237-g1-20261018\candidate\SAM.Analytical.UI.WPF.dll"
+"SHA256","5A969D49FC035B5E17E703892813AA3E9D90B0CBFDCF84CA488A878FFF961037","C:\TasOut\pr237-g1-20261018\baseline\SAM.Analytical.UI.WPF.dll"