diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..3171155
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,4 @@
+# The benchmark producer golden fixtures are asserted byte-for-byte against the serializer
+# output, which is UTF-8 without BOM and uses LF newlines. Pin them to LF so Git's autocrlf
+# setting cannot rewrite them to CRLF on checkout and break the byte-stability comparison.
+benchmark/**/*.json text eol=lf
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 45e997d..02f145d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -89,6 +89,62 @@ jobs:
# Current sow branch: base_ref on PR events, ref_name on push events.
$sowRef = if ($baseRef -match '^sow/') { $baseRef } elseif ($refName -match '^sow/') { $refName } else { '' }
if ($sowRef -and $sowRef -ne $headRef) { $candidates += $sowRef }
+ # Feature branches off a quarterly sow branch (e.g. sow/2026-Q3-feature): the
+ # quarter branch itself carries the matching dependency state. Without this a
+ # push-event build (no head/base ref) falls straight to the stale fallback below.
+ # NOTE: the loop variable must NOT be $r — that is the outer repo-name variable.
+ foreach ($branchRef in @($headRef, $baseRef, $refName)) {
+ if ($branchRef -match '^sow/(\d{4}-Q\d)') {
+ $quarterRef = "sow/$($Matches[1])"
+ if ($candidates -notcontains $quarterRef) { $candidates += $quarterRef }
+ }
+ }
+ $candidates += 'sow/2026-Q2'
+ $cloned = $false
+ foreach ($cand in $candidates) {
+ $has = (git ls-remote --heads "https://github.com/$org/$r.git" $cand 2>$null | Out-String).Trim()
+ if ($has) {
+ Write-Host "Cloning $org/$r @ $cand"
+ git clone --depth 1 --branch $cand "https://github.com/$org/$r.git" $r
+ $cloned = $true
+ break
+ }
+ }
+ if (-not $cloned) {
+ Write-Host "Cloning $org/$r @ default branch"
+ git clone --depth 1 "https://github.com/$org/$r.git" $r
+ }
+ }
+
+
+ - name: Build benchmark schema (SAM_Validation)
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+
+ $org = '${{ env.ORG_NAME }}'
+ $r = 'SAM_Validation'
+
+ # Clone SAM_Validation as a sibling (same branch-candidate fallback as the deps step
+ # above), then build ONLY the engine-neutral benchmark schema (B1a) into its build\
+ # folder. The B1b producer references it by HintPath
+ # (..\..\..\SAM_Validation\build\SAM.Analytical.Benchmark.dll), so it must exist before
+ # SAM_OpenStudio is built below. The schema project is standalone (netstandard2.0,
+ # System.Text.Json only) — no other SAM_Validation project is built.
+ if (-not (Test-Path $r)) {
+ $headRef = '${{ github.head_ref }}'
+ $baseRef = '${{ github.base_ref }}'
+ $refName = '${{ github.ref_name }}'
+ $candidates = @()
+ if ($headRef) { $candidates += $headRef }
+ $sowRef = if ($baseRef -match '^sow/') { $baseRef } elseif ($refName -match '^sow/') { $refName } else { '' }
+ if ($sowRef -and $sowRef -ne $headRef) { $candidates += $sowRef }
+ foreach ($branchRef in @($headRef, $baseRef, $refName)) {
+ if ($branchRef -match '^sow/(\d{4}-Q\d)') {
+ $quarterRef = "sow/$($Matches[1])"
+ if ($candidates -notcontains $quarterRef) { $candidates += $quarterRef }
+ }
+ }
$candidates += 'sow/2026-Q2'
$cloned = $false
foreach ($cand in $candidates) {
@@ -106,6 +162,14 @@ jobs:
}
}
+ $csproj = Join-Path $r 'SAM.Analytical.Benchmark/SAM.Analytical.Benchmark.csproj'
+ Write-Host "==> Building benchmark schema: $csproj"
+ & dotnet build $csproj -c Release '/p:SAMVersion=${{ steps.ver.outputs.samversion }}'
+ if ($LASTEXITCODE -ne 0) { throw "Benchmark schema build failed" }
+
+ $dll = Join-Path $r 'build/SAM.Analytical.Benchmark.dll'
+ if (-not (Test-Path $dll)) { throw "Expected schema DLL missing after build: $dll" }
+ Write-Host "Schema DLL present: $dll"
- name: Restore + Rebuild in order (safe Platform handling)
shell: pwsh
@@ -188,6 +252,27 @@ jobs:
if ($LASTEXITCODE -ne 0) { throw "Rebuild failed: $($it.Repo)" }
}
+ - name: Run benchmark producer tests (offline)
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+
+ $repo = '${{ env.REPO_NAME }}'
+
+ # Run the portable B1b producer tests: the C5 -> B1a mapping, the committed golden
+ # JSON byte-stability check and the CLI contract tests. The single live end-to-end
+ # test is [Category("Simulation")] and is excluded here (it needs an OpenStudio/
+ # EnergyPlus install); it remains available for an environment-gated run.
+ $testProject = Join-Path $repo 'benchmark/SAM.Analytical.OpenStudio.Benchmark.Tests/SAM.Analytical.OpenStudio.Benchmark.Tests.csproj'
+ Write-Host "==> Testing (excluding Simulation): $testProject"
+ & dotnet test $testProject `
+ -c Release `
+ '/p:Platform=x64' `
+ '/p:SAMVersion=${{ steps.ver.outputs.samversion }}' `
+ --filter 'TestCategory!=Simulation' `
+ --logger 'console;verbosity=normal'
+ if ($LASTEXITCODE -ne 0) { throw "Benchmark producer tests failed" }
+
- name: Upload build folders (optional)
uses: actions/upload-artifact@v4
with:
diff --git a/Grasshopper/SAM.Analytical.Grasshopper.OpenStudio/Component/MenuHelper.cs b/Grasshopper/SAM.Analytical.Grasshopper.OpenStudio/Component/MenuHelper.cs
new file mode 100644
index 0000000..ba3ff25
--- /dev/null
+++ b/Grasshopper/SAM.Analytical.Grasshopper.OpenStudio/Component/MenuHelper.cs
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using Grasshopper.Kernel;
+using Grasshopper.Kernel.Types;
+using System.Linq;
+
+namespace SAM.Analytical.Grasshopper.OpenStudio
+{
+ ///
+ /// Shared helpers for the "Go to Directory" context-menu item (same behaviour as
+ /// SAMAnalytical.Paths in the SAM repository): the path is read from the volatile data
+ /// of an input parameter and opened in Windows Explorer.
+ ///
+ internal static class MenuHelper
+ {
+ internal static string GetVolatileString(GH_Component component, string name)
+ {
+ int index = component.Params.IndexOfInputParam(name);
+ if (index == -1)
+ {
+ return null;
+ }
+
+ object @object = component.Params.Input[index].VolatileData.AllData(true)?.OfType