diff --git a/SAM_OpenStudio/SAM.Core.OpenStudio/Query/OpenStudioCliPath.cs b/SAM_OpenStudio/SAM.Core.OpenStudio/Query/OpenStudioCliPath.cs
index 9182bb9..d8c91bd 100644
--- a/SAM_OpenStudio/SAM.Core.OpenStudio/Query/OpenStudioCliPath.cs
+++ b/SAM_OpenStudio/SAM.Core.OpenStudio/Query/OpenStudioCliPath.cs
@@ -16,6 +16,9 @@ public static partial class Query
/// 2. directories on the PATH environment variable;
/// 3. conventional direct installations (system drive root openstudio-* and %ProgramFiles%\OpenStudio*), newest first;
/// 4. Ladybug Tools bundled installation (%ProgramFiles%\ladybug_tools\openstudio).
+ /// Without an explicit path, when more than one CLI is found, the first one (in that order) whose version equals the
+ /// loaded OpenStudio SDK version () is returned, so that another OpenStudio earlier on
+ /// PATH is not picked by accident; when none matches, the first one found is returned, as before.
///
/// Optional explicit executable or installation directory path.
/// Full path to openstudio.exe or null when not found.
@@ -48,6 +51,194 @@ public static string OpenStudioCliPath(string path = null)
return null;
}
+ List candidates = OpenStudioCliCandidates(executableName);
+ if (candidates.Count < 2)
+ {
+ return candidates.Count == 0 ? null : candidates[0];
+ }
+
+ string sdkVersion;
+ try
+ {
+ sdkVersion = OpenStudioVersion();
+ }
+ catch (Exception)
+ {
+ sdkVersion = null;
+ }
+
+ return OpenStudioCliPath(candidates, sdkVersion, OpenStudioCliVersion);
+ }
+
+ ///
+ /// Chooses among discovered OpenStudio CLI candidates: the first whose version equals
+ /// (compared without +build metadata), otherwise the first candidate.
+ ///
+ /// Candidate openstudio.exe paths in discovery order.
+ /// Loaded OpenStudio SDK version; when null or empty the first candidate is returned.
+ /// Returns a candidate's version (for example "3.10.0+86d7e215a1"), or null when unknown.
+ /// The selected candidate, or null when there is none.
+ public static string OpenStudioCliPath(IEnumerable candidates, string sdkVersion, Func cliVersion)
+ {
+ if (candidates == null)
+ {
+ return null;
+ }
+
+ string sdkCoreVersion = CoreVersion(sdkVersion);
+ string first = null;
+ foreach (string candidate in candidates)
+ {
+ if (string.IsNullOrWhiteSpace(candidate))
+ {
+ continue;
+ }
+
+ if (first == null)
+ {
+ first = candidate;
+ }
+
+ if (sdkCoreVersion != null && cliVersion != null && string.Equals(CoreVersion(cliVersion(candidate)), sdkCoreVersion, StringComparison.OrdinalIgnoreCase))
+ {
+ return candidate;
+ }
+ }
+
+ return first;
+ }
+
+ ///
+ /// Version reported by an OpenStudio CLI (openstudio.exe --version, for example "3.10.0+86d7e215a1").
+ /// The result is cached per executable path, size and write time.
+ ///
+ /// Path to openstudio.exe.
+ /// The first non-empty line the CLI printed, or null when it could not be run or printed nothing.
+ public static string OpenStudioCliVersion(string cliPath)
+ {
+ if (string.IsNullOrWhiteSpace(cliPath) || !File.Exists(cliPath))
+ {
+ return null;
+ }
+
+ string key;
+ try
+ {
+ FileInfo fileInfo = new FileInfo(cliPath);
+ key = string.Format("{0}|{1}|{2}", fileInfo.FullName.ToUpperInvariant(), fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+
+ lock (openStudioCliVersions)
+ {
+ if (openStudioCliVersions.TryGetValue(key, out string cached))
+ {
+ return cached;
+ }
+ }
+
+ string result = null;
+ try
+ {
+ System.Diagnostics.ProcessStartInfo processStartInfo = new System.Diagnostics.ProcessStartInfo(cliPath, "--version")
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+
+ using (System.Diagnostics.Process process = System.Diagnostics.Process.Start(processStartInfo))
+ {
+ System.Threading.Tasks.Task output = process.StandardOutput.ReadToEndAsync();
+ System.Threading.Tasks.Task error = process.StandardError.ReadToEndAsync();
+ if (!process.WaitForExit(30000))
+ {
+ try
+ {
+ process.Kill();
+ }
+ catch (Exception)
+ {
+ // the CLI may already have exited
+ }
+
+ return null;
+ }
+
+ foreach (string line in output.Result.Split('\n'))
+ {
+ if (!string.IsNullOrWhiteSpace(line))
+ {
+ result = line.Trim();
+ break;
+ }
+ }
+ }
+ }
+ catch (Exception)
+ {
+ result = null;
+ }
+
+ lock (openStudioCliVersions)
+ {
+ openStudioCliVersions[key] = result;
+ }
+
+ return result;
+ }
+
+ private static readonly Dictionary openStudioCliVersions = new Dictionary();
+
+ private static string CoreVersion(string version)
+ {
+ if (string.IsNullOrWhiteSpace(version))
+ {
+ return null;
+ }
+
+ string result = version.Trim();
+ int index = result.IndexOf('+');
+ if (index >= 0)
+ {
+ result = result.Substring(0, index).Trim();
+ }
+
+ return result.Length == 0 ? null : result;
+ }
+
+ private static List OpenStudioCliCandidates(string executableName)
+ {
+ List result = new List();
+ HashSet fullPaths = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ void Add(string candidate)
+ {
+ if (!File.Exists(candidate))
+ {
+ return;
+ }
+
+ string fullPath;
+ try
+ {
+ fullPath = Path.GetFullPath(candidate);
+ }
+ catch (Exception)
+ {
+ return;
+ }
+
+ if (fullPaths.Add(fullPath))
+ {
+ result.Add(candidate);
+ }
+ }
+
string pathVariable = Environment.GetEnvironmentVariable("PATH");
if (!string.IsNullOrWhiteSpace(pathVariable))
{
@@ -68,10 +259,7 @@ public static string OpenStudioCliPath(string path = null)
continue;
}
- if (File.Exists(candidate))
- {
- return candidate;
- }
+ Add(candidate);
}
}
@@ -101,14 +289,10 @@ public static string OpenStudioCliPath(string path = null)
foreach (string installationDirectory in installationDirectories)
{
- string candidate = Path.Combine(installationDirectory, "bin", executableName);
- if (File.Exists(candidate))
- {
- return candidate;
- }
+ Add(Path.Combine(installationDirectory, "bin", executableName));
}
- return null;
+ return result;
}
private static IEnumerable InstallationDirectories(string directory, string searchPattern)
diff --git a/benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/OpenStudioCliSelectionTests.cs b/benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/OpenStudioCliSelectionTests.cs
new file mode 100644
index 0000000..1d8c76a
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/OpenStudioCliSelectionTests.cs
@@ -0,0 +1,106 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020-2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using System.Collections.Generic;
+using System.IO;
+using NUnit.Framework;
+
+namespace SAM.Analytical.OpenStudio.Benchmark.Tests
+{
+ ///
+ /// Roadmap S06: the OpenStudio CLI that runs a simulation is chosen deterministically. Among the discovered CLIs
+ /// (PATH, direct installations, ladybug_tools, in that order) the first whose version equals the in-process SDK version
+ /// wins, so another OpenStudio earlier on PATH (ladybug's 3.11 on the S05 laptop) is not picked by accident; with no
+ /// match the first one found is kept, as before. Fully offline: candidate versions are supplied by the test.
+ ///
+ [TestFixture]
+ public class OpenStudioCliSelectionTests
+ {
+ private const string Ladybug = @"C:\Program Files\ladybug_tools\openstudio\bin\openstudio.exe";
+ private const string Direct = @"C:\openstudio-3.10.0\bin\openstudio.exe";
+ private const string Other = @"D:\tools\OpenStudio-3.10.0\bin\openstudio.exe";
+
+ private static readonly Dictionary Versions = new Dictionary
+ {
+ { Ladybug, "3.11.0+241b8abb4d" },
+ { Direct, "3.10.0+86d7e215a1" },
+ { Other, "3.10.0+86d7e215a1" },
+ };
+
+ private static string Version(string path)
+ {
+ return Versions.TryGetValue(path, out string version) ? version : null;
+ }
+
+ [Test]
+ public void SdkMatchingCli_IsPreferred_OverAnEarlierDifferentVersionOnPath()
+ {
+ string selected = Core.OpenStudio.Query.OpenStudioCliPath(new[] { Ladybug, Direct }, "3.10.0", Version);
+ Assert.That(selected, Is.EqualTo(Direct));
+ }
+
+ [Test]
+ public void FirstMatchingCli_Wins_WhenSeveralMatch()
+ {
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliPath(new[] { Ladybug, Other, Direct }, "3.10.0", Version), Is.EqualTo(Other));
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliPath(new[] { Ladybug, Direct, Other }, "3.10.0", Version), Is.EqualTo(Direct));
+ }
+
+ [Test]
+ public void NoMatchingCli_KeepsTheFirstCandidate()
+ {
+ string selected = Core.OpenStudio.Query.OpenStudioCliPath(new[] { Ladybug, Direct }, "3.9.0", Version);
+ Assert.That(selected, Is.EqualTo(Ladybug));
+ }
+
+ [Test]
+ public void UnknownSdkVersion_KeepsTheFirstCandidate()
+ {
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliPath(new[] { Ladybug, Direct }, null, Version), Is.EqualTo(Ladybug));
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliPath(new[] { Ladybug, Direct }, " ", Version), Is.EqualTo(Ladybug));
+ }
+
+ [Test]
+ public void BuildMetadata_IsIgnored_OnBothSides()
+ {
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliPath(new[] { Ladybug, Direct }, "3.10.0+86d7e215a1", Version), Is.EqualTo(Direct));
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliPath(new[] { Ladybug, Direct }, "3.11.0", Version), Is.EqualTo(Ladybug));
+ }
+
+ [Test]
+ public void CliWithUnknownVersion_IsNotAMatch()
+ {
+ string unknown = @"E:\broken\openstudio.exe";
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliPath(new[] { unknown, Direct }, "3.10.0", Version), Is.EqualTo(Direct));
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliPath(new[] { unknown }, "3.10.0", Version), Is.EqualTo(unknown));
+ }
+
+ [Test]
+ public void NoCandidates_ReturnsNull_AndBlankEntriesAreSkipped()
+ {
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliPath(new string[0], "3.10.0", Version), Is.Null);
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliPath(null, "3.10.0", Version), Is.Null);
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliPath(new[] { "", null, Ladybug }, "3.9.0", Version), Is.EqualTo(Ladybug));
+ }
+
+ [Test]
+ public void CliVersion_OfAMissingExecutable_IsNull()
+ {
+ string missing = Path.Combine(TestContext.CurrentContext.WorkDirectory, "no-such-folder", "openstudio.exe");
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliVersion(missing), Is.Null);
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliVersion(null), Is.Null);
+ }
+
+ [Test]
+ public void ExplicitPath_IsUnchanged_AndNeverVersionChecked()
+ {
+ string directory = Path.Combine(TestContext.CurrentContext.WorkDirectory, "s06-explicit-cli", "bin");
+ Directory.CreateDirectory(directory);
+ string executable = Path.Combine(directory, "openstudio.exe");
+ File.WriteAllText(executable, "not an executable");
+
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliPath(executable), Is.EqualTo(executable));
+ Assert.That(Core.OpenStudio.Query.OpenStudioCliPath(Path.GetDirectoryName(directory)), Is.EqualTo(executable));
+ }
+ }
+}
diff --git a/documentation/S06-OpenStudio-Cli-Selection-PR.md b/documentation/S06-OpenStudio-Cli-Selection-PR.md
new file mode 100644
index 0000000..1e0311e
--- /dev/null
+++ b/documentation/S06-OpenStudio-Cli-Selection-PR.md
@@ -0,0 +1,62 @@
+# S06 - OpenStudio CLI selection matches the SDK - PR record
+
+Roadmap task S06 (`SAM/documentation/SIMULATION_ROADMAP_Q4_2026.md`), supporting PR for SAM-BIM/SAM_Deploy's S06 smoke
+candidate (branch `feature/s06-installed-smoke-candidate`). Base: `sow/2026-Q4` (`d4216f35`). Branch:
+`fix/s06-openstudio-cli-sdk-match`. Validated content SHA: `1f4bf60c9b34dceef9d20310c403e2c44f3dcd4c` (code and tests).
+Record-only commits follow; the final PR head and hosted checks are in the PR description.
+Merge needs owner approval (AGENTS.md). `PROJECT_PROGRESS.md` is not touched (post-merge closeout only).
+
+## Status
+Ready for review. Local tests pass. The licensed installed-candidate smoke results (Tas, OpenStudio and Both from the
+installed SAM_Deploy payload) are recorded in SAM_Deploy's S06 record, which consumes this commit through its
+SAM_OpenStudio gitlink.
+
+## Problem
+`Query.OpenStudioCliPath()` with no explicit path returned the first `openstudio.exe` on `PATH`. Neither SAM_UI's
+OpenStudio route nor `benchmark-openstudio` passes a path, so `PATH` decided which engine ran. On the S05 laptop the
+in-process SDK is OpenStudio 3.10.0 (EnergyPlus 25.1.0), while the CLI otherwise found is ladybug_tools' OpenStudio 3.11.0
+(EnergyPlus 25.2.0). S03 (SAM_UI#242) recorded this; S05 worked around it by putting 3.10 first on `PATH` in the harness.
+
+## Change
+`SAM_OpenStudio/SAM.Core.OpenStudio/Query/OpenStudioCliPath.cs`:
+- Discovery order unchanged: explicit path; `PATH`; `\openstudio*` and `%ProgramFiles%\OpenStudio*`, newest
+ first; `%ProgramFiles%\ladybug_tools\openstudio`. Duplicate paths are listed once.
+- No explicit path and more than one CLI found: the first whose `openstudio.exe --version` equals the loaded SDK version
+ (`Query.OpenStudioVersion()`, build metadata after `+` ignored) is returned. No match, or the SDK version unknown: the
+ first one found, as before. One or no CLI found: unchanged, no version probe.
+- An explicit path is never version-checked (unchanged).
+- New public helpers: `OpenStudioCliPath(candidates, sdkVersion, cliVersion)` (the pure selection rule) and
+ `OpenStudioCliVersion(cliPath)` (`--version`, 30 s timeout, cached per path/size/write time).
+
+Not changed: the SDK integration, the runner, diagnostics, conversion, results. A mismatch that remains (only a
+different version installed) stays visible where S03 already records it: the OpenStudio run manifest's
+`engine.openStudioSdkVersion`, `engine.energyPlusVersion` (from `eplusout.err`) and `engine.cli` (path, hash, version).
+
+## Decisions and assumptions
+- Prefer, do not refuse: refusing a non-matching CLI would stop users who only have another OpenStudio installed; that is
+ an owner decision, not S06's minimal correction.
+- Match on the full core version (`3.10.0`), not major.minor: the EnergyPlus build changes with the OpenStudio patch.
+
+## Files changed
+- `SAM_OpenStudio/SAM.Core.OpenStudio/Query/OpenStudioCliPath.cs`
+- `benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/OpenStudioCliSelectionTests.cs` (new; 9 offline tests, in the
+ project hosted CI runs)
+- `documentation/S06-OpenStudio-Cli-Selection-PR.md` (this record)
+
+## Validation (local, Windows, on `1f4bf60c`)
+- `dotnet test benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/SAM.Analytical.OpenStudio.Benchmark.Tests.csproj -c
+ Release -p:Platform=x64 --filter "TestCategory!=Simulation"`: **51/51 passed** (the 9 new included).
+- Mutation check: disabling the version match (always the first candidate) fails 4 of the 9 new tests (preference, first
+ match wins, build metadata, unknown version); restored.
+- End to end: SAM_Deploy S06 record (installed candidate run with ladybug's 3.11 deliberately first on `PATH` and 3.10
+ second).
+- Hosted CI: see the PR description.
+
+## Unresolved, risks
+- Each run with more than one CLI installed starts `openstudio.exe --version` once per candidate per process (cached).
+- `tests/SAM.Analytical.OpenStudio.Tests` (not run by hosted CI) was not rerun; it calls `OpenStudioCliPath()` only to
+ locate a CLI.
+
+## Next step
+Owner review. After merge, repoint SAM_Deploy's S06 SAM_OpenStudio gitlink from `1f4bf60c` to the merge commit, then the
+SAM_OpenStudio `PROJECT_PROGRESS.md` closeout.