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.