diff --git a/.gitattributes b/.gitattributes
index 3171155..7344634 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,3 +2,7 @@
# 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
+
+# S09: the S08 signed-off equivalence fixtures are reused byte for byte (sha256 in Fixtures/SHA256SUMS.txt, CRLF as
+# committed in SAM_Tas). Later rules win, so this keeps them out of the LF rule above.
+benchmark/SAM.Analytical.OpenStudio.InputReadBack/Fixtures/** -text
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 88f641a..d876d08 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -169,6 +169,51 @@ jobs:
if (-not (Test-Path $dll)) { throw "Expected schema DLL missing after build: $dll" }
Write-Host "Schema DLL present: $dll"
+ - name: Clone S08 evaluation core (SAM_Tas, sparse)
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+
+ $org = '${{ env.ORG_NAME }}'
+ $r = 'SAM_Tas'
+
+ # The S09 read-back (benchmark/SAM.Analytical.OpenStudio.InputReadBack) reuses, from SAM_Tas, the COM-free S08
+ # evaluation core (project reference) and the SAM-intent builder ReadBackExpected.cs (linked source). Only those
+ # two folders are checked out; nothing TAS/COM is built. Same branch-candidate order as the other dependencies.
+ 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 }
+ }
+ }
+ $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 (sparse)"
+ git clone --depth 1 --filter=blob:none --sparse --branch $cand "https://github.com/$org/$r.git" $r
+ $cloned = $true
+ break
+ }
+ }
+ if (-not $cloned) { throw "No SAM_Tas branch among: $($candidates -join ', ')" }
+ git -C $r sparse-checkout set benchmark/SAM.Analytical.InputEquivalence SAM_Tas/SAM.Analytical.Tas.DirectT3D.Validation/ReadBack
+ if ($LASTEXITCODE -ne 0) { throw "SAM_Tas sparse checkout failed" }
+ }
+
+ Write-Host ("SAM_Tas at " + (git -C $r rev-parse HEAD) + " (" + (git -C $r rev-parse --abbrev-ref HEAD) + ")")
+ foreach ($p in @('benchmark/SAM.Analytical.InputEquivalence/SAM.Analytical.InputEquivalence.csproj', 'SAM_Tas/SAM.Analytical.Tas.DirectT3D.Validation/ReadBack/ReadBackExpected.cs')) {
+ if (-not (Test-Path (Join-Path $r $p))) { throw "Missing from the SAM_Tas checkout: $p" }
+ }
+
- name: Restore + Rebuild in order (safe Platform handling)
shell: pwsh
run: |
@@ -271,6 +316,35 @@ jobs:
--logger 'console;verbosity=normal'
if ($LASTEXITCODE -ne 0) { throw "Benchmark producer tests failed" }
+ - name: Run S09 OpenStudio native-input read-back tests (offline)
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+
+ $repo = '${{ env.REPO_NAME }}'
+
+ # S09: the writer's ventilation policy and the fresh-process OSM/IDF read-back on the S08 signed-off fixtures with
+ # the SAM#212 Budapest EPW (from the SAM sibling). OpenStudio SDK NuGet only: no CLI, no EnergyPlus run.
+ $testProject = Join-Path $repo 'benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/SAM.Analytical.OpenStudio.InputReadBack.Tests.csproj'
+ Write-Host "==> Testing: $testProject"
+ & dotnet test $testProject `
+ -c Release `
+ '/p:Platform=x64' `
+ '/p:SAMVersion=${{ steps.ver.outputs.samversion }}' `
+ --logger 'console;verbosity=normal'
+ if ($LASTEXITCODE -ne 0) { throw "S09 read-back tests failed" }
+
+ - name: Upload S09 failure diagnostics
+ # Only when a step failed: the bounded text evidence (exit code, stdout/stderr, process identities, file list) the
+ # S09 tests write for a failed CLI step. No native files.
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ env.REPO_NAME }}-s09-diagnostics
+ path: ${{ env.REPO_NAME }}/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/bin/**/s09-diagnostics/*.txt
+ if-no-files-found: ignore
+ retention-days: 30
+
- name: Upload build folders (optional)
uses: actions/upload-artifact@v4
with:
diff --git a/SAM_OpenStudio.sln b/SAM_OpenStudio.sln
index 30ace78..6091aa3 100644
--- a/SAM_OpenStudio.sln
+++ b/SAM_OpenStudio.sln
@@ -32,6 +32,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SAM.Analytical.OpenStudio.B
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SAM.Analytical.OpenStudio.Benchmark.Tests", "benchmark\SAM.Analytical.OpenStudio.Benchmark.Tests\SAM.Analytical.OpenStudio.Benchmark.Tests.csproj", "{A845555D-DDC0-49C9-8BAD-ADEF4F5E7C6F}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SAM.Analytical.OpenStudio.InputReadBack", "benchmark\SAM.Analytical.OpenStudio.InputReadBack\SAM.Analytical.OpenStudio.InputReadBack.csproj", "{EBF33B94-81C3-4D70-BD25-F10C0E119B6F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SAM.Analytical.OpenStudio.InputReadBack.Cli", "benchmark\SAM.Analytical.OpenStudio.InputReadBack.Cli\SAM.Analytical.OpenStudio.InputReadBack.Cli.csproj", "{850D2D75-52C9-4322-B233-234F72981C39}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SAM.Analytical.OpenStudio.InputReadBack.Tests", "benchmark\SAM.Analytical.OpenStudio.InputReadBack.Tests\SAM.Analytical.OpenStudio.InputReadBack.Tests.csproj", "{3F2CC081-C850-4FE7-BEF7-E7BFC5699603}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -138,6 +144,42 @@ Global
{A845555D-DDC0-49C9-8BAD-ADEF4F5E7C6F}.Release|x64.Build.0 = Release|x64
{A845555D-DDC0-49C9-8BAD-ADEF4F5E7C6F}.Release|x86.ActiveCfg = Release|x64
{A845555D-DDC0-49C9-8BAD-ADEF4F5E7C6F}.Release|x86.Build.0 = Release|x64
+ {EBF33B94-81C3-4D70-BD25-F10C0E119B6F}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {EBF33B94-81C3-4D70-BD25-F10C0E119B6F}.Debug|Any CPU.Build.0 = Debug|x64
+ {EBF33B94-81C3-4D70-BD25-F10C0E119B6F}.Debug|x64.ActiveCfg = Debug|x64
+ {EBF33B94-81C3-4D70-BD25-F10C0E119B6F}.Debug|x64.Build.0 = Debug|x64
+ {EBF33B94-81C3-4D70-BD25-F10C0E119B6F}.Debug|x86.ActiveCfg = Debug|x64
+ {EBF33B94-81C3-4D70-BD25-F10C0E119B6F}.Debug|x86.Build.0 = Debug|x64
+ {EBF33B94-81C3-4D70-BD25-F10C0E119B6F}.Release|Any CPU.ActiveCfg = Release|x64
+ {EBF33B94-81C3-4D70-BD25-F10C0E119B6F}.Release|Any CPU.Build.0 = Release|x64
+ {EBF33B94-81C3-4D70-BD25-F10C0E119B6F}.Release|x64.ActiveCfg = Release|x64
+ {EBF33B94-81C3-4D70-BD25-F10C0E119B6F}.Release|x64.Build.0 = Release|x64
+ {EBF33B94-81C3-4D70-BD25-F10C0E119B6F}.Release|x86.ActiveCfg = Release|x64
+ {EBF33B94-81C3-4D70-BD25-F10C0E119B6F}.Release|x86.Build.0 = Release|x64
+ {850D2D75-52C9-4322-B233-234F72981C39}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {850D2D75-52C9-4322-B233-234F72981C39}.Debug|Any CPU.Build.0 = Debug|x64
+ {850D2D75-52C9-4322-B233-234F72981C39}.Debug|x64.ActiveCfg = Debug|x64
+ {850D2D75-52C9-4322-B233-234F72981C39}.Debug|x64.Build.0 = Debug|x64
+ {850D2D75-52C9-4322-B233-234F72981C39}.Debug|x86.ActiveCfg = Debug|x64
+ {850D2D75-52C9-4322-B233-234F72981C39}.Debug|x86.Build.0 = Debug|x64
+ {850D2D75-52C9-4322-B233-234F72981C39}.Release|Any CPU.ActiveCfg = Release|x64
+ {850D2D75-52C9-4322-B233-234F72981C39}.Release|Any CPU.Build.0 = Release|x64
+ {850D2D75-52C9-4322-B233-234F72981C39}.Release|x64.ActiveCfg = Release|x64
+ {850D2D75-52C9-4322-B233-234F72981C39}.Release|x64.Build.0 = Release|x64
+ {850D2D75-52C9-4322-B233-234F72981C39}.Release|x86.ActiveCfg = Release|x64
+ {850D2D75-52C9-4322-B233-234F72981C39}.Release|x86.Build.0 = Release|x64
+ {3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Debug|Any CPU.Build.0 = Debug|x64
+ {3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Debug|x64.ActiveCfg = Debug|x64
+ {3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Debug|x64.Build.0 = Debug|x64
+ {3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Debug|x86.ActiveCfg = Debug|x64
+ {3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Debug|x86.Build.0 = Debug|x64
+ {3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Release|Any CPU.ActiveCfg = Release|x64
+ {3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Release|Any CPU.Build.0 = Release|x64
+ {3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Release|x64.ActiveCfg = Release|x64
+ {3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Release|x64.Build.0 = Release|x64
+ {3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Release|x86.ActiveCfg = Release|x64
+ {3F2CC081-C850-4FE7-BEF7-E7BFC5699603}.Release|x86.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -151,6 +193,9 @@ Global
{45966997-BBBF-4272-B77F-86B401A1CE45} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{E21DBAA0-8078-43F7-8A49-D9535DFD63D0} = {09EED85C-BE3C-7566-DC0E-2E8E43466740}
{A845555D-DDC0-49C9-8BAD-ADEF4F5E7C6F} = {09EED85C-BE3C-7566-DC0E-2E8E43466740}
+ {EBF33B94-81C3-4D70-BD25-F10C0E119B6F} = {09EED85C-BE3C-7566-DC0E-2E8E43466740}
+ {850D2D75-52C9-4322-B233-234F72981C39} = {09EED85C-BE3C-7566-DC0E-2E8E43466740}
+ {3F2CC081-C850-4FE7-BEF7-E7BFC5699603} = {09EED85C-BE3C-7566-DC0E-2E8E43466740}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {4E0308FF-C8D5-4700-951A-859CB9D4FD09}
diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/LightingSplit.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/LightingSplit.cs
new file mode 100644
index 0000000..94a7549
--- /dev/null
+++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/LightingSplit.cs
@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+namespace SAM.Analytical.OpenStudio
+{
+ ///
+ /// The lighting energy split of SAM S07 §5.1 (amendment L1, T-LIGHT-SPLIT). SAM
+ /// LightingRadiantProportion p is the total radiant share (long-wave plus short-wave) of the lighting gain
+ /// entering the room; SAM and Tas have no luminaire-return allocation. For EnergyPlus Lights:
+ /// Return Air Fraction 0, Fraction Visible p·s, Fraction Radiant p·(1 − s), convective 1 − p.
+ ///
+ /// s is a declared contract assumption, not SAM data: the short-wave share of the emitter's radiant output for the
+ /// reference category, suspended (pendant) LED office lighting with the whole fitting in the conditioned room. It is
+ /// a midpoint-derived reference from cited historical ranges (IEA SHC Task 61 / EBC Annex 77 Technical Report D1,
+ /// Table 6; ASHRAE Handbook—Fundamentals 2017, Ch. 18, Table 3), kept at full precision.
+ ///
+ /// The view coefficient (LightingViewCoefficient) is a comfort coefficient, never an energy fraction, and is
+ /// not part of this split.
+ ///
+ public static class LightingSplit
+ {
+ /// The reference total radiant share p₀ (S07 §5.1 baseline), applied only when SAM states no p.
+ public const double ReferenceRadiantProportion = 0.576;
+
+ /// The short-wave share s of the radiant output, 0.2415 / 0.576, never rounded.
+ public const double ShortWaveShare = 0.2415 / 0.576;
+
+ /// EnergyPlus Fraction Visible for a total radiant share p: p·s.
+ public static double FractionVisible(double radiantProportion) => radiantProportion * ShortWaveShare;
+
+ /// EnergyPlus Fraction Radiant (long-wave) for a total radiant share p: p·(1 − s).
+ public static double FractionRadiant(double radiantProportion) => radiantProportion * (1 - ShortWaveShare);
+ }
+}
diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioImportContext.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioImportContext.cs
index f2f2899..da07a03 100644
--- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioImportContext.cs
+++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioImportContext.cs
@@ -41,9 +41,26 @@ public sealed class OpenStudioImportContext
/// OpenStudio material name → SAM material created for it.
public IDictionary MaterialMap { get; } = new Dictionary();
+ ///
+ /// Source SAM material Guid → the one SAM material its SAM-written face and thickness objects were merged
+ /// into with the OpenStudio handles of its validated members, or a null material when they are imported one by
+ /// one (fewer than two, or a conflict). Only a listed member resolves to the merged material.
+ ///
+ internal IDictionary Members)> OpaqueMaterialGroups { get; } = new Dictionary Members)>();
+
/// OpenStudio construction name → SAM opaque Construction created for it.
public IDictionary ConstructionMap { get; } = new Dictionary();
+ ///
+ /// OpenStudio construction name → the innermost-first SAM Construction for the planar surfaces using it, when the
+ /// same OpenStudio construction is also used by a subsurface or shading surface (which keep the OpenStudio order
+ /// through ). Surfaces resolve here first.
+ ///
+ internal IDictionary SurfaceConstructionMap { get; } = new Dictionary();
+
+ /// OpenStudio construction handle → whether planar surfaces, and whether subsurfaces or shading surfaces, use it; read once from .
+ internal IDictionary ConstructionUses { get; set; }
+
/// OpenStudio construction name → SAM ApertureConstruction created for it.
public IDictionary ApertureConstructionMap { get; } = new Dictionary();
diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/AnalyticalModel.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/AnalyticalModel.cs
index 6a39612..2146518 100644
--- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/AnalyticalModel.cs
+++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/AnalyticalModel.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
namespace SAM.Analytical.OpenStudio
{
@@ -221,11 +222,21 @@ private static OpenStudioConversionContext ToOpenStudio_Context(AnalyticalModel
}
Dictionary> surfacesByPanel = new Dictionary>();
+ Dictionary> surfaceSpacesByPanel = new Dictionary>();
Dictionary panelByGuid = new Dictionary();
Dictionary> subSurfacesByAperture = new Dictionary>();
HashSet spacesWithSurfaces = new HashSet();
List spaces = adjacencyCluster.GetSpaces();
+ Dictionary spaceIndexes = new Dictionary();
+ for (int i = 0; spaces != null && i < spaces.Count; i++)
+ {
+ if (spaces[i] != null && !spaceIndexes.ContainsKey(spaces[i].Guid))
+ {
+ spaceIndexes[spaces[i].Guid] = i;
+ }
+ }
+
context.Statistics.SourceObjects = (spaces?.Count ?? 0) + (sourcePanels?.Count ?? 0) + sourceApertureCount;
if (spaces == null || spaces.Count == 0)
{
@@ -289,11 +300,21 @@ private static OpenStudioConversionContext ToOpenStudio_Context(AnalyticalModel
}
}
+ // Per-space outdoor-air override: replaces the SpaceType DSOA in EnergyPlus. It is the
+ // space's requirement, so it is delivered by the same rule as the SpaceType one (S07
+ // D-F2): only through the selected Ventilation profile, otherwise not at all. Every
+ // rate field is written explicitly so no OpenStudio default (Flow/Person, 0.00944
+ // m3/s per person) can add air SAM never stated.
if (space.TryGetValue(SpaceParameter.OutsideSupplyAirFlow, out double outsideSupplyAirFlow) && !double.IsNaN(outsideSupplyAirFlow) && outsideSupplyAirFlow > 0)
{
global::OpenStudio.DesignSpecificationOutdoorAir designSpecificationOutdoorAir = new global::OpenStudio.DesignSpecificationOutdoorAir(context.Target);
designSpecificationOutdoorAir.setName(Core.OpenStudio.Query.OpenStudioName("DesignSpecificationOutdoorAir", space.Name, space.Guid));
+ designSpecificationOutdoorAir.setOutdoorAirMethod("Sum");
+ designSpecificationOutdoorAir.setOutdoorAirFlowperPerson(0);
+ designSpecificationOutdoorAir.setOutdoorAirFlowperFloorArea(0);
+ designSpecificationOutdoorAir.setOutdoorAirFlowAirChangesperHour(0);
designSpecificationOutdoorAir.setOutdoorAirFlowRate(outsideSupplyAirFlow);
+ designSpecificationOutdoorAir.setOutdoorAirFlowRateFractionSchedule(internalCondition.ToOpenStudio_VentilationDeliverySchedule(context, designSpecificationOutdoorAir.nameString()));
openStudioSpace.setDesignSpecificationOutdoorAir(designSpecificationOutdoorAir);
}
@@ -357,6 +378,13 @@ private static OpenStudioConversionContext ToOpenStudio_Context(AnalyticalModel
}
surfaces.Add(surface);
+ if (!surfaceSpacesByPanel.TryGetValue(panel.Guid, out List surfaceSpaces))
+ {
+ surfaceSpaces = new List();
+ surfaceSpacesByPanel[panel.Guid] = surfaceSpaces;
+ }
+
+ surfaceSpaces.Add(space.Guid);
panelByGuid[panel.Guid] = panel;
spacesWithSurfaces.Add(space.Guid);
}
@@ -495,6 +523,32 @@ private static OpenStudioConversionContext ToOpenStudio_Context(AnalyticalModel
continue;
}
+ // Orientation: SAM lists layers inside → outside from the panel's first space in
+ // GetSpaces() order (S07 section 1), so the forward construction belongs to that space's
+ // side. When that side produced no surface, the remaining one is never given it instead.
+ surfaceSpacesByPanel.TryGetValue(panel.Guid, out List sides);
+ string orientationProblem = ForwardSideProblem(FirstSpace(adjacencyCluster, panel, spaceIndexes), sides);
+ if (orientationProblem != null)
+ {
+ context.AddDiagnostic(Core.OpenStudio.OpenStudioDiagnosticCodes.AdjacencyMissingSurface, Core.OpenStudio.OpenStudioDiagnosticSeverity.Error, orientationProblem, panel);
+ context.RegisterSkip();
+ continue;
+ }
+
+ // Single-layer face optics, checked for this use before any cached construction is
+ // reused: on the ground or an adiabatic boundary only the zone face is used (internal
+ // values); on an outdoor or interzone surface both faces are, and one single-sided
+ // EnergyPlus material cannot carry two different value sets. Two sides (even demoted to
+ // adiabatic after a failed pairing) are two zone faces, one per value set.
+ List divergentFaceOptics = SingleLayerDivergentFaceOptics(construction, context.Source?.MaterialLibrary);
+ string outsideBoundaryCondition = surfaces[0].outsideBoundaryCondition();
+ if (divergentFaceOptics.Count > 0 && (surfaces.Count >= 2 || !IsZoneFaceOnlyBoundary(outsideBoundaryCondition)))
+ {
+ context.AddDiagnostic(Core.OpenStudio.OpenStudioDiagnosticCodes.MaterialFaceOpticsUnrepresentable, Core.OpenStudio.OpenStudioDiagnosticSeverity.Error, string.Format("Single-layer construction '{0}' on a surface with outside boundary {1} needs different optical values on its two faces ({2}); EnergyPlus opaque materials are single-sided, so no construction was assigned — no external, averaged or split-layer substitute", construction.Name, outsideBoundaryCondition, string.Join(", ", divergentFaceOptics)), panel);
+ context.RegisterSkip();
+ continue;
+ }
+
global::OpenStudio.Construction forwardConstruction = construction.ToOpenStudio(true, context);
if (forwardConstruction == null)
{
@@ -628,6 +682,44 @@ private static OpenStudioConversionContext ToOpenStudio_Context(AnalyticalModel
/// PolygonClipping, and flag the surface anyway. Best-effort: a geometry-kernel
/// failure skips the advisory, never the surface.
///
+ ///
+ /// The panel's first related space in order: the space SAM's
+ /// inside → outside layer order starts from (S07 section 1). Null when the panel has no related space
+ /// in the list.
+ ///
+ private static Space FirstSpace(AdjacencyCluster adjacencyCluster, Panel panel, Dictionary spaceIndexes)
+ {
+ List relatedSpaces = adjacencyCluster.GetRelatedObjects(panel);
+ return relatedSpaces?.Where(x => x != null && spaceIndexes.ContainsKey(x.Guid)).OrderBy(x => spaceIndexes[x.Guid]).FirstOrDefault();
+ }
+
+ ///
+ /// The error when the panel's first surface (which receives the forward construction) is not on its first
+ /// space's side: that side produced no surface, and giving the forward construction to the other side would
+ /// swap SAM's inside and outside. Null when the first side is present or the panel has no ordered space.
+ ///
+ /// The panel's first related space in GetSpaces() order.
+ /// The space of each converted surface of the panel, in conversion order.
+ internal static string ForwardSideProblem(Space firstSpace, IReadOnlyList surfaceSpaces)
+ {
+ if (firstSpace == null || surfaceSpaces == null || surfaceSpaces.Count == 0 || surfaceSpaces[0] == firstSpace.Guid)
+ {
+ return null;
+ }
+
+ return string.Format("The side of the panel facing its first space ('{0}') produced no surface; SAM orients the construction inside → outside from that space, so no construction was assigned — the sides are never swapped", firstSpace.Name);
+ }
+
+ ///
+ /// True for an outside boundary whose face receives no solar or long-wave exchange (ground and
+ /// adiabatic), so only the zone face's optics are used.
+ ///
+ private static bool IsZoneFaceOnlyBoundary(string outsideBoundaryCondition)
+ {
+ return outsideBoundaryCondition == "Adiabatic" || outsideBoundaryCondition == "Foundation"
+ || (outsideBoundaryCondition != null && outsideBoundaryCondition.StartsWith("Ground", StringComparison.Ordinal));
+ }
+
private static void EmitNonConvexCastingDiagnostic(Panel panel, string openStudioObjectName, OpenStudioConversionContext openStudioConversionContext)
{
Geometry.Spatial.Face3D face3D = panel?.GetFace3D();
diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/Construction.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/Construction.cs
index 4088eb4..fe0a060 100644
--- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/Construction.cs
+++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/Construction.cs
@@ -171,6 +171,24 @@ private static double PerformanceParameter(Aperture aperture, ApertureConstructi
return double.NaN;
}
+ ///
+ /// The opaque optical properties whose SAM faces differ on a single-layer construction. Its one
+ /// material is then both the zone face (internal values) and the outer face (external values), which
+ /// a single-sided EnergyPlus material cannot hold. Empty for multi-layer constructions, non-opaque
+ /// layers and agreeing faces.
+ ///
+ internal static List SingleLayerDivergentFaceOptics(Construction construction, MaterialLibrary materialLibrary)
+ {
+ List constructionLayers = construction?.ConstructionLayers?.FindAll(x => x != null);
+ if (constructionLayers == null || constructionLayers.Count != 1 || materialLibrary == null)
+ {
+ return new List();
+ }
+
+ OpaqueMaterial opaqueMaterial = materialLibrary.GetMaterial(constructionLayers[0].Name) as OpaqueMaterial;
+ return opaqueMaterial == null ? new List() : DivergentInternalOptics(opaqueMaterial);
+ }
+
private static global::OpenStudio.Construction ToOpenStudio_Construction(SAMObject sAMObject, List constructionLayers, bool forward, string variant, OpenStudioMaterialUsage openStudioMaterialUsage, OpenStudioConversionContext openStudioConversionContext)
{
string direction = forward ? "Forward" : "Reverse";
@@ -197,6 +215,10 @@ private static double PerformanceParameter(Aperture aperture, ApertureConstructi
return null;
}
+ // The face role follows SAM's own layer order, never the EnergyPlus position: SAM's first
+ // (innermost) layer faces the first space, so it carries the internal-face optics in both the
+ // forward and the reverse construction, which therefore share the same material objects.
+ ConstructionLayer firstLayer = constructionLayers.Find(x => x != null);
List orderedLayers = new List(constructionLayers);
if (forward)
{
@@ -211,6 +233,8 @@ private static double PerformanceParameter(Aperture aperture, ApertureConstructi
continue;
}
+ bool internalFace = openStudioMaterialUsage == OpenStudioMaterialUsage.OpaqueConstruction && ReferenceEquals(constructionLayer, firstLayer);
+
IMaterial material = materialLibrary.GetMaterial(constructionLayer.Name);
if (material == null)
{
@@ -235,7 +259,7 @@ private static double PerformanceParameter(Aperture aperture, ApertureConstructi
}
}
- global::OpenStudio.Material openStudioMaterial = material.ToOpenStudio(thickness, openStudioConversionContext, openStudioMaterialUsage);
+ global::OpenStudio.Material openStudioMaterial = ToOpenStudio_Layer(material, thickness, openStudioConversionContext, openStudioMaterialUsage, internalFace);
if (openStudioMaterial == null)
{
return null;
diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/IdealLoads.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/IdealLoads.cs
index b13265c..cf113bc 100644
--- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/IdealLoads.cs
+++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/IdealLoads.cs
@@ -42,6 +42,22 @@ public static partial class Convert
}
}
+ // Outdoor-air controls (S07 D-F2, T-OA-SYS): stated explicitly rather than left to defaults, so the delivered
+ // outdoor air is exactly the space's effective DSOA (its per-space override, else the inherited SpaceType one)
+ // times its fraction schedule, at the design occupancy: no demand-controlled ventilation, no economizer and
+ // no heat recovery. A space without a DSOA (no requirement, V0) leaves the link empty: no outdoor air.
+ result.setDemandControlledVentilationType("None");
+ result.setOutdoorAirEconomizerType("NoEconomizer");
+ result.setHeatRecoveryType("None");
+ if (openStudioConversionContext.TryGetModelObject(space.Guid, out global::OpenStudio.Space openStudioSpace))
+ {
+ global::OpenStudio.OptionalDesignSpecificationOutdoorAir designSpecificationOutdoorAir = openStudioSpace.designSpecificationOutdoorAir();
+ if (designSpecificationOutdoorAir != null && !designSpecificationOutdoorAir.isNull())
+ {
+ result.setDesignSpecificationOutdoorAirObject(designSpecificationOutdoorAir.get());
+ }
+ }
+
if (!result.addToThermalZone(thermalZone))
{
openStudioConversionContext.AddDiagnostic(Core.OpenStudio.OpenStudioDiagnosticCodes.HvacMissingSetpoints, Core.OpenStudio.OpenStudioDiagnosticSeverity.Error, "Ideal Loads air system could not be attached to the thermal zone", space, thermalZone.nameString());
diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/InternalCondition.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/InternalCondition.cs
index 93c4e88..45efd4b 100644
--- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/InternalCondition.cs
+++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/InternalCondition.cs
@@ -2,6 +2,7 @@
// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
using System.Collections.Generic;
+using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
@@ -122,18 +123,19 @@ public static partial class Convert
lightsDefinition.setName(name + "_LightsDefinition");
lightsDefinition.setWattsperSpaceFloorArea(lightingGain / area);
- if (!internalCondition.TryGetValue(InternalConditionParameter.LightingRadiantProportion, out double lightingRadiantProportion) || double.IsNaN(lightingRadiantProportion))
+ // S07 §5.1 (T-LIGHT-SPLIT): p is the total radiant share; it is split by the declared short-wave
+ // share s, Return Air Fraction stays unset (0) and the view coefficient is never read here.
+ // An authored p, including 0, is preserved (SAM keeps it within [0, 1]); an absent or NaN p takes
+ // the common default p₀.
+ bool lightingRadiantProportionStated = internalCondition.TryGetValue(InternalConditionParameter.LightingRadiantProportion, out double lightingRadiantProportion) && !double.IsNaN(lightingRadiantProportion);
+ if (!lightingRadiantProportionStated)
{
- lightingRadiantProportion = 0.32;
+ lightingRadiantProportion = LightingSplit.ReferenceRadiantProportion;
}
- if (!internalCondition.TryGetValue(InternalConditionParameter.LightingViewCoefficient, out double lightingViewCoefficient) || double.IsNaN(lightingViewCoefficient))
- {
- lightingViewCoefficient = 0.25;
- }
-
- lightsDefinition.setFractionRadiant(lightingRadiantProportion);
- lightsDefinition.setFractionVisible(lightingViewCoefficient);
+ lightsDefinition.setFractionRadiant(LightingSplit.FractionRadiant(lightingRadiantProportion));
+ lightsDefinition.setFractionVisible(LightingSplit.FractionVisible(lightingRadiantProportion));
+ openStudioConversionContext.AddDiagnostic(Core.OpenStudio.OpenStudioDiagnosticCodes.InternalConditionAssumption, Core.OpenStudio.OpenStudioDiagnosticSeverity.Information, LightingSplitMessage(lightingRadiantProportion, lightingRadiantProportionStated), internalCondition, name);
global::OpenStudio.Lights lights = new global::OpenStudio.Lights(lightsDefinition);
lights.setName(name + "_Lights");
@@ -262,8 +264,10 @@ public static partial class Convert
// Sum, from the condition's supply parameters (per person / per area / ACH /
// absolute). SpaceParameter.OutsideSupplyAirFlow still takes precedence — the
// orchestrator assigns a per-space DSOA which overrides the SpaceType one in
- // EnergyPlus. A named-but-unresolved ventilation profile is an error; without a
- // named profile the rates are unscheduled (fraction 1).
+ // EnergyPlus. The supply parameters are the requirement; the selected Ventilation
+ // profile is the delivery (S07 D-F2). Without a selection, or with a selection that
+ // does not resolve, the requirement is kept and an all-zero fraction schedule
+ // delivers no outdoor air (never unscheduled).
double supplyAirFlowPerPerson = ParameterValue(internalCondition, InternalConditionParameter.SupplyAirFlowPerPerson);
double supplyAirFlowPerArea = ParameterValue(internalCondition, InternalConditionParameter.SupplyAirFlowPerArea);
double supplyAirChangesPerHour = ParameterValue(internalCondition, InternalConditionParameter.SupplyAirChangesPerHour);
@@ -278,16 +282,7 @@ public static partial class Convert
designSpecificationOutdoorAir.setOutdoorAirFlowperFloorArea(supplyAirFlowPerArea > 0 ? supplyAirFlowPerArea : 0);
designSpecificationOutdoorAir.setOutdoorAirFlowAirChangesperHour(supplyAirChangesPerHour > 0 ? supplyAirChangesPerHour : 0);
designSpecificationOutdoorAir.setOutdoorAirFlowRate(supplyAirFlow > 0 ? supplyAirFlow : 0);
-
- string ventilationProfileName = internalCondition.GetProfileName(ProfileType.Ventilation);
- if (ventilationProfileName != null)
- {
- global::OpenStudio.Schedule ventilationSchedule = RequiredSchedule(internalCondition, profileDictionary, ProfileType.Ventilation, name, openStudioConversionContext);
- if (ventilationSchedule != null)
- {
- designSpecificationOutdoorAir.setOutdoorAirFlowRateFractionSchedule(ventilationSchedule);
- }
- }
+ designSpecificationOutdoorAir.setOutdoorAirFlowRateFractionSchedule(VentilationDeliverySchedule(internalCondition, profileDictionary, designSpecificationOutdoorAir.nameString(), openStudioConversionContext));
result.setDesignSpecificationOutdoorAir(designSpecificationOutdoorAir);
}
@@ -312,18 +307,26 @@ public static partial class Convert
}
// TAS view coefficients and the lighting control function (coverage manifest,
- // Unsupported SAM-OS-IC-001 info; review P1-01): OS:People and OS:ElectricEquipment
- // have no visible-fraction field, and the TAS control expression has no
- // deterministic daylighting-control mapping. Reported per SpaceType.
+ // Unsupported SAM-OS-IC-001 info; review P1-01; S07 §5.1): a view coefficient weights
+ // the emitter's radiation in the TAS mean radiant and resultant temperatures. It is a
+ // comfort coefficient, not an energy fraction, and EnergyPlus has no input for it. The
+ // TAS control expression has no deterministic daylighting-control mapping. Reported per
+ // SpaceType.
if (!double.IsNaN(ParameterValue(internalCondition, InternalConditionParameter.OccupancyViewCoefficient)))
{
- openStudioConversionContext.AddDiagnostic(Core.OpenStudio.OpenStudioDiagnosticCodes.InternalConditionUnsupportedParameter, Core.OpenStudio.OpenStudioDiagnosticSeverity.Information, "Occupancy view coefficient is not converted — OS:People has no visible-fraction field", internalCondition, name);
+ openStudioConversionContext.AddDiagnostic(Core.OpenStudio.OpenStudioDiagnosticCodes.InternalConditionUnsupportedParameter, Core.OpenStudio.OpenStudioDiagnosticSeverity.Information, "Occupancy view coefficient is not converted — it is a TAS comfort (mean radiant and resultant temperature) coefficient, not an energy fraction; OS:People has no input for it", internalCondition, name);
openStudioConversionContext.RegisterSkip();
}
if (!double.IsNaN(ParameterValue(internalCondition, InternalConditionParameter.EquipmentViewCoefficient)))
{
- openStudioConversionContext.AddDiagnostic(Core.OpenStudio.OpenStudioDiagnosticCodes.InternalConditionUnsupportedParameter, Core.OpenStudio.OpenStudioDiagnosticSeverity.Information, "Equipment view coefficient is not converted — OS:ElectricEquipment has no visible-fraction field", internalCondition, name);
+ openStudioConversionContext.AddDiagnostic(Core.OpenStudio.OpenStudioDiagnosticCodes.InternalConditionUnsupportedParameter, Core.OpenStudio.OpenStudioDiagnosticSeverity.Information, "Equipment view coefficient is not converted — it is a TAS comfort (mean radiant and resultant temperature) coefficient, not an energy fraction; OS:ElectricEquipment has no input for it", internalCondition, name);
+ openStudioConversionContext.RegisterSkip();
+ }
+
+ if (!double.IsNaN(ParameterValue(internalCondition, InternalConditionParameter.LightingViewCoefficient)))
+ {
+ openStudioConversionContext.AddDiagnostic(Core.OpenStudio.OpenStudioDiagnosticCodes.InternalConditionUnsupportedParameter, Core.OpenStudio.OpenStudioDiagnosticSeverity.Information, "Lighting view coefficient is not converted — it is a TAS comfort (mean radiant and resultant temperature) coefficient, not an energy fraction; OS:Lights has no input for it, and its Fraction Visible comes from the lighting radiant proportion (S07 §5.1)", internalCondition, name);
openStudioConversionContext.RegisterSkip();
}
@@ -382,6 +385,79 @@ private static double ParameterValue(InternalCondition internalCondition, Intern
return internalCondition.TryGetValue(internalConditionParameter, out value) ? value : double.NaN;
}
+ ///
+ /// The S07 §5.1 lighting-split note (Information, Summary): the values written, s and its reference category and
+ /// limits, and, for an absent p, that the common default p₀ was applied and that its suitability outside the
+ /// category is the open owner decision OD-L1.
+ ///
+ private static string LightingSplitMessage(double radiantProportion, bool stated)
+ {
+ string values = string.Format(CultureInfo.InvariantCulture,
+ "Fraction Radiant = p·(1 − s) = {0:0.######}, Fraction Visible = p·s = {1:0.######}, Return Air Fraction 0, convective 1 − p = {2:0.######}; s = 0.2415 / 0.576 = {3:0.#######} (short-wave share of the radiant output)",
+ LightingSplit.FractionRadiant(radiantProportion), LightingSplit.FractionVisible(radiantProportion), 1 - radiantProportion, LightingSplit.ShortWaveShare);
+ const string category = "Reference category: suspended (pendant) LED office lighting with the entire fitting in the conditioned room; a midpoint-derived reference from cited historical ranges (IEA SHC Task 61 / EBC Annex 77 D1, Table 6; ASHRAE Fundamentals 2017, Ch. 18, Table 3), not manufacturer-certified. Not applicable to recessed troffers, downlights, retrofit kits, high-bays or non-LED lamps";
+
+ if (stated)
+ {
+ return string.Format(CultureInfo.InvariantCulture, "Lighting radiant proportion p = {0:R} (authored) is split under S07 §5.1 T-LIGHT-SPLIT: {1}. s is a declared assumption, not SAM data. {2}.", radiantProportion, values, category);
+ }
+
+ return string.Format(CultureInfo.InvariantCulture, "Lighting radiant proportion is not stated; the S07 §5.1 common default p₀ = {0:R} was applied: {1}. {2}. p₀ is not a regulatory, NCM, TM59 or dwelling value, and its suitability outside the reference category is unresolved (owner decision OD-L1); author LightingRadiantProportion where a method or template prescribes it.", radiantProportion, values, category);
+ }
+
+ ///
+ /// The outdoor-air delivery schedule of a space's ventilation requirement (S07 D-F2): the selected Ventilation
+ /// profile when it resolves (V2); otherwise , an all-zero fraction
+ /// schedule, so the requirement stays in the model and no outdoor air is delivered. No selection (V1) is reported
+ /// as information; a selection that does not resolve to a Ventilation profile (missing, or another type) is an
+ /// invalid input and an error. Used for the SpaceType DSOA and for the per-space OutsideSupplyAirFlow DSOA.
+ ///
+ /// The space's internal condition; null means no selection.
+ /// Conversion context.
+ /// The DSOA the schedule is for (diagnostics).
+ /// The delivery schedule; never null.
+ public static global::OpenStudio.Schedule ToOpenStudio_VentilationDeliverySchedule(this InternalCondition internalCondition, OpenStudioConversionContext openStudioConversionContext, string openStudioObjectName)
+ {
+ ProfileLibrary profileLibrary = openStudioConversionContext.Source?.ProfileLibrary;
+ Dictionary profileDictionary = internalCondition == null || profileLibrary == null ? new Dictionary() : internalCondition.GetProfileDictionary(profileLibrary);
+ return VentilationDeliverySchedule(internalCondition, profileDictionary, openStudioObjectName, openStudioConversionContext);
+ }
+
+ /// The name of the all-zero fraction schedule that delivers no outdoor air (V1, or an invalid selection).
+ public const string VentilationNotDeliveredScheduleName = "SAM_Schedule_VentilationNotDelivered";
+
+ private static global::OpenStudio.Schedule VentilationDeliverySchedule(InternalCondition internalCondition, Dictionary profileDictionary, string openStudioObjectName, OpenStudioConversionContext openStudioConversionContext)
+ {
+ string selection = internalCondition?.GetProfileName(ProfileType.Ventilation);
+ if (string.IsNullOrWhiteSpace(selection))
+ {
+ openStudioConversionContext.AddDiagnostic(Core.OpenStudio.OpenStudioDiagnosticCodes.HvacMissingSetpoints, Core.OpenStudio.OpenStudioDiagnosticSeverity.Information, "No Ventilation profile is selected: the outdoor-air requirement is kept and no outdoor air is delivered (S07 D-F2 V1)", internalCondition, openStudioObjectName);
+ return openStudioConversionContext.ToOpenStudio_ConstantSchedule(VentilationNotDeliveredScheduleName, 0, "Fractional");
+ }
+
+ global::OpenStudio.Schedule result = null;
+ if (profileDictionary != null && profileDictionary.TryGetValue(ProfileType.Ventilation, out Profile profile) && profile != null)
+ {
+ result = profile.ToOpenStudio(ProfileType.Ventilation, openStudioConversionContext);
+ if (result == null)
+ {
+ openStudioConversionContext.AddDiagnostic(Core.OpenStudio.OpenStudioDiagnosticCodes.ScheduleMissingProfile, Core.OpenStudio.OpenStudioDiagnosticSeverity.Error, string.Format("The selected Ventilation profile '{0}' could not be converted; no outdoor air is delivered (never unscheduled)", selection), internalCondition, openStudioObjectName);
+ }
+ }
+ else
+ {
+ openStudioConversionContext.AddDiagnostic(Core.OpenStudio.OpenStudioDiagnosticCodes.ScheduleMissingProfile, Core.OpenStudio.OpenStudioDiagnosticSeverity.Error, string.Format("The selected Ventilation profile '{0}' was not found as a Ventilation profile (missing, or of another type): an invalid input; no outdoor air is delivered (never unscheduled)", selection), internalCondition, openStudioObjectName);
+ }
+
+ if (result == null)
+ {
+ openStudioConversionContext.RegisterSkip();
+ return openStudioConversionContext.ToOpenStudio_ConstantSchedule(VentilationNotDeliveredScheduleName, 0, "Fractional");
+ }
+
+ return result;
+ }
+
private static global::OpenStudio.Schedule RequiredSchedule(InternalCondition internalCondition, Dictionary profileDictionary, ProfileType profileType, string openStudioObjectName, OpenStudioConversionContext openStudioConversionContext)
{
Profile profile;
diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/Material.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/Material.cs
index 9b2cbb9..68a7265 100644
--- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/Material.cs
+++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToOpenStudio/Material.cs
@@ -27,6 +27,24 @@ public static partial class Convert
/// Construction-usage context (opaque or fenestration).
/// OpenStudio material, or null when unsupported/invalid (diagnostic raised).
public static global::OpenStudio.Material ToOpenStudio(this IMaterial material, double thickness, OpenStudioConversionContext openStudioConversionContext, OpenStudioMaterialUsage openStudioMaterialUsage = OpenStudioMaterialUsage.OpaqueConstruction)
+ {
+ return ToOpenStudio_Layer(material, thickness, openStudioConversionContext, openStudioMaterialUsage, false);
+ }
+
+ ///
+ /// Face-aware material conversion for a construction layer. EnergyPlus opaque materials carry one
+ /// absorptance set for both faces, and only the outermost layer's outside face and the innermost
+ /// layer's zone face are used. SAM stores layers inside → outside, so SAM's first layer is the zone
+ /// face of the first space (and of every single-sided surface) and its last layer the outdoor face or
+ /// the second space's zone face. is therefore true for SAM's first
+ /// opaque-construction layer only: when that material's Internal* optics differ from its External*
+ /// ones, an Internal face variant (own cache key, name suffix _Internal) carries the internal
+ /// values. Every other layer, and every material whose faces agree, uses the external-face object.
+ /// Forward and reverse constructions share the same objects, so interzone constructions stay exact
+ /// mirrors. Every opaque object carries the source material Guid, its face and all authored face
+ /// optics (), so an import recovers both faces.
+ ///
+ internal static global::OpenStudio.Material ToOpenStudio_Layer(IMaterial material, double thickness, OpenStudioConversionContext openStudioConversionContext, OpenStudioMaterialUsage openStudioMaterialUsage, bool internalFace)
{
if (material == null || openStudioConversionContext == null)
{
@@ -57,7 +75,19 @@ public static partial class Convert
: ToOpenStudio_AirGap(gasMaterial, sAMObject, openStudioConversionContext);
}
- string cacheKey = string.Format(CultureInfo.InvariantCulture, "{0:N}:{1:R}", sAMObject.Guid, thickness);
+ bool zoneFace = internalFace && material is OpaqueMaterial && openStudioMaterialUsage == OpenStudioMaterialUsage.OpaqueConstruction;
+ bool internalVariant = zoneFace && DivergentInternalOptics(sAMObject).Count > 0;
+
+ // Inheritance on a zone face, reported once per material at Summary: an Internal* value SAM does not state
+ // (absent or NaN) takes the stated External* value. The written value is not SAM data, so it is not stamped
+ // as authored and an import keeps the internal value unspecified.
+ List inheritedInternalOptics = zoneFace ? InheritedInternalOptics(sAMObject) : new List();
+ if (inheritedInternalOptics.Count > 0 && openStudioConversionContext.RegisterOnce("SAM-OS-MAT-004:Inherited:" + sAMObject.Guid.ToString("N")))
+ {
+ openStudioConversionContext.AddDiagnostic(Core.OpenStudio.OpenStudioDiagnosticCodes.MaterialFaceAssumption, Core.OpenStudio.OpenStudioDiagnosticSeverity.Information, string.Format("Zone face: {0} not stated (absent or NaN); the external-side value is written on the zone face and the internal value stays unspecified", string.Join(", ", inheritedInternalOptics)), sAMObject);
+ }
+
+ string cacheKey = string.Format(CultureInfo.InvariantCulture, "{0:N}:{1:R}{2}", sAMObject.Guid, thickness, internalVariant ? ":Internal" : string.Empty);
global::OpenStudio.Material cached;
if (openStudioConversionContext.MaterialMap.TryGetValue(cacheKey, out cached))
{
@@ -70,7 +100,7 @@ public static partial class Convert
return null;
}
- string name = Core.OpenStudio.Query.OpenStudioName(sAMObject.GetType().Name, string.Format(CultureInfo.InvariantCulture, "{0}_{1:0.###}mm", sAMObject.Name, thickness * 1000), sAMObject.Guid);
+ string name = Core.OpenStudio.Query.OpenStudioName(sAMObject.GetType().Name, string.Format(CultureInfo.InvariantCulture, "{0}_{1:0.###}mm{2}", sAMObject.Name, thickness * 1000, internalVariant ? "_Internal" : string.Empty), sAMObject.Guid);
global::OpenStudio.Material result = null;
@@ -89,32 +119,35 @@ public static partial class Convert
standardOpaqueMaterial.setDensity(opaqueMaterial.Density);
standardOpaqueMaterial.setSpecificHeat(opaqueMaterial.SpecificHeatCapacity);
- if (TryGetFraction(openStudioConversionContext, sAMObject, OpaqueMaterialParameter.ExternalEmissivity, name, out double externalEmissivity))
+ // The internal variant carries each Internal* value SAM states; a property whose
+ // Internal* value is not stated keeps its External* value, as on every other layer.
+ if (TryGetFaceFraction(openStudioConversionContext, sAMObject, OpaqueMaterialParameter.InternalEmissivity, OpaqueMaterialParameter.ExternalEmissivity, internalVariant, name, out double emissivity))
{
- standardOpaqueMaterial.setThermalAbsorptance(externalEmissivity);
+ standardOpaqueMaterial.setThermalAbsorptance(emissivity);
}
- if (TryGetFraction(openStudioConversionContext, sAMObject, OpaqueMaterialParameter.ExternalSolarReflectance, name, out double externalSolarReflectance))
+ if (TryGetFaceFraction(openStudioConversionContext, sAMObject, OpaqueMaterialParameter.InternalSolarReflectance, OpaqueMaterialParameter.ExternalSolarReflectance, internalVariant, name, out double solarReflectance))
{
- standardOpaqueMaterial.setSolarAbsorptance(1 - externalSolarReflectance);
+ standardOpaqueMaterial.setSolarAbsorptance(1 - solarReflectance);
}
- if (TryGetFraction(openStudioConversionContext, sAMObject, OpaqueMaterialParameter.ExternalLightReflectance, name, out double externalLightReflectance))
+ if (TryGetFaceFraction(openStudioConversionContext, sAMObject, OpaqueMaterialParameter.InternalLightReflectance, OpaqueMaterialParameter.ExternalLightReflectance, internalVariant, name, out double lightReflectance))
{
- standardOpaqueMaterial.setVisibleAbsorptance(1 - externalLightReflectance);
+ standardOpaqueMaterial.setVisibleAbsorptance(1 - lightReflectance);
}
// Single-sided approximation (coverage manifest OpaqueMaterialParameter
- // .Internal*, Approximated SAM-OS-MAT-002 info when Internal differs from
- // External; review P1-01): EnergyPlus opaque materials carry one absorptance
- // set — the external-side values govern both sides. Reported once per material.
+ // .Internal*; review P1-01): only an opaque pane of a fenestration construction (a
+ // solid door) still takes the external values on both faces. Opaque constructions
+ // place an internal variant on the zone face instead (ToOpenStudio_Layer).
List divergentInternalOptics = DivergentInternalOptics(sAMObject);
- if (divergentInternalOptics.Count > 0
+ if (openStudioMaterialUsage == OpenStudioMaterialUsage.FenestrationConstruction && divergentInternalOptics.Count > 0
&& openStudioConversionContext.RegisterOnce("SAM-OS-MAT-002:InternalOptics:" + sAMObject.Guid.ToString("N")))
{
openStudioConversionContext.AddDiagnostic(Core.OpenStudio.OpenStudioDiagnosticCodes.MaterialUnsupportedParameter, Core.OpenStudio.OpenStudioDiagnosticSeverity.Information, string.Format("Internal-side optical properties ({0}) differ from the external-side values — EnergyPlus opaque materials are single-sided; the external values govern both sides", string.Join(", ", divergentInternalOptics)), sAMObject, name);
}
+ StampOpaqueFaces(standardOpaqueMaterial, sAMObject, internalVariant);
result = standardOpaqueMaterial;
}
else if (material is TransparentMaterial transparentMaterial)
@@ -306,7 +339,32 @@ public static partial class Convert
/// from its External* counterpart (or has no external counterpart to govern it) — the
/// cases where the single-sided EnergyPlus approximation loses information.
///
- private static List DivergentInternalOptics(SAMObject sAMObject)
+ ///
+ /// The opaque Internal* optical parameters SAM does not state (absent or NaN) while their External* counterpart is
+ /// stated: on a zone face the external value is written in their place.
+ ///
+ internal static List InheritedInternalOptics(SAMObject sAMObject)
+ {
+ List result = new List();
+ foreach ((OpaqueMaterialParameter internalParameter, OpaqueMaterialParameter externalParameter) in new[]
+ {
+ (OpaqueMaterialParameter.InternalEmissivity, OpaqueMaterialParameter.ExternalEmissivity),
+ (OpaqueMaterialParameter.InternalSolarReflectance, OpaqueMaterialParameter.ExternalSolarReflectance),
+ (OpaqueMaterialParameter.InternalLightReflectance, OpaqueMaterialParameter.ExternalLightReflectance),
+ })
+ {
+ bool internalStated = sAMObject.TryGetValue(internalParameter, out double internalValue) && !double.IsNaN(internalValue);
+ bool externalStated = sAMObject.TryGetValue(externalParameter, out double externalValue) && !double.IsNaN(externalValue);
+ if (!internalStated && externalStated)
+ {
+ result.Add(internalParameter.ToString());
+ }
+ }
+
+ return result;
+ }
+
+ internal static List DivergentInternalOptics(SAMObject sAMObject)
{
List result = new List();
AddDivergentInternalOptic(sAMObject, OpaqueMaterialParameter.InternalEmissivity, OpaqueMaterialParameter.ExternalEmissivity, result);
@@ -328,6 +386,57 @@ private static void AddDivergentInternalOptic(SAMObject sAMObject, OpaqueMateria
}
}
+ ///
+ /// The face value of one opaque optical property: the Internal* value for an internal variant when SAM
+ /// states it, otherwise the External* value, validated as a fraction by .
+ ///
+ private static bool TryGetFaceFraction(OpenStudioConversionContext openStudioConversionContext, SAMObject sAMObject, OpaqueMaterialParameter internalParameter, OpaqueMaterialParameter externalParameter, bool internalVariant, string openStudioObjectName, out double value)
+ {
+ if (internalVariant && sAMObject.TryGetValue(internalParameter, out double internalValue) && !double.IsNaN(internalValue))
+ {
+ return TryGetFraction(openStudioConversionContext, sAMObject, internalParameter, openStudioObjectName, out value);
+ }
+
+ return TryGetFraction(openStudioConversionContext, sAMObject, externalParameter, openStudioObjectName, out value);
+ }
+
+ private static readonly OpaqueMaterialParameter[] opaqueOptics =
+ {
+ OpaqueMaterialParameter.ExternalEmissivity, OpaqueMaterialParameter.InternalEmissivity,
+ OpaqueMaterialParameter.ExternalSolarReflectance, OpaqueMaterialParameter.InternalSolarReflectance,
+ OpaqueMaterialParameter.ExternalLightReflectance, OpaqueMaterialParameter.InternalLightReflectance,
+ };
+
+ ///
+ /// Stamps the source material Guid, the face this object's absorptances carry and every authored face
+ /// optic SAM states onto the object's AdditionalProperties. Additive metadata: no EnergyPlus input
+ /// changes. A value SAM does not state is not written, so an import never receives an invented one.
+ ///
+ private static void StampOpaqueFaces(global::OpenStudio.StandardOpaqueMaterial standardOpaqueMaterial, SAMObject sAMObject, bool internalVariant)
+ {
+ global::OpenStudio.AdditionalProperties additionalProperties = standardOpaqueMaterial.additionalProperties();
+ if (additionalProperties == null)
+ {
+ return;
+ }
+
+ additionalProperties.setFeature(Core.OpenStudio.OpenStudioIdentityKeys.MaterialGuid, sAMObject.Guid.ToString("D"));
+ additionalProperties.setFeature(Core.OpenStudio.OpenStudioIdentityKeys.MaterialFace, internalVariant ? Core.OpenStudio.OpenStudioIdentityKeys.MaterialFaceInternal : Core.OpenStudio.OpenStudioIdentityKeys.MaterialFaceExternal);
+ foreach (OpaqueMaterialParameter opaqueMaterialParameter in opaqueOptics)
+ {
+ if (sAMObject.TryGetValue(opaqueMaterialParameter, out double value) && !double.IsNaN(value))
+ {
+ additionalProperties.setFeature(Core.OpenStudio.OpenStudioIdentityKeys.OpticPrefix + opaqueMaterialParameter, value.ToString("R", CultureInfo.InvariantCulture));
+ }
+ }
+
+ // What was actually written, including inherited values and OpenStudio defaults, so a later edit of the object
+ // can be told apart from an unchanged default.
+ additionalProperties.setFeature(Core.OpenStudio.OpenStudioIdentityKeys.WrittenPrefix + "ThermalAbsorptance", standardOpaqueMaterial.thermalAbsorptance().ToString("R", CultureInfo.InvariantCulture));
+ additionalProperties.setFeature(Core.OpenStudio.OpenStudioIdentityKeys.WrittenPrefix + "SolarAbsorptance", standardOpaqueMaterial.solarAbsorptance().ToString("R", CultureInfo.InvariantCulture));
+ additionalProperties.setFeature(Core.OpenStudio.OpenStudioIdentityKeys.WrittenPrefix + "VisibleAbsorptance", standardOpaqueMaterial.visibleAbsorptance().ToString("R", CultureInfo.InvariantCulture));
+ }
+
private static bool IsValidPositive(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value) && value > 0;
diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/AnalyticalModel.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/AnalyticalModel.cs
index 66ebd57..0092eaa 100644
--- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/AnalyticalModel.cs
+++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/AnalyticalModel.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using SAM.Geometry.Spatial;
namespace SAM.Analytical.OpenStudio
@@ -82,6 +83,10 @@ internal static AnalyticalModel ToSAM_AnalyticalModel(OpenStudioImportContext op
List>> spacePanels = new List>>();
List unpairedInterzoneSurfaces = new List();
+ // Which surface each panel was built from: its construction is read innermost layer first from that
+ // surface's zone (Convert/ToSAM/Construction.cs), so that surface's space must be the panel's first space.
+ PanelSides panelSides = new PanelSides();
+
if (spaceVector != null)
{
foreach (global::OpenStudio.Space openStudioSpace in spaceVector)
@@ -108,7 +113,7 @@ internal static AnalyticalModel ToSAM_AnalyticalModel(OpenStudioImportContext op
continue;
}
- Panel panel = ResolvePanel(surface, transformation, panelBySurfaceName, unpairedInterzoneSurfaces, openStudioImportContext);
+ Panel panel = ResolvePanel(surface, transformation, panelBySurfaceName, unpairedInterzoneSurfaces, panelSides, openStudioImportContext);
if (panel == null)
{
continue;
@@ -145,15 +150,30 @@ internal static AnalyticalModel ToSAM_AnalyticalModel(OpenStudioImportContext op
// supersedes the second surface's panel with the first; those supersessions are
// recorded so the captured spacePanels lists can be remapped before topology.
Dictionary supersededPanels = new Dictionary();
- ResolveUnpairedInterzoneSurfaces(unpairedInterzoneSurfaces, panelBySurfaceName, supersededPanels, openStudioImportContext);
+ ResolveUnpairedInterzoneSurfaces(unpairedInterzoneSurfaces, panelBySurfaceName, supersededPanels, panelSides, openStudioImportContext);
// Pass D: topology. A shared internal panel is added once and related to BOTH spaces —
// that relation, not a second coincident panel, is how SAM represents one physical
- // partition between two rooms.
- foreach (KeyValuePair> keyValuePair in spacePanels)
+ // partition between two rooms. SAM's first space of a panel is the earlier of its spaces
+ // in the cluster's space order (the writer's forward side), so the spaces are added in an
+ // order that puts each panel's building side first (OrderSpaces).
+ List>> orderedSpacePanels = OrderSpaces(spacePanels, supersededPanels, panelSides, openStudioImportContext);
+
+ // Fail closed: a successful import keeps every opaque surface's native layer order and the absorptances of
+ // every face EnergyPlus uses. When an edited or mixed model's orientation or face roles cannot reproduce them,
+ // the import is unsuccessful (SAM-OSI-CON-002) instead of silently changing a used face.
+ if (!VerifySurfaceFaces(orderedSpacePanels, supersededPanels, panelSides, materialLibrary, openStudioImportContext))
+ {
+ return null;
+ }
+
+ foreach (KeyValuePair> keyValuePair in orderedSpacePanels)
{
adjacencyCluster.AddObject(keyValuePair.Key);
+ }
+ foreach (KeyValuePair> keyValuePair in orderedSpacePanels)
+ {
// A geometric-fallback pairing replaced the second panel with the first after
// this list was captured, so resolve each panel through the supersession map
// (and dedupe) or the cluster would keep two coincident panels for one partition.
@@ -213,6 +233,536 @@ internal static AnalyticalModel ToSAM_AnalyticalModel(OpenStudioImportContext op
}
}
+ /// The side each panel was built from (see and ).
+ private sealed class PanelSides
+ {
+ /// OpenStudio name of the space of the surface the panel was built from.
+ public Dictionary SpaceName { get; } = new Dictionary();
+
+ /// Panels built from a surface carrying the SAM-written forward construction.
+ public HashSet Forward { get; } = new HashSet();
+
+ /// For a two-sided panel: the SAM construction read from the partner surface (innermost first from the partner's zone), or null.
+ public Dictionary Partner { get; } = new Dictionary();
+
+ /// The native layers and used-face absorptances of the surfaces of each panel (its building surface and partner).
+ public Dictionary> Faces { get; } = new Dictionary>();
+
+ public void AddFaces(Panel panel, SurfaceFaces surfaceFaces)
+ {
+ if (surfaceFaces == null)
+ {
+ return;
+ }
+
+ if (!Faces.TryGetValue(panel, out List list))
+ {
+ list = new List();
+ Faces[panel] = list;
+ }
+
+ list.Add(surfaceFaces);
+ }
+ }
+
+ ///
+ /// Managed snapshot of an OpenStudio surface's construction, taken during the model walk (no SWIG wrapper is kept):
+ /// its space, boundary, layer names outside first, each layer's physical signature, and the thermal, solar and visible
+ /// absorptances of its first (outside) and last (zone) layers; null absorptances for a layer that is neither a
+ /// standard nor a massless opaque material, NaN for a value OpenStudio does not state.
+ ///
+ private sealed class SurfaceFaces
+ {
+ public string Space { get; set; }
+
+ public string Boundary { get; set; }
+
+ public List Layers { get; set; } = new List();
+
+ ///
+ /// Per layer, outside first: thickness, conductivity, density and specific heat of a StandardOpaqueMaterial (the
+ /// values a SAM-written face or thickness object of a merged source material keeps on its own), null for any other
+ /// layer kind (massless, air gap: imported at a documented nominal thickness, compared by material only).
+ ///
+ public List Physical { get; set; } = new List();
+
+ public double[] Outside { get; set; }
+
+ public double[] Zone { get; set; }
+ }
+
+ private static SurfaceFaces Snapshot(global::OpenStudio.Surface surface)
+ {
+ try
+ {
+ global::OpenStudio.OptionalSpace optionalSpace = surface.space();
+ SurfaceFaces result = new SurfaceFaces { Space = optionalSpace == null || optionalSpace.isNull() ? null : optionalSpace.get().nameString(), Boundary = surface.outsideBoundaryCondition() };
+ global::OpenStudio.OptionalConstructionBase optionalConstructionBase = surface.construction();
+ if (optionalConstructionBase == null || optionalConstructionBase.isNull())
+ {
+ return result;
+ }
+
+ global::OpenStudio.OptionalConstruction optionalConstruction = optionalConstructionBase.get().to_Construction();
+ if (optionalConstruction == null || optionalConstruction.isNull())
+ {
+ return result;
+ }
+
+ List layers = optionalConstruction.get().layers().ToList();
+ result.Layers = layers.Select(x => x.nameString()).ToList();
+ result.Physical = layers.Select(Physical).ToList();
+ if (layers.Count > 0)
+ {
+ result.Outside = Absorptances(layers[0]);
+ result.Zone = Absorptances(layers[layers.Count - 1]);
+ }
+
+ return result;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ private static double[] Physical(global::OpenStudio.Material material)
+ {
+ global::OpenStudio.OptionalStandardOpaqueMaterial optionalStandardOpaqueMaterial = material.to_StandardOpaqueMaterial();
+ if (optionalStandardOpaqueMaterial == null || optionalStandardOpaqueMaterial.isNull())
+ {
+ return null;
+ }
+
+ global::OpenStudio.StandardOpaqueMaterial standardOpaqueMaterial = optionalStandardOpaqueMaterial.get();
+ return new[] { standardOpaqueMaterial.thickness(), standardOpaqueMaterial.thermalConductivity(), standardOpaqueMaterial.density(), standardOpaqueMaterial.specificHeat() };
+ }
+
+ private static double[] Absorptances(global::OpenStudio.Material material)
+ {
+ global::OpenStudio.OptionalStandardOpaqueMaterial optionalStandardOpaqueMaterial = material.to_StandardOpaqueMaterial();
+ if (optionalStandardOpaqueMaterial != null && !optionalStandardOpaqueMaterial.isNull())
+ {
+ global::OpenStudio.StandardOpaqueMaterial standardOpaqueMaterial = optionalStandardOpaqueMaterial.get();
+ return new[] { standardOpaqueMaterial.thermalAbsorptance(), standardOpaqueMaterial.solarAbsorptance(), standardOpaqueMaterial.visibleAbsorptance() };
+ }
+
+ global::OpenStudio.OptionalMasslessOpaqueMaterial optionalMasslessOpaqueMaterial = material.to_MasslessOpaqueMaterial();
+ if (optionalMasslessOpaqueMaterial != null && !optionalMasslessOpaqueMaterial.isNull())
+ {
+ global::OpenStudio.MasslessOpaqueMaterial masslessOpaqueMaterial = optionalMasslessOpaqueMaterial.get();
+ return new[] { Value(masslessOpaqueMaterial.thermalAbsorptance()), Value(masslessOpaqueMaterial.solarAbsorptance()), Value(masslessOpaqueMaterial.visibleAbsorptance()) };
+ }
+
+ return null;
+ }
+
+ private static double Value(double value)
+ {
+ return value;
+ }
+
+ private static double Value(global::OpenStudio.OptionalDouble optionalDouble)
+ {
+ return optionalDouble == null || optionalDouble.isNull() ? double.NaN : optionalDouble.get();
+ }
+
+ /// OpenStudio's StandardOpaqueMaterial thermal, solar and visible absorptance defaults: what the writer leaves for an optic SAM does not state.
+ private static readonly double[] openStudioDefaultAbsorptances = { 0.9, 0.7, 0.7 };
+
+ ///
+ /// The thermal, solar and visible absorptances the writer puts on a face from SAM material
+ /// (Convert/ToOpenStudio/Material.cs): in the zone-face role of SAM's first layer the Internal* value where stated,
+ /// otherwise, and in every other role, the External* value; OpenStudio's default where neither is stated. Null for a
+ /// material that is not opaque.
+ ///
+ private static double[] WrittenAbsorptances(Core.IMaterial material, bool zoneFaceRole)
+ {
+ Core.OpaqueMaterial opaqueMaterial = material as Core.OpaqueMaterial;
+ if (opaqueMaterial == null)
+ {
+ return null;
+ }
+
+ (OpaqueMaterialParameter Internal, OpaqueMaterialParameter External, bool Direct)[] properties =
+ {
+ (OpaqueMaterialParameter.InternalEmissivity, OpaqueMaterialParameter.ExternalEmissivity, true),
+ (OpaqueMaterialParameter.InternalSolarReflectance, OpaqueMaterialParameter.ExternalSolarReflectance, false),
+ (OpaqueMaterialParameter.InternalLightReflectance, OpaqueMaterialParameter.ExternalLightReflectance, false),
+ };
+
+ double[] result = new double[properties.Length];
+ for (int i = 0; i < properties.Length; i++)
+ {
+ double value = double.NaN;
+ bool stated = zoneFaceRole && opaqueMaterial.TryGetValue(properties[i].Internal, out value) && !double.IsNaN(value);
+ if (!stated)
+ {
+ stated = opaqueMaterial.TryGetValue(properties[i].External, out value) && !double.IsNaN(value);
+ }
+
+ result[i] = !stated ? openStudioDefaultAbsorptances[i] : properties[i].Direct ? value : 1.0 - value;
+ }
+
+ return result;
+ }
+
+ ///
+ /// Compares one side's native layers, already in SAM order, with the panel's SAM layers: each StandardOpaqueMaterial
+ /// layer's thickness with the SAM layer thickness, and its conductivity, density and specific heat with the SAM
+ /// material's. Other layer kinds (massless, air gap) keep their documented nominal-thickness import and are matched
+ /// by material only. A count mismatch is left to the layer-name check.
+ ///
+ private static void ComparePhysical(List problems, string space, List nativeNames, List native, List layers, Core.MaterialLibrary materialLibrary)
+ {
+ if (native == null || native.Count != layers.Count)
+ {
+ return;
+ }
+
+ string[] names = { "conductivity", "density", "specific heat" };
+ for (int i = 0; i < layers.Count; i++)
+ {
+ double[] signature = native[i];
+ if (signature == null)
+ {
+ continue;
+ }
+
+ if (!Same(signature[0], layers[i].Thickness))
+ {
+ problems.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture, "layer {0} ('{1}') of the surface in space '{2}' is {3:R} m thick but the SAM layer is {4:R} m", i + 1, nativeNames[i], space, signature[0], layers[i].Thickness));
+ }
+
+ Core.OpaqueMaterial opaqueMaterial = materialLibrary?.GetMaterial(layers[i].Name) as Core.OpaqueMaterial;
+ if (opaqueMaterial == null)
+ {
+ continue;
+ }
+
+ double[] sam = { opaqueMaterial.ThermalConductivity, opaqueMaterial.Density, opaqueMaterial.SpecificHeatCapacity };
+ for (int j = 0; j < sam.Length; j++)
+ {
+ if (!Same(signature[j + 1], sam[j]))
+ {
+ problems.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture, "layer {0} ('{1}') of the surface in space '{2}' has {3} {4:R} but the SAM material {5:R}", i + 1, nativeNames[i], space, names[j], signature[j + 1], sam[j]));
+ }
+ }
+ }
+ }
+
+ private static void CompareFace(List problems, string face, double[] native, double[] written)
+ {
+ if (native == null || written == null)
+ {
+ return;
+ }
+
+ string[] names = { "thermal", "solar", "visible" };
+ for (int i = 0; i < native.Length; i++)
+ {
+ if (!double.IsNaN(native[i]) && Math.Abs(native[i] - written[i]) > 1e-9)
+ {
+ problems.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0} {1} absorptance would be {2:R} instead of the native {3:R}", face, names[i], written[i], native[i]));
+ }
+ }
+ }
+
+ ///
+ /// Checks every imported opaque surface panel against the native surfaces it was read from: the layers of its first
+ /// space's surface must be its SAM layers innermost first, those of a second side their mirror, each native
+ /// StandardOpaqueMaterial layer's own thickness and thermal properties on both sides must be those of the SAM layer
+ /// it became (merged source names alone would hide a different-thickness object on one side), and the absorptances
+ /// the writer would put on each face EnergyPlus uses (both zone faces of an interzone panel; the zone face, and the
+ /// outdoor face of an outdoor surface) must equal the native ones. A mismatch, which only an edited or mixed model
+ /// whose orientation or face roles cannot be established produces, is error SAM-OSI-CON-002 naming the panel and
+ /// faces, and the import returns no model. Layers whose native material is neither a standard nor a massless opaque
+ /// material carry their own diagnostics and are not compared.
+ ///
+ private static bool VerifySurfaceFaces(List>> orderedSpacePanels, Dictionary supersededPanels, PanelSides panelSides, Core.MaterialLibrary materialLibrary, OpenStudioImportContext openStudioImportContext)
+ {
+ Dictionary spaceNames = new Dictionary();
+ foreach (KeyValuePair keyValuePair in openStudioImportContext.SpaceMap)
+ {
+ if (keyValuePair.Value != null)
+ {
+ spaceNames[keyValuePair.Value] = keyValuePair.Key;
+ }
+ }
+
+ Dictionary> panelSpaceNames = new Dictionary>();
+ List panels = new List();
+ foreach (KeyValuePair> keyValuePair in orderedSpacePanels)
+ {
+ if (keyValuePair.Key == null || !spaceNames.TryGetValue(keyValuePair.Key, out string spaceName))
+ {
+ continue;
+ }
+
+ foreach (Panel panel in keyValuePair.Value)
+ {
+ Panel resolvedPanel = ResolveSupersededPanel(panel, supersededPanels);
+ if (resolvedPanel == null)
+ {
+ continue;
+ }
+
+ if (!panelSpaceNames.TryGetValue(resolvedPanel, out List names))
+ {
+ names = new List();
+ panelSpaceNames[resolvedPanel] = names;
+ panels.Add(resolvedPanel);
+ }
+
+ if (!names.Contains(spaceName))
+ {
+ names.Add(spaceName);
+ }
+ }
+ }
+
+ string SamName(string openStudioName)
+ {
+ return openStudioName != null && openStudioImportContext.MaterialMap.TryGetValue(openStudioName, out Core.IMaterial iMaterial) ? iMaterial?.Name : null;
+ }
+
+ bool result = true;
+ foreach (Panel panel in panels)
+ {
+ List layers = panel.PanelType == PanelType.Air ? null : panel.Construction?.ConstructionLayers?.FindAll(x => x != null);
+ if (layers == null || layers.Count == 0 || !panelSides.Faces.TryGetValue(panel, out List faces))
+ {
+ continue;
+ }
+
+ List spaces = panelSpaceNames[panel];
+ SurfaceFaces first = faces.FirstOrDefault(x => x.Space == spaces[0]);
+ if (first == null || first.Layers.Count == 0)
+ {
+ continue;
+ }
+
+ SurfaceFaces second = spaces.Count > 1 ? faces.FirstOrDefault(x => x.Space == spaces[1]) : null;
+ List samLayers = layers.Select(x => x.Name).ToList();
+ List problems = new List();
+
+ List firstAsSam = first.Layers.Select(SamName).ToList();
+ firstAsSam.Reverse();
+ if (!firstAsSam.SequenceEqual(samLayers))
+ {
+ problems.Add(string.Format("the layers of the surface in space '{0}' are not the SAM layers innermost first", spaces[0]));
+ }
+
+ if (second != null && second.Layers.Count > 0 && !second.Layers.Select(SamName).SequenceEqual(samLayers))
+ {
+ problems.Add(string.Format("the layers of the surface in space '{0}' are not the mirror of the first side", spaces[1]));
+ }
+
+ // Names alone are not enough: face and thickness objects of one source material merge into one SAM material,
+ // so each native layer's own thickness and thermal properties are compared with the SAM layer it became, on
+ // both sides and in each side's direction (innermost first for the first space, as stored for the second).
+ List firstNames = new List(first.Layers);
+ firstNames.Reverse();
+ List firstPhysical = new List(first.Physical);
+ firstPhysical.Reverse();
+ ComparePhysical(problems, spaces[0], firstNames, firstPhysical, layers, materialLibrary);
+ if (second != null && second.Layers.Count > 0)
+ {
+ ComparePhysical(problems, spaces[1], second.Layers, second.Physical, layers, materialLibrary);
+ }
+
+ CompareFace(problems, string.Format("the zone face in space '{0}':", spaces[0]), first.Zone, WrittenAbsorptances(materialLibrary?.GetMaterial(samLayers[0]), true));
+ bool lastIsFirst = samLayers.Count == 1;
+ Core.IMaterial last = materialLibrary?.GetMaterial(samLayers[samLayers.Count - 1]);
+ if (second != null)
+ {
+ CompareFace(problems, string.Format("the zone face in space '{0}':", spaces[1]), second.Zone, WrittenAbsorptances(last, lastIsFirst));
+ }
+ else if (string.Equals(first.Boundary, "Outdoors", StringComparison.OrdinalIgnoreCase))
+ {
+ CompareFace(problems, "the outdoor face:", first.Outside, WrittenAbsorptances(last, lastIsFirst));
+ }
+
+ if (problems.Count > 0)
+ {
+ result = false;
+ openStudioImportContext.AddDiagnostic(Core.OpenStudio.OpenStudioImportDiagnosticCodes.ConstructionFacesUnresolved, Core.OpenStudio.OpenStudioDiagnosticSeverity.Error, string.Format("Panel '{0}' with construction '{1}' cannot reproduce its native surfaces: {2}. One SAM panel cannot represent these surfaces of this (edited or mixed) model, so the import is unsuccessful rather than changing a layer or a face EnergyPlus uses", panel.Name, panel.Construction.Name, string.Join("; ", problems)), (string)null, panel);
+ }
+ }
+
+ return result;
+ }
+
+ /// The SAM construction a planar surface resolves to (innermost first), or null when it has none or it was not imported.
+ private static Construction SurfaceConstruction(global::OpenStudio.Surface surface, OpenStudioImportContext openStudioImportContext)
+ {
+ try
+ {
+ global::OpenStudio.OptionalConstructionBase optionalConstructionBase = surface?.construction();
+ if (optionalConstructionBase == null || optionalConstructionBase.isNull())
+ {
+ return null;
+ }
+
+ string name = optionalConstructionBase.get().nameString();
+ return openStudioImportContext.SurfaceConstructionMap.TryGetValue(name, out Construction result) || openStudioImportContext.ConstructionMap.TryGetValue(name, out result) ? result : null;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// The order the spaces are added to the cluster in. SAM's first space of a two-sided panel is the earlier of its two
+ /// spaces in that order (the writer gives it the forward construction), and the panel's layers are read innermost
+ /// first from the side it was built from, so that side's space must come first.
+ ///
+ /// The visit order is kept wherever it allows this; a panel built from a SAM-written forward side moves its space
+ /// before the other one (a stable topological order). Should those requirements contradict each other (only an
+ /// edited model can), the remaining spaces keep their visit order and a warning names it. A panel whose building
+ /// side is then not first (an unstamped panel of a mixed model, or a forward side lost to such a contradiction) is
+ /// re-read from its partner surface's construction (information, SAM-OSI-APX-001). Whether that keeps the native
+ /// layers and used faces is not assumed: checks it and fails the import otherwise.
+ /// Replacement panels are recorded in .
+ ///
+ ///
+ private static List>> OrderSpaces(List>> spacePanels, Dictionary supersededPanels, PanelSides panelSides, OpenStudioImportContext openStudioImportContext)
+ {
+ int count = spacePanels.Count;
+ Dictionary> panelSpaces = new Dictionary>();
+ for (int i = 0; i < count; i++)
+ {
+ foreach (Panel panel in spacePanels[i].Value)
+ {
+ Panel resolvedPanel = ResolveSupersededPanel(panel, supersededPanels);
+ if (resolvedPanel == null)
+ {
+ continue;
+ }
+
+ if (!panelSpaces.TryGetValue(resolvedPanel, out List indexes))
+ {
+ indexes = new List();
+ panelSpaces[resolvedPanel] = indexes;
+ }
+
+ if (!indexes.Contains(i))
+ {
+ indexes.Add(i);
+ }
+ }
+ }
+
+ int SideIndex(Panel panel, List indexes)
+ {
+ if (panelSides.SpaceName.TryGetValue(panel, out string name) && openStudioImportContext.SpaceMap.TryGetValue(name, out Space space) && space != null)
+ {
+ foreach (int index in indexes)
+ {
+ if (ReferenceEquals(space, spacePanels[index].Key))
+ {
+ return index;
+ }
+ }
+ }
+
+ return -1;
+ }
+
+ List> before = Enumerable.Range(0, count).Select(x => new HashSet()).ToList();
+ foreach (KeyValuePair> keyValuePair in panelSpaces)
+ {
+ int side = SideIndex(keyValuePair.Key, keyValuePair.Value);
+ if (keyValuePair.Value.Count == 2 && side >= 0 && panelSides.Forward.Contains(keyValuePair.Key))
+ {
+ before[keyValuePair.Value.Single(x => x != side)].Add(side);
+ }
+ }
+
+ List order = new List();
+ HashSet placed = new HashSet();
+ while (order.Count < count)
+ {
+ int next = Enumerable.Range(0, count).Where(x => !placed.Contains(x) && before[x].All(placed.Contains)).DefaultIfEmpty(-1).First();
+ if (next < 0)
+ {
+ List remaining = Enumerable.Range(0, count).Where(x => !placed.Contains(x)).ToList();
+ openStudioImportContext.AddDiagnostic(Core.OpenStudio.OpenStudioImportDiagnosticCodes.ApproximationApplied, Core.OpenStudio.OpenStudioDiagnosticSeverity.Warning, string.Format("The SAM-written forward sides of the interzone panels between spaces {0} cannot all come first in one space order; these spaces keep the OpenStudio order, a panel whose forward side is then second is read from its other side, and the import goes on only if that keeps the native layers and used faces (otherwise SAM-OSI-CON-002)",string.Join(", ", remaining.Select(x => "'" + spacePanels[x].Key?.Name + "'"))), (string)null);
+ order.AddRange(remaining);
+ break;
+ }
+
+ order.Add(next);
+ placed.Add(next);
+ }
+
+ Dictionary position = new Dictionary();
+ for (int i = 0; i < order.Count; i++)
+ {
+ position[order[i]] = i;
+ }
+
+ foreach (KeyValuePair> keyValuePair in panelSpaces.ToList())
+ {
+ Panel panel = keyValuePair.Key;
+ int side = SideIndex(panel, keyValuePair.Value);
+ if (keyValuePair.Value.Count != 2 || side < 0 || keyValuePair.Value.All(x => position[side] <= position[x]))
+ {
+ continue;
+ }
+
+ // Left as built when the partner construction is unknown: VerifySurfaceFaces then fails the import.
+ if (!panelSides.Partner.TryGetValue(panel, out Construction partner) || partner == null)
+ {
+ continue;
+ }
+
+ Panel reoriented = Analytical.Create.Panel(panel, partner);
+ if (reoriented == null)
+ {
+ continue;
+ }
+
+ int other = keyValuePair.Value.Single(x => x != side);
+ supersededPanels[panel] = reoriented;
+ if (panelSides.Faces.TryGetValue(panel, out List faces))
+ {
+ panelSides.Faces[reoriented] = faces;
+ }
+
+ openStudioImportContext.AddDiagnostic(Core.OpenStudio.OpenStudioImportDiagnosticCodes.ApproximationApplied, Core.OpenStudio.OpenStudioDiagnosticSeverity.Information, string.Format("Interzone panel built from the side of space '{0}' has space '{1}' first in the imported space order; its construction '{2}' is read from that space's surface instead, and the import goes on only if this keeps the native layers and used faces (otherwise SAM-OSI-CON-002)", spacePanels[side].Key?.Name, spacePanels[other].Key?.Name, partner.Name), (string)null, reoriented);
+ }
+
+ return order.Select(x => spacePanels[x]).ToList();
+ }
+
+ ///
+ /// True when the surface's construction is the SAM-written forward one: an OS:Construction stamped with
+ /// SAM type Construction and a SAM Guid. The writer gives it to the panel's first space only, and the
+ /// reverse construction of the other side carries no stamp.
+ ///
+ private static bool IsSamForwardSide(global::OpenStudio.Surface surface)
+ {
+ try
+ {
+ global::OpenStudio.OptionalConstructionBase optionalConstructionBase = surface?.construction();
+ if (optionalConstructionBase == null || optionalConstructionBase.isNull())
+ {
+ return false;
+ }
+
+ global::OpenStudio.ConstructionBase constructionBase = optionalConstructionBase.get();
+ return Core.OpenStudio.Query.TryGetSAMType(constructionBase, out string type)
+ && string.Equals(type, typeof(Construction).Name, StringComparison.Ordinal)
+ && Core.OpenStudio.Query.TryGetSAMGuid(constructionBase, out Guid _);
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
///
/// Returns the SAM panel for an OpenStudio surface, creating it only once per physical
/// element.
@@ -223,8 +773,15 @@ internal static AnalyticalModel ToSAM_AnalyticalModel(OpenStudioImportContext op
/// own adjacency handles are authoritative and are always tried first; geometry is only
/// consulted later, for surfaces that claim a "Surface" boundary but carry no handle.
///
+ ///
+ /// Side: a pair is built from the surface whose construction is the SAM-written forward one
+ /// () when exactly that partner carries it, so SAM's first
+ /// space, layer order and face roles come back as they were written; otherwise from the
+ /// first surface visited, as before. The building side is recorded in
+ /// .
+ ///
///
- private static Panel ResolvePanel(global::OpenStudio.Surface surface, global::OpenStudio.Transformation transformation, Dictionary panelBySurfaceName, List unpairedInterzoneSurfaces, OpenStudioImportContext openStudioImportContext)
+ private static Panel ResolvePanel(global::OpenStudio.Surface surface, global::OpenStudio.Transformation transformation, Dictionary panelBySurfaceName, List unpairedInterzoneSurfaces, PanelSides panelSides, OpenStudioImportContext openStudioImportContext)
{
string surfaceName = surface.nameString();
@@ -257,12 +814,43 @@ private static Panel ResolvePanel(global::OpenStudio.Surface surface, global::Op
}
}
- Panel panel = surface.ToSAM(openStudioImportContext, transformation);
+ global::OpenStudio.Surface side = surface;
+ global::OpenStudio.Transformation sideTransformation = transformation;
+ if (hasAdjacentHandle && !IsSamForwardSide(surface) && IsSamForwardSide(optionalAdjacentSurface.get()))
+ {
+ global::OpenStudio.OptionalSpace optionalSpace = optionalAdjacentSurface.get().space();
+ if (optionalSpace != null && !optionalSpace.isNull())
+ {
+ side = optionalAdjacentSurface.get();
+ sideTransformation = SpaceTransformation(optionalSpace.get());
+ }
+ }
+
+ Panel panel = side.ToSAM(openStudioImportContext, sideTransformation);
if (panel == null)
{
return null;
}
+ global::OpenStudio.OptionalSpace sideSpace = side.space();
+ if (sideSpace != null && !sideSpace.isNull())
+ {
+ panelSides.SpaceName[panel] = sideSpace.get().nameString();
+ }
+
+ if (IsSamForwardSide(side))
+ {
+ panelSides.Forward.Add(panel);
+ }
+
+ panelSides.AddFaces(panel, Snapshot(side));
+ if (hasAdjacentHandle)
+ {
+ global::OpenStudio.Surface partner = ReferenceEquals(side, surface) ? optionalAdjacentSurface.get() : surface;
+ panelSides.Partner[panel] = SurfaceConstruction(partner, openStudioImportContext);
+ panelSides.AddFaces(panel, Snapshot(partner));
+ }
+
panelBySurfaceName[surfaceName] = panel;
if (hasAdjacentHandle)
@@ -276,7 +864,7 @@ private static Panel ResolvePanel(global::OpenStudio.Surface surface, global::Op
// and dereferenced after the model walk: doing so aborts the process with an
// unmanaged AccessViolationException. The name is the key back into
// panelBySurfaceName; the label is all the diagnostics need.
- unpairedInterzoneSurfaces.Add(new UnpairedInterzoneSurface(surfaceName, OpenStudioImportContext.OpenStudioObjectLabel(surface)));
+ unpairedInterzoneSurfaces.Add(new UnpairedInterzoneSurface(surfaceName, OpenStudioImportContext.OpenStudioObjectLabel(surface), IsSamForwardSide(surface)));
}
return panel;
@@ -298,7 +886,7 @@ private static Panel ResolvePanel(global::OpenStudio.Surface surface, global::Op
/// exactly what the forward direction does with the mirror-image case.
///
///
- private static void ResolveUnpairedInterzoneSurfaces(List surfaces, Dictionary panelBySurfaceName, Dictionary supersededPanels, OpenStudioImportContext openStudioImportContext)
+ private static void ResolveUnpairedInterzoneSurfaces(List surfaces, Dictionary panelBySurfaceName, Dictionary supersededPanels, PanelSides panelSides, OpenStudioImportContext openStudioImportContext)
{
if (surfaces == null || surfaces.Count == 0)
{
@@ -343,15 +931,26 @@ private static void ResolveUnpairedInterzoneSurfaces(List supersededFaces))
+ {
+ supersededFaces.ForEach(x => panelSides.AddFaces(kept, x));
+ }
paired.Add(nameI);
paired.Add(nameJ);
- openStudioImportContext.AddDiagnostic(Core.OpenStudio.OpenStudioImportDiagnosticCodes.AdjacencyGeometricFallback, Core.OpenStudio.OpenStudioDiagnosticSeverity.Warning, string.Format("Surfaces '{0}' and '{1}' declare an interzone boundary but carry no OpenStudio adjacency handle; they were paired into one SAM panel by validated geometric matching (coincident centroids, comparable areas, opposed normals) - verify the source model's adjacency", nameI, nameJ), surfaces[i].Label, panelI);
+ openStudioImportContext.AddDiagnostic(Core.OpenStudio.OpenStudioImportDiagnosticCodes.AdjacencyGeometricFallback, Core.OpenStudio.OpenStudioDiagnosticSeverity.Warning, string.Format("Surfaces '{0}' and '{1}' declare an interzone boundary but carry no OpenStudio adjacency handle; they were paired into one SAM panel by validated geometric matching (coincident centroids, comparable areas, opposed normals) - verify the source model's adjacency", nameI, nameJ), surfaces[i].Label, kept);
break;
}
}
@@ -403,12 +1002,16 @@ private static Panel ResolveSupersededPanel(Panel panel, Dictionary
private struct UnpairedInterzoneSurface
{
- public UnpairedInterzoneSurface(string name, string label)
+ public UnpairedInterzoneSurface(string name, string label, bool samForwardSide)
{
Name = name;
Label = label;
+ SamForwardSide = samForwardSide;
}
+ /// True when the surface's construction is the SAM-written forward one (see IsSamForwardSide).
+ public bool SamForwardSide { get; }
+
/// OpenStudio surface name; the key into panelBySurfaceName.
public string Name { get; }
diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/Construction.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/Construction.cs
index 894ccc1..265d607 100644
--- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/Construction.cs
+++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/Construction.cs
@@ -22,9 +22,16 @@ public static partial class Convert
/// stable regardless of import order.
///
///
- /// Layer order is preserved exactly as OpenStudio stores it — outside first — which is
- /// the order the forward converter writes, so a construction survives a round trip
- /// without being reversed.
+ /// Layer order: OpenStudio stores layers outside first, SAM inside → outside from the
+ /// panel's first space (the forward converter reverses SAM's order). An opaque layered
+ /// construction used by a planar surface is therefore read innermost layer first, so the
+ /// layer facing the surface's zone is SAM's first layer and an export, import and export
+ /// keeps every layer, thickness and exposed face in place. Fenestration constructions
+ /// (glazing, gas and opaque door panes), air boundaries, non-layered constructions and
+ /// constructions no planar surface uses keep OpenStudio's order. A construction that is
+ /// used by planar surfaces and also by a subsurface or shading surface is split: the
+ /// surfaces get the innermost-first SAM construction, the other uses a separate one in
+ /// OpenStudio's order (reported).
///
///
/// Import context.
@@ -179,6 +186,29 @@ private static object ToSAM_Construction(global::OpenStudio.ConstructionBase con
return apertureConstruction;
}
+ (bool surfaceUse, bool otherUse) = ConstructionUse(constructionBase, openStudioImportContext);
+ if (surfaceUse)
+ {
+ List innermostFirst = new List(constructionLayers);
+ innermostFirst.Reverse();
+ if (otherUse)
+ {
+ Construction surfaceConstruction = new Construction(openStudioImportContext.ResolveGuid(constructionBase, typeof(Construction).Name), name, innermostFirst);
+ Modify.SetOpenStudioSource(surfaceConstruction, constructionBase);
+ openStudioImportContext.SurfaceConstructionMap[name] = surfaceConstruction;
+ openStudioImportContext.RegisterCreated();
+
+ Construction openStudioOrder = new Construction(openStudioImportContext.ResolveGuid(null), name, constructionLayers);
+ Modify.SetOpenStudioSource(openStudioOrder, constructionBase);
+ openStudioImportContext.ConstructionMap[name] = openStudioOrder;
+ openStudioImportContext.RegisterCreated();
+ openStudioImportContext.AddDiagnostic(Core.OpenStudio.OpenStudioImportDiagnosticCodes.ApproximationApplied, Core.OpenStudio.OpenStudioDiagnosticSeverity.Information, "Opaque construction is used by planar surfaces and by a subsurface or shading surface: the surfaces receive a SAM construction read innermost layer first (SAM inside -> outside), the other uses a separate SAM construction in OpenStudio's layer order with its own Guid", label);
+ return surfaceConstruction;
+ }
+
+ constructionLayers = innermostFirst;
+ }
+
Construction result = new Construction(openStudioImportContext.ResolveGuid(constructionBase, typeof(Construction).Name), name, constructionLayers);
Modify.SetOpenStudioSource(result, constructionBase);
openStudioImportContext.ConstructionMap[name] = result;
@@ -186,6 +216,60 @@ private static object ToSAM_Construction(global::OpenStudio.ConstructionBase con
return result;
}
+ ///
+ /// Whether planar surfaces, and whether subsurfaces or shading surfaces, of the source model use
+ /// (directly or through a default construction set), read once per import.
+ ///
+ private static (bool Surface, bool Other) ConstructionUse(global::OpenStudio.ConstructionBase constructionBase, OpenStudioImportContext openStudioImportContext)
+ {
+ if (openStudioImportContext.ConstructionUses == null)
+ {
+ Dictionary uses = new Dictionary(StringComparer.Ordinal);
+ void Use(global::OpenStudio.OptionalConstructionBase optionalConstructionBase, bool surface)
+ {
+ if (optionalConstructionBase == null || optionalConstructionBase.isNull())
+ {
+ return;
+ }
+
+ string key = optionalConstructionBase.get().handle().__str__();
+ uses.TryGetValue(key, out (bool Surface, bool Other) use);
+ uses[key] = surface ? (true, use.Other) : (use.Surface, true);
+ }
+
+ try
+ {
+ foreach (global::OpenStudio.Surface surface in openStudioImportContext.Source.getSurfaces())
+ {
+ Use(surface.construction(), true);
+ }
+
+ foreach (global::OpenStudio.SubSurface subSurface in openStudioImportContext.Source.getSubSurfaces())
+ {
+ Use(subSurface.construction(), false);
+ }
+
+ foreach (global::OpenStudio.ShadingSurface shadingSurface in openStudioImportContext.Source.getShadingSurfaces())
+ {
+ Use(shadingSurface.construction(), false);
+ }
+
+ foreach (global::OpenStudio.InteriorPartitionSurface interiorPartitionSurface in openStudioImportContext.Source.getInteriorPartitionSurfaces())
+ {
+ Use(interiorPartitionSurface.construction(), false);
+ }
+ }
+ catch (Exception)
+ {
+ // an unreadable use leaves the construction in OpenStudio's order, as before
+ }
+
+ openStudioImportContext.ConstructionUses = uses;
+ }
+
+ return openStudioImportContext.ConstructionUses.TryGetValue(constructionBase.handle().__str__(), out (bool Surface, bool Other) result) ? result : (false, false);
+ }
+
///
/// Thickness [m] of a construction layer. OpenStudio reports 0 for layers defined by
/// resistance rather than geometry (massless, air gap, simple glazing); the nominal
diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/InternalCondition.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/InternalCondition.cs
index c47eccb..49e9bab 100644
--- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/InternalCondition.cs
+++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/InternalCondition.cs
@@ -233,12 +233,48 @@ private static void ToSAM_Lights(global::OpenStudio.SpaceType spaceType, Interna
}
}
- internalCondition.SetValue(InternalConditionParameter.LightingRadiantProportion, lightsDefinition.fractionRadiant());
- internalCondition.SetValue(InternalConditionParameter.LightingViewCoefficient, lightsDefinition.fractionVisible());
+ ToSAM_LightingRadiantProportion(lightsDefinition, internalCondition, openStudioImportContext, label);
AssignProfile(internalCondition, InternalConditionParameter.LightingProfileName, lights.schedule(), ProfileType.Lighting, openStudioImportContext, profileLibrary);
}
+ ///
+ /// Lights radiant and visible fractions → SAM's total radiant share (S07 §5.1, default resolution item 5):
+ /// p = Fraction Radiant + Fraction Visible. The view coefficient is a comfort coefficient, not an energy fraction,
+ /// and is not set. A return-air share has no SAM allocation and becomes part of the convective room gain
+ /// (warning); a short-wave share of the radiant output other than s is reported (information). A sum above 1 is
+ /// an EnergyPlus input error and is not imported.
+ ///
+ private static void ToSAM_LightingRadiantProportion(global::OpenStudio.LightsDefinition lightsDefinition, InternalCondition internalCondition, OpenStudioImportContext openStudioImportContext, string label)
+ {
+ double fractionRadiant = lightsDefinition.fractionRadiant();
+ double fractionVisible = lightsDefinition.fractionVisible();
+ double fractionReturnAir = lightsDefinition.returnAirFraction();
+ if (double.IsNaN(fractionRadiant) || double.IsNaN(fractionVisible))
+ {
+ return;
+ }
+
+ double radiantProportion = fractionRadiant + fractionVisible;
+ if (radiantProportion > 1 + 1e-9 || (!double.IsNaN(fractionReturnAir) && fractionReturnAir + radiantProportion > 1 + 1e-9))
+ {
+ openStudioImportContext.AddDiagnostic(Core.OpenStudio.OpenStudioImportDiagnosticCodes.ApproximationApplied, Core.OpenStudio.OpenStudioDiagnosticSeverity.Warning, string.Format(CultureInfo.InvariantCulture, "The Lights fractions sum above 1 (Return Air {0:G6}, Radiant {1:G6}, Visible {2:G6}), an EnergyPlus input error; no lighting radiant proportion was imported", fractionReturnAir, fractionRadiant, fractionVisible), label, internalCondition);
+ return;
+ }
+
+ internalCondition.SetValue(InternalConditionParameter.LightingRadiantProportion, radiantProportion);
+
+ if (!double.IsNaN(fractionReturnAir) && fractionReturnAir > 0)
+ {
+ openStudioImportContext.AddDiagnostic(Core.OpenStudio.OpenStudioImportDiagnosticCodes.ApproximationApplied, Core.OpenStudio.OpenStudioDiagnosticSeverity.Warning, string.Format(CultureInfo.InvariantCulture, "The Lights Return Air Fraction {0:G6} has no SAM allocation; that share becomes part of the convective room gain (SAM convective share 1 − p = {1:G6}, S07 §5.1)", fractionReturnAir, 1 - radiantProportion), label, internalCondition);
+ }
+
+ if (radiantProportion > 0 && Math.Abs(fractionVisible / radiantProportion - LightingSplit.ShortWaveShare) > 1e-6)
+ {
+ openStudioImportContext.AddDiagnostic(Core.OpenStudio.OpenStudioImportDiagnosticCodes.ApproximationApplied, Core.OpenStudio.OpenStudioDiagnosticSeverity.Information, string.Format(CultureInfo.InvariantCulture, "The Lights short-wave share of the radiant output, Fraction Visible / (Fraction Radiant + Fraction Visible) = {0:G6}, differs from the S07 §5.1 declared s = {1:G6}; SAM keeps only the total radiant share p = {2:G6}, so an export re-splits it by s", fractionVisible / radiantProportion, LightingSplit.ShortWaveShare, radiantProportion), label, internalCondition);
+ }
+ }
+
///
/// ElectricEquipment → sensible and latent equipment gains.
///
diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/MaterialLibrary.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/MaterialLibrary.cs
index 7688cc3..a4d1d0f 100644
--- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/MaterialLibrary.cs
+++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/MaterialLibrary.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
+using System.Linq;
using SAM.Core;
namespace SAM.Analytical.OpenStudio
@@ -108,11 +109,32 @@ public static IMaterial ToSAM(this global::OpenStudio.Material material, OpenStu
}
string label = OpenStudioImportContext.OpenStudioObjectLabel(material);
+ global::OpenStudio.OptionalStandardOpaqueMaterial optionalStandardOpaqueMaterial = global::OpenStudio.OpenStudioModelResources.toStandardOpaqueMaterial(material);
+
+ // SAM-written face and thickness objects of one source material become one SAM material, matched by the
+ // explicit source Guid only (never by name). Null when there is nothing to merge or they conflict. The object
+ // itself is validated first and must be a member of the group: a malformed one carrying the same source Guid
+ // is imported as its own material with its own properties, reported, never aliased to a valid group.
+ if (optionalStandardOpaqueMaterial != null && !optionalStandardOpaqueMaterial.isNull() && TryGetSourceMaterialGuid(optionalStandardOpaqueMaterial.get(), out Guid sourceGuid)
+ && ReadFaceObject(optionalStandardOpaqueMaterial.get(), openStudioImportContext, label, false) != null)
+ {
+ if (!openStudioImportContext.OpaqueMaterialGroups.TryGetValue(sourceGuid, out (IMaterial Material, ISet Members) group))
+ {
+ group = MergedOpaqueMaterial(sourceGuid, openStudioImportContext);
+ openStudioImportContext.OpaqueMaterialGroups[sourceGuid] = group;
+ }
+
+ if (group.Material != null && group.Members.Contains(Handle(material)))
+ {
+ openStudioImportContext.MaterialMap[name] = group.Material;
+ return group.Material;
+ }
+ }
+
Guid guid = openStudioImportContext.ResolveGuid(material);
IMaterial result = null;
- global::OpenStudio.OptionalStandardOpaqueMaterial optionalStandardOpaqueMaterial = global::OpenStudio.OpenStudioModelResources.toStandardOpaqueMaterial(material);
if (optionalStandardOpaqueMaterial != null && !optionalStandardOpaqueMaterial.isNull())
{
result = ToSAM_OpaqueMaterial(optionalStandardOpaqueMaterial.get(), guid, name, openStudioImportContext, label);
@@ -197,25 +219,435 @@ private static IMaterial ToSAM_OpaqueMaterial(global::OpenStudio.StandardOpaqueM
result.SetValue(Core.MaterialParameter.DefaultThickness, standardOpaqueMaterial.thickness());
- // EnergyPlus opaque materials are single-sided: one absorptance set governs both
- // faces. Both SAM sides therefore receive the same value - recorded here so the
- // asymmetry the forward converter warns about is not silently invented on the way
- // back.
double thermalAbsorptance = standardOpaqueMaterial.thermalAbsorptance();
+ double solarReflectance = 1.0 - standardOpaqueMaterial.solarAbsorptance();
+ double visibleReflectance = 1.0 - standardOpaqueMaterial.visibleAbsorptance();
+
+ // A SAM-written object carries both authored face sets explicitly (the forward converter
+ // writes an internal-face variant for a zone face). When that metadata is usable, exactly the
+ // authored values are restored - nothing unstated is filled in - and an edit made to the object
+ // after it was written is kept on the face it carries, reported.
+ FaceObject faceObject = ReadFaceObject(standardOpaqueMaterial, openStudioImportContext, label, true);
+ if (faceObject != null)
+ {
+ foreach (KeyValuePair keyValuePair in faceObject.Authored)
+ {
+ result.SetValue(keyValuePair.Key, keyValuePair.Value);
+ }
+
+ ApplyEdits(result, new[] { faceObject }, faceObject.Edits, openStudioImportContext);
+ return result;
+ }
+
+ // EnergyPlus opaque materials are single-sided: one absorptance set governs both
+ // faces. Without usable SAM face metadata both SAM sides therefore receive the same
+ // value - recorded here so no asymmetry is invented on the way back.
result.SetValue(OpaqueMaterialParameter.ExternalEmissivity, thermalAbsorptance);
result.SetValue(OpaqueMaterialParameter.InternalEmissivity, thermalAbsorptance);
-
- double solarReflectance = 1.0 - standardOpaqueMaterial.solarAbsorptance();
result.SetValue(OpaqueMaterialParameter.ExternalSolarReflectance, solarReflectance);
result.SetValue(OpaqueMaterialParameter.InternalSolarReflectance, solarReflectance);
-
- double visibleReflectance = 1.0 - standardOpaqueMaterial.visibleAbsorptance();
result.SetValue(OpaqueMaterialParameter.ExternalLightReflectance, visibleReflectance);
result.SetValue(OpaqueMaterialParameter.InternalLightReflectance, visibleReflectance);
return result;
}
+ private static readonly OpaqueMaterialParameter[] stampedOptics =
+ {
+ OpaqueMaterialParameter.ExternalEmissivity, OpaqueMaterialParameter.InternalEmissivity,
+ OpaqueMaterialParameter.ExternalSolarReflectance, OpaqueMaterialParameter.InternalSolarReflectance,
+ OpaqueMaterialParameter.ExternalLightReflectance, OpaqueMaterialParameter.InternalLightReflectance,
+ };
+
+ /// One SAM-written opaque face object, read from its explicit metadata.
+ private sealed class FaceObject
+ {
+ public global::OpenStudio.StandardOpaqueMaterial Material { get; set; }
+ public string Name { get; set; }
+ public string Label { get; set; }
+ public Guid MaterialGuid { get; set; }
+ public bool Internal { get; set; }
+ public bool CarriesIdentity { get; set; }
+
+ /// The authored SAM face optics the writer stamped (unstated ones absent).
+ public Dictionary Authored { get; } = new Dictionary();
+
+ /// Values changed since the object was written, as SAM values on the face the object carries.
+ public Dictionary Edits { get; } = new Dictionary();
+
+ /// Per edited parameter: what was written and what is there now.
+ public Dictionary EditNotes { get; } = new Dictionary();
+
+ public string Signature => string.Join(";", Authored.OrderBy(x => x.Key.ToString(), StringComparer.Ordinal).Select(x => x.Key + "=" + x.Value.ToString("R", CultureInfo.InvariantCulture)));
+
+ private (bool Internal, bool External, bool Exposed, string Note)? faces;
+
+ ///
+ /// The SAM faces whose values this object's single absorptance set supplies, so an edit is kept on each of
+ /// them. The writer's contract decides it where it can: an internal variant is placed only on its material's
+ /// zone face, and an external-face object of a material whose authored faces differ never is (the variant
+ /// is). An external-face object of a material whose faces agree is shared by every layer position, so its
+ /// faces are read from the constructions that use it (). When it supplies no exposed
+ /// face nothing distinguishes the faces: its single value is kept on the external face and on each stated
+ /// internal one, so no asymmetry is invented and an unstated internal value still takes the external one.
+ ///
+ public (bool Internal, bool External, bool Exposed, string Note) Faces
+ {
+ get
+ {
+ if (faces == null)
+ {
+ if (Internal)
+ {
+ faces = (true, false, true, string.Empty);
+ }
+ else if (DivergentAuthoredOptics(Authored))
+ {
+ faces = (false, true, true, string.Empty);
+ }
+ else
+ {
+ (bool servesInternal, bool servesExternal) = ServedFaces(Material);
+ faces = !servesInternal && !servesExternal
+ ? (true, true, false, "; its material's faces agree and it supplies no exposed face, so the value is kept on both faces")
+ : (servesInternal, servesExternal, true, servesInternal ? "; its material's faces agree and this shared object supplies the zone face" + (servesExternal ? " and the outer face" : string.Empty) : string.Empty);
+ }
+ }
+
+ return faces.Value;
+ }
+ }
+
+ /// True when this object supplies the face of (see ).
+ public bool Carries(OpaqueMaterialParameter opaqueMaterialParameter)
+ {
+ if (!opaqueMaterialParameter.ToString().StartsWith("Internal", StringComparison.Ordinal))
+ {
+ return Faces.External;
+ }
+
+ return Faces.Internal && (Faces.Exposed || Authored.ContainsKey(opaqueMaterialParameter));
+ }
+ }
+
+ /// True when an authored Internal* optic differs from its External* counterpart or has none: the writer then places an internal variant on the zone face (mirrors the writer's DivergentInternalOptics).
+ private static bool DivergentAuthoredOptics(Dictionary authored)
+ {
+ foreach ((OpaqueMaterialParameter internalParameter, OpaqueMaterialParameter externalParameter) in new[]
+ {
+ (OpaqueMaterialParameter.InternalEmissivity, OpaqueMaterialParameter.ExternalEmissivity),
+ (OpaqueMaterialParameter.InternalSolarReflectance, OpaqueMaterialParameter.ExternalSolarReflectance),
+ (OpaqueMaterialParameter.InternalLightReflectance, OpaqueMaterialParameter.ExternalLightReflectance),
+ })
+ {
+ if (authored.TryGetValue(internalParameter, out double internalValue) && (!authored.TryGetValue(externalParameter, out double externalValue) || Math.Abs(internalValue - externalValue) > 1e-9))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// The SAM faces an opaque object supplies through the constructions that use it as their outermost or innermost
+ /// layer. A SAM-written opaque construction (SAM type Construction) is the forward, outside-first one: its last
+ /// layer is the SAM first layer's zone face (internal) and its first layer the SAM last layer's outer face
+ /// (external). A SAM-written fenestration construction (an opaque door pane) takes the external values on both
+ /// faces. A construction without SAM identity that mirrors a SAM-written one is its reverse and shares its
+ /// objects; any other construction's direction is unknown, so an exposed position supplies both faces.
+ ///
+ private static (bool Internal, bool External) ServedFaces(global::OpenStudio.StandardOpaqueMaterial standardOpaqueMaterial)
+ {
+ bool servesInternal = false;
+ bool servesExternal = false;
+ string handle = Handle(standardOpaqueMaterial);
+
+ List<(global::OpenStudio.Construction Construction, List Layers)> constructions = standardOpaqueMaterial.model().getConstructions()
+ .Select(x => (x, x.layers().Select(Handle).ToList())).ToList();
+ HashSet written = new HashSet(constructions.Where(x => Core.OpenStudio.Query.TryGetSAMType(x.Construction, out string _)).Select(x => string.Join("|", x.Layers)), StringComparer.Ordinal);
+
+ foreach ((global::OpenStudio.Construction construction, List layers) in constructions)
+ {
+ bool outermost = layers.Count > 0 && layers[0] == handle;
+ bool innermost = layers.Count > 0 && layers[layers.Count - 1] == handle;
+ if (!outermost && !innermost)
+ {
+ continue;
+ }
+
+ if (Core.OpenStudio.Query.TryGetSAMType(construction, out string type))
+ {
+ if (string.Equals(type, typeof(Construction).Name, StringComparison.Ordinal))
+ {
+ servesInternal |= innermost;
+ servesExternal |= outermost;
+ }
+ else
+ {
+ servesExternal = true;
+ }
+
+ continue;
+ }
+
+ List reversed = new List(layers);
+ reversed.Reverse();
+ if (written.Contains(string.Join("|", reversed)))
+ {
+ continue;
+ }
+
+ servesInternal = true;
+ servesExternal = true;
+ }
+
+ return (servesInternal, servesExternal);
+ }
+
+ private static string Handle(global::OpenStudio.ModelObject modelObject)
+ {
+ return modelObject?.handle()?.__str__() ?? string.Empty;
+ }
+
+ private const string singleValueOnBothFaces = "; the object is imported as its own material, never merged with others of its source, and its single EnergyPlus value is imported on both faces";
+
+ /// The source SAM material Guid stamped on a SAM-written opaque object, if well formed.
+ private static bool TryGetSourceMaterialGuid(global::OpenStudio.StandardOpaqueMaterial standardOpaqueMaterial, out Guid materialGuid)
+ {
+ materialGuid = Guid.Empty;
+ return Core.OpenStudio.Query.TryGetSAMFeature(standardOpaqueMaterial, Core.OpenStudio.OpenStudioIdentityKeys.MaterialFace, out string _)
+ && Core.OpenStudio.Query.TryGetSAMFeature(standardOpaqueMaterial, Core.OpenStudio.OpenStudioIdentityKeys.MaterialGuid, out string raw)
+ && Guid.TryParse(raw, out materialGuid) && materialGuid != Guid.Empty;
+ }
+
+ ///
+ /// Reads the SAM face metadata of an opaque object (see ).
+ /// Null when the object has none (a third-party or older OSM: silent) or it cannot be trusted (reported when
+ /// ): the source Guid and face must be well formed, any SAM.Guid must name the
+ /// same material, every stamped value must be a fraction, and the written values must agree with the authored
+ /// face they came from.
+ ///
+ /// Each absorptance is compared with the value the writer put there (SAM.Written.*, or the authored face it
+ /// came from): an unchanged object keeps exactly the authored values, so an OpenStudio default written for an
+ /// unstated value is never imported as SAM data, while a changed value is an edit, kept on every face the object
+ /// supplies (). Without either record the value's provenance is unknown and it is
+ /// kept as an edit too.
+ ///
+ ///
+ private static FaceObject ReadFaceObject(global::OpenStudio.StandardOpaqueMaterial standardOpaqueMaterial, OpenStudioImportContext openStudioImportContext, string label, bool report)
+ {
+ if (!Core.OpenStudio.Query.TryGetSAMFeature(standardOpaqueMaterial, Core.OpenStudio.OpenStudioIdentityKeys.MaterialFace, out string face))
+ {
+ return null;
+ }
+
+ if (!Core.OpenStudio.Query.TryGetSAMFeature(standardOpaqueMaterial, Core.OpenStudio.OpenStudioIdentityKeys.MaterialGuid, out string rawGuid) || !Guid.TryParse(rawGuid, out Guid materialGuid) || materialGuid == Guid.Empty
+ || (face != Core.OpenStudio.OpenStudioIdentityKeys.MaterialFaceExternal && face != Core.OpenStudio.OpenStudioIdentityKeys.MaterialFaceInternal))
+ {
+ Report(report, openStudioImportContext, Core.OpenStudio.OpenStudioImportDiagnosticCodes.IdentityCollision, string.Format("SAM face metadata is malformed (source material '{0}', face '{1}')" + singleValueOnBothFaces, rawGuid, face), label);
+ return null;
+ }
+
+ bool carriesIdentity = Core.OpenStudio.Query.TryGetSAMGuid(standardOpaqueMaterial, out Guid ownGuid);
+ if (carriesIdentity && ownGuid != materialGuid)
+ {
+ Report(report, openStudioImportContext, Core.OpenStudio.OpenStudioImportDiagnosticCodes.IdentityCollision, string.Format("SAM identity {0} and source material {1} of this face object disagree" + singleValueOnBothFaces, ownGuid, materialGuid), label);
+ return null;
+ }
+
+ FaceObject result = new FaceObject { Material = standardOpaqueMaterial, Name = standardOpaqueMaterial.nameString(), Label = label, MaterialGuid = materialGuid, Internal = face == Core.OpenStudio.OpenStudioIdentityKeys.MaterialFaceInternal, CarriesIdentity = carriesIdentity };
+ foreach (OpaqueMaterialParameter opaqueMaterialParameter in stampedOptics)
+ {
+ if (!Core.OpenStudio.Query.TryGetSAMFeature(standardOpaqueMaterial, Core.OpenStudio.OpenStudioIdentityKeys.OpticPrefix + opaqueMaterialParameter, out string raw))
+ {
+ continue;
+ }
+
+ if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out double value) || double.IsNaN(value) || value < 0 || value > 1)
+ {
+ Report(report, openStudioImportContext, Core.OpenStudio.OpenStudioImportDiagnosticCodes.MaterialUnsupported, string.Format("Stamped SAM {0} '{1}' is not a fraction" + singleValueOnBothFaces, opaqueMaterialParameter, raw), label);
+ return null;
+ }
+
+ result.Authored[opaqueMaterialParameter] = value;
+ }
+
+ // (internal, external, written key, native absorptance, absorptance is the SAM value itself)
+ (OpaqueMaterialParameter Internal, OpaqueMaterialParameter External, string Written, double Native, bool Direct)[] properties =
+ {
+ (OpaqueMaterialParameter.InternalEmissivity, OpaqueMaterialParameter.ExternalEmissivity, "ThermalAbsorptance", standardOpaqueMaterial.thermalAbsorptance(), true),
+ (OpaqueMaterialParameter.InternalSolarReflectance, OpaqueMaterialParameter.ExternalSolarReflectance, "SolarAbsorptance", standardOpaqueMaterial.solarAbsorptance(), false),
+ (OpaqueMaterialParameter.InternalLightReflectance, OpaqueMaterialParameter.ExternalLightReflectance, "VisibleAbsorptance", standardOpaqueMaterial.visibleAbsorptance(), false),
+ };
+
+ foreach ((OpaqueMaterialParameter internalParameter, OpaqueMaterialParameter externalParameter, string writtenKey, double native, bool direct) in properties)
+ {
+ double? written = null;
+ if (Core.OpenStudio.Query.TryGetSAMFeature(standardOpaqueMaterial, Core.OpenStudio.OpenStudioIdentityKeys.WrittenPrefix + writtenKey, out string rawWritten))
+ {
+ if (!double.TryParse(rawWritten, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) || double.IsNaN(parsed))
+ {
+ Report(report, openStudioImportContext, Core.OpenStudio.OpenStudioImportDiagnosticCodes.MaterialUnsupported, string.Format("Stamped written {0} '{1}' is not a number" + singleValueOnBothFaces, writtenKey, rawWritten), label);
+ return null;
+ }
+
+ written = parsed;
+ }
+
+ // The authored face the writer used: the internal value on an internal-face object when stated, else the external one.
+ OpaqueMaterialParameter source = result.Internal && result.Authored.ContainsKey(internalParameter) ? internalParameter : externalParameter;
+ if (result.Authored.TryGetValue(source, out double authored))
+ {
+ double expected = direct ? authored : 1.0 - authored;
+ if (written.HasValue && Math.Abs(written.Value - expected) > 1e-9)
+ {
+ Report(report, openStudioImportContext, Core.OpenStudio.OpenStudioImportDiagnosticCodes.MaterialUnsupported, string.Format(CultureInfo.InvariantCulture, "Stamped written {0} {1:R} disagrees with the stamped SAM {2} {3:R}" + singleValueOnBothFaces, writtenKey, written.Value, source, authored), label);
+ return null;
+ }
+
+ written = written ?? expected;
+ }
+
+ if (written.HasValue && Math.Abs(written.Value - native) <= 1e-9)
+ {
+ continue;
+ }
+
+ // Kept on every SAM face the object supplies (FaceObject.Faces), never only on the face it is stamped with.
+ foreach (OpaqueMaterialParameter onFace in new[] { internalParameter, externalParameter }.Where(result.Carries))
+ {
+ result.Edits[onFace] = direct ? native : 1.0 - native;
+ result.EditNotes[onFace] = (written.HasValue
+ ? string.Format(CultureInfo.InvariantCulture, "{0} written {1:R}, now {2:R}", writtenKey, written.Value, native)
+ : string.Format(CultureInfo.InvariantCulture, "{0} {1:R} with no record of what was written", writtenKey, native)) + result.Faces.Note;
+ }
+ }
+
+ return result;
+ }
+
+ private static void Report(bool report, OpenStudioImportContext openStudioImportContext, string code, string message, string label)
+ {
+ if (report)
+ {
+ openStudioImportContext.AddDiagnostic(code, Core.OpenStudio.OpenStudioDiagnosticSeverity.Warning, message, label);
+ }
+ }
+
+ /// Sets each edited value on its face and reports it: an edit made after export is kept, never silently replaced by an authored value or a default.
+ private static void ApplyEdits(OpaqueMaterial opaqueMaterial, IEnumerable faceObjects, Dictionary edits, OpenStudioImportContext openStudioImportContext)
+ {
+ foreach (KeyValuePair keyValuePair in edits)
+ {
+ opaqueMaterial.SetValue(keyValuePair.Key, keyValuePair.Value);
+ }
+
+ foreach (FaceObject faceObject in faceObjects)
+ {
+ foreach (KeyValuePair note in faceObject.EditNotes)
+ {
+ openStudioImportContext.AddDiagnostic(Core.OpenStudio.OpenStudioImportDiagnosticCodes.MaterialUnsupported, Core.OpenStudio.OpenStudioDiagnosticSeverity.Warning, string.Format(CultureInfo.InvariantCulture, "The {0}-face object was changed after it was written ({1}); the changed value is imported as SAM {2} = {3:R}", faceObject.Internal ? "internal" : "external", note.Value, note.Key, faceObject.Edits[note.Key]), faceObject.Label);
+ }
+ }
+ }
+
+ ///
+ /// One SAM material for all SAM-written face and thickness objects of source material ,
+ /// with the handles of the objects merged into it, or a null material when fewer than two usable objects exist or
+ /// they conflict (each is then imported on its own).
+ ///
+ /// Matching is by the explicit SAM.MaterialGuid only, never by name. An object whose face metadata cannot be
+ /// trusted is not a member and never resolves to the merged material. The objects merge when their thermal
+ /// properties and authored face optics agree and, per face, every object that supplies it resolves each optic to
+ /// the same value (its edit, else the authored value). Layer thicknesses stay on the construction layers. The SAM
+ /// Guid is restored from the object that carries it.
+ ///
+ ///
+ private static (IMaterial Material, ISet Members) MergedOpaqueMaterial(Guid materialGuid, OpenStudioImportContext openStudioImportContext)
+ {
+ List faceObjects = new List();
+ foreach (global::OpenStudio.StandardOpaqueMaterial standardOpaqueMaterial in openStudioImportContext.Source.getStandardOpaqueMaterials())
+ {
+ if (TryGetSourceMaterialGuid(standardOpaqueMaterial, out Guid guid) && guid == materialGuid)
+ {
+ FaceObject faceObject = ReadFaceObject(standardOpaqueMaterial, openStudioImportContext, OpenStudioImportContext.OpenStudioObjectLabel(standardOpaqueMaterial), false);
+ if (faceObject != null)
+ {
+ faceObjects.Add(faceObject);
+ }
+ }
+ }
+
+ if (faceObjects.Count < 2)
+ {
+ return (null, null);
+ }
+
+ faceObjects = faceObjects.OrderBy(x => x.Name, StringComparer.Ordinal).ToList();
+ FaceObject first = faceObjects[0];
+ string conflict = null;
+ if (faceObjects.Any(x => !Same(x.Material.thermalConductivity(), first.Material.thermalConductivity()) || !Same(x.Material.density(), first.Material.density()) || !Same(x.Material.specificHeat(), first.Material.specificHeat())))
+ {
+ conflict = "their thermal properties differ";
+ }
+ else if (faceObjects.Any(x => x.Signature != first.Signature))
+ {
+ conflict = "their authored face optics differ";
+ }
+
+ Dictionary edits = new Dictionary();
+ foreach (OpaqueMaterialParameter opaqueMaterialParameter in stampedOptics)
+ {
+ if (conflict != null)
+ {
+ break;
+ }
+
+ // Unedited objects resolve every face to the authored values, which agree (Signature); only edits can differ.
+ if (faceObjects.All(x => x.Edits.Count == 0))
+ {
+ break;
+ }
+
+ bool internalParameter = opaqueMaterialParameter.ToString().StartsWith("Internal", StringComparison.Ordinal);
+ List onFace = faceObjects.Where(x => x.Carries(opaqueMaterialParameter)).ToList();
+ List values = onFace.Select(x => x.Edits.TryGetValue(opaqueMaterialParameter, out double edit) ? edit : x.Authored.TryGetValue(opaqueMaterialParameter, out double authored) ? authored : (double?)null).ToList();
+ if (values.Any(x => x.HasValue != values[0].HasValue || (x.HasValue && !Same(x.Value, values[0].Value))))
+ {
+ conflict = opaqueMaterialParameter + " differs between the " + (internalParameter ? "internal" : "external") + "-face objects";
+ }
+ else if (onFace.Any(x => x.Edits.ContainsKey(opaqueMaterialParameter)))
+ {
+ edits[opaqueMaterialParameter] = values[0].Value;
+ }
+ }
+
+ if (conflict != null)
+ {
+ openStudioImportContext.AddDiagnostic(Core.OpenStudio.OpenStudioImportDiagnosticCodes.IdentityCollision, Core.OpenStudio.OpenStudioDiagnosticSeverity.Warning, string.Format("The face and thickness objects of source material {0} ({1}) are not merged: {2}; each is imported as its own material", materialGuid, string.Join(", ", faceObjects.Select(x => x.Name)), conflict), first.Label);
+ return (null, null);
+ }
+
+ // The identity carrier, else an external-face object, else the first by name; ties by thickness then name.
+ FaceObject primary = faceObjects.OrderByDescending(x => x.CarriesIdentity).ThenBy(x => x.Internal).ThenBy(x => x.Material.thickness()).ThenBy(x => x.Name, StringComparer.Ordinal).First();
+ OpaqueMaterial result = new OpaqueMaterial(openStudioImportContext.ResolveGuid(primary.Material), primary.Name, primary.Name, string.Empty, primary.Material.thermalConductivity(), primary.Material.density(), primary.Material.specificHeat());
+ result.SetValue(Core.MaterialParameter.DefaultThickness, primary.Material.thickness());
+ foreach (KeyValuePair keyValuePair in first.Authored)
+ {
+ result.SetValue(keyValuePair.Key, keyValuePair.Value);
+ }
+
+ ApplyEdits(result, faceObjects, edits, openStudioImportContext);
+ openStudioImportContext.RegisterCreated();
+ return (result, new HashSet(faceObjects.Select(x => Handle(x.Material)), StringComparer.Ordinal));
+ }
+
+ private static bool Same(double a, double b)
+ {
+ return Math.Abs(a - b) <= 1e-9 * Math.Max(1.0, Math.Max(Math.Abs(a), Math.Abs(b)));
+ }
+
///
/// MasslessOpaqueMaterial → SAM OpaqueMaterial. SAM has no massless material, so the
/// layer is reconstructed at a nominal thickness with conductivity = thickness / R: the
diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/Panel.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/Panel.cs
index 3bf8fb2..5319605 100644
--- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/Panel.cs
+++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Convert/ToSAM/Panel.cs
@@ -171,8 +171,9 @@ private static Construction ResolveConstruction(global::OpenStudio.Surface surfa
}
}
+ // A construction shared with subsurfaces or shading surfaces has its innermost-first surface version here.
Construction result;
- if (openStudioImportContext.ConstructionMap.TryGetValue(constructionName, out result))
+ if (openStudioImportContext.SurfaceConstructionMap.TryGetValue(constructionName, out result) || openStudioImportContext.ConstructionMap.TryGetValue(constructionName, out result))
{
return result;
}
diff --git a/SAM_OpenStudio/SAM.Core.OpenStudio/Query/GetDiagnosticDetail.cs b/SAM_OpenStudio/SAM.Core.OpenStudio/Query/GetDiagnosticDetail.cs
index 69b4936..13b7615 100644
--- a/SAM_OpenStudio/SAM.Core.OpenStudio/Query/GetDiagnosticDetail.cs
+++ b/SAM_OpenStudio/SAM.Core.OpenStudio/Query/GetDiagnosticDetail.cs
@@ -34,7 +34,7 @@ private static readonly (string Code, string MessagePrefix, DiagnosticDetail Det
///
///
/// - Summary: run and workflow outcomes and basis (RUN-001, SET-001, weather and design-day source and site
- /// overrides RUN-002), fallbacks and approximations (CON-001 simple-glazing fallback, SCH-001 leap-year profile, import
+ /// overrides RUN-002), fallbacks and approximations (CON-001 simple-glazing fallback, SCH-001 leap-year profile, IC-002 lighting split and default, MAT-004 inherited zone-face optics, import
/// APX-001), data that is not converted (MAT-002, CON-002, IC-001, HVAC-001, import LOAD-001,
/// WEA-001) and import translation / workflow notes.
/// - Standard: advisories with no effect on results (GEO-003 non-convex casting under PixelCounting, ADJ-001
@@ -61,6 +61,8 @@ public static DiagnosticDetail GetDiagnosticDetail(this OpenStudioDiagnostic ope
case OpenStudioDiagnosticCodes.MaterialUnsupportedParameter:
case OpenStudioDiagnosticCodes.ConstructionUnsupportedParameter:
case OpenStudioDiagnosticCodes.InternalConditionUnsupportedParameter:
+ case OpenStudioDiagnosticCodes.InternalConditionAssumption:
+ case OpenStudioDiagnosticCodes.MaterialFaceAssumption:
case OpenStudioDiagnosticCodes.HvacMissingSetpoints:
case OpenStudioImportDiagnosticCodes.OsmVersionTranslationFailed:
case OpenStudioImportDiagnosticCodes.OswEnergyPlusMeasureNotRepresentable:
diff --git a/SAM_OpenStudio/SAM.Core.OpenStudio/Variables/OpenStudioDiagnosticCodes.cs b/SAM_OpenStudio/SAM.Core.OpenStudio/Variables/OpenStudioDiagnosticCodes.cs
index 84cb861..0d74c1b 100644
--- a/SAM_OpenStudio/SAM.Core.OpenStudio/Variables/OpenStudioDiagnosticCodes.cs
+++ b/SAM_OpenStudio/SAM.Core.OpenStudio/Variables/OpenStudioDiagnosticCodes.cs
@@ -27,6 +27,12 @@ public static class OpenStudioDiagnosticCodes
/// Unsupported material parameter (e.g. blind flag without slat geometry).
public const string MaterialUnsupportedParameter = "SAM-OS-MAT-002";
+ /// Opaque face optics a construction use cannot represent: a single-layer construction on an outdoor or interzone surface whose two faces need different optical values (EnergyPlus materials are single-sided).
+ public const string MaterialFaceOpticsUnrepresentable = "SAM-OS-MAT-003";
+
+ /// Declared opaque face assumption: an internal-face optical value SAM does not state (absent or NaN) on a zone face takes the external value; its unspecified provenance is kept.
+ public const string MaterialFaceAssumption = "SAM-OS-MAT-004";
+
/// Missing construction layer.
public const string ConstructionMissingLayer = "SAM-OS-CON-001";
@@ -39,6 +45,9 @@ public static class OpenStudioDiagnosticCodes
/// Unsupported internal-condition parameter.
public const string InternalConditionUnsupportedParameter = "SAM-OS-IC-001";
+ /// Declared internal-condition conversion assumption or contract default applied (S07 §5.1 lighting split and its common default).
+ public const string InternalConditionAssumption = "SAM-OS-IC-002";
+
/// Conditioned zone missing setpoints, or HVAC-dependent data (emitter characteristics, exhaust flows) deferred to the detailed-HVAC programme.
public const string HvacMissingSetpoints = "SAM-OS-HVAC-001";
diff --git a/SAM_OpenStudio/SAM.Core.OpenStudio/Variables/OpenStudioIdentityKeys.cs b/SAM_OpenStudio/SAM.Core.OpenStudio/Variables/OpenStudioIdentityKeys.cs
index c3116ef..c102748 100644
--- a/SAM_OpenStudio/SAM.Core.OpenStudio/Variables/OpenStudioIdentityKeys.cs
+++ b/SAM_OpenStudio/SAM.Core.OpenStudio/Variables/OpenStudioIdentityKeys.cs
@@ -29,5 +29,40 @@ public static class OpenStudioIdentityKeys
/// Original, unsanitised SAM object name.
public const string Name = "SAM.Name";
+
+ ///
+ /// On every opaque material written from a SAM opaque material: the source SAM material Guid,
+ /// Guid.ToString("D"). Unlike it is written on every face object of the
+ /// material, so it never claims the object's own identity.
+ ///
+ public const string MaterialGuid = "SAM.MaterialGuid";
+
+ ///
+ /// On every opaque material written from a SAM opaque material: the SAM face whose optical values
+ /// the object's single EnergyPlus absorptance set carries, or
+ /// .
+ ///
+ public const string MaterialFace = "SAM.MaterialFace";
+
+ /// value: the external-face values.
+ public const string MaterialFaceExternal = "External";
+
+ /// value: the internal-face values.
+ public const string MaterialFaceInternal = "Internal";
+
+ ///
+ /// Prefix of the authored SAM opaque optical values carried on every face object of a material
+ /// (for example SAM.Optic.InternalEmissivity), invariant round-trip text. A value SAM does
+ /// not state is not written.
+ ///
+ public const string OpticPrefix = "SAM.Optic.";
+
+ ///
+ /// Prefix of the absorptances the writer actually put on an opaque face object (SAM.Written.ThermalAbsorptance,
+ /// .SolarAbsorptance, .VisibleAbsorptance), invariant round-trip text, including an inherited value
+ /// or an OpenStudio default. An import compares them with the object's current values, so an unchanged default is
+ /// told apart from a later edit.
+ ///
+ public const string WrittenPrefix = "SAM.Written.";
}
}
diff --git a/SAM_OpenStudio/SAM.Core.OpenStudio/Variables/OpenStudioImportDiagnosticCodes.cs b/SAM_OpenStudio/SAM.Core.OpenStudio/Variables/OpenStudioImportDiagnosticCodes.cs
index 885ec04..e994a5c 100644
--- a/SAM_OpenStudio/SAM.Core.OpenStudio/Variables/OpenStudioImportDiagnosticCodes.cs
+++ b/SAM_OpenStudio/SAM.Core.OpenStudio/Variables/OpenStudioImportDiagnosticCodes.cs
@@ -91,6 +91,13 @@ public static class OpenStudioImportDiagnosticCodes
/// Unsupported construction kind.
public const string ConstructionUnsupported = "SAM-OSI-CON-001";
+ ///
+ /// Error: an imported opaque surface construction cannot reproduce the native layer order or the absorptances of a
+ /// face EnergyPlus uses (an edited or mixed model whose orientation or face roles cannot be established); the import
+ /// is unsuccessful rather than silently changing those faces.
+ ///
+ public const string ConstructionFacesUnresolved = "SAM-OSI-CON-002";
+
/// Unsupported schedule kind, or a schedule that could not be expanded to hourly values.
public const string ScheduleUnsupported = "SAM-OSI-SCH-001";
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/Export.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/Export.cs
new file mode 100644
index 0000000..748d989
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/Export.cs
@@ -0,0 +1,120 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using SAM.Analytical.OpenStudio.InputReadBack;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Cli
+{
+ ///
+ /// The export step (writer side). It converts the frozen SAM model with the production writer and the explicit EPW,
+ /// saves the OSM without running the CLI, reloads the saved OSM and translates it to IDF with the OpenStudio SDK's
+ /// ForwardTranslator (the step the CLI performs before EnergyPlus). Nothing is simulated. It records hashes and
+ /// diagnostics; it never reads values back.
+ ///
+ internal static class Export
+ {
+ public const string ModelFile = "frozen-model.json";
+ public const string EpwFile = "weather.epw";
+ public const string OsmFile = "model.osm";
+ public const string IdfFile = "in.idf";
+ public const string ManifestFile = "export.json";
+
+ public static int Run(string modelPath, string epwPath, string outputDirectory, string label)
+ {
+ if (!File.Exists(modelPath) || !File.Exists(epwPath))
+ {
+ throw new FileNotFoundException("The frozen model and the EPW must exist", File.Exists(modelPath) ? epwPath : modelPath);
+ }
+
+ if (Directory.Exists(outputDirectory) && Directory.EnumerateFileSystemEntries(outputDirectory).Any())
+ {
+ throw new IOException("The export directory must be new or empty: " + outputDirectory);
+ }
+
+ Directory.CreateDirectory(outputDirectory);
+
+ // The run's inputs are byte copies of the frozen inputs, so the compare step can prove what was converted.
+ string modelCopy = Path.Combine(outputDirectory, ModelFile);
+ string epwCopy = Path.Combine(outputDirectory, EpwFile);
+ File.Copy(modelPath, modelCopy);
+ File.Copy(epwPath, epwCopy);
+
+ ExportManifest manifest = new ExportManifest
+ {
+ Label = label,
+ ModelSha256 = Hashing.Sha256(modelCopy),
+ EpwSha256 = Hashing.Sha256(epwCopy),
+ SourceModelPath = Path.GetFullPath(modelPath),
+ SourceEpwPath = Path.GetFullPath(epwPath),
+ ProcessId = Environment.ProcessId,
+ ProcessStartedUtc = ProcessIdentity.CurrentStartedUtc(),
+ StartedUtc = DateTime.UtcNow,
+ OpenStudioSdkVersion = global::OpenStudio.OpenStudioUtilitiesCore.openStudioLongVersion(),
+ WriterAssembly = typeof(Convert).Assembly.Location,
+ WriterAssemblySha256 = Hashing.Sha256(typeof(Convert).Assembly.Location)
+ };
+
+ AnalyticalModel analyticalModel = Core.Convert.ToSAM(modelCopy)?.FirstOrDefault();
+ if (analyticalModel == null)
+ {
+ throw new InvalidDataException("The frozen model could not be read: " + modelCopy);
+ }
+
+ string conversionDirectory = Path.Combine(outputDirectory, "conversion");
+ Core.OpenStudio.OpenStudioRunOptions runOptions = new Core.OpenStudio.OpenStudioRunOptions { UseUniqueRunDirectory = false };
+ using (OpenStudioConversionResult result = analyticalModel.ToOpenStudio(epwCopy, conversionDirectory, null, runOptions, run: false))
+ {
+ if (result?.OsmPath == null || !File.Exists(result.OsmPath))
+ {
+ throw new InvalidOperationException("The writer saved no OSM");
+ }
+
+ manifest.ConversionValid = result.IsValid;
+ foreach (Core.OpenStudio.OpenStudioDiagnostic diagnostic in result.Diagnostics)
+ {
+ manifest.Diagnostics.Add(string.Format("{0} {1} {2}", diagnostic.Severity, diagnostic.Code, diagnostic.Message));
+ }
+
+ File.Copy(result.OsmPath, Path.Combine(outputDirectory, OsmFile));
+ }
+
+ // The CLI route: load the saved OSM (not the in-memory model) and forward-translate it.
+ string osmPath = Path.Combine(outputDirectory, OsmFile);
+ global::OpenStudio.OptionalModel loaded = global::OpenStudio.Model.load(global::OpenStudio.OpenStudioUtilitiesCore.toPath(osmPath));
+ if (loaded == null || loaded.isNull())
+ {
+ throw new InvalidDataException("The saved OSM could not be loaded by the SDK");
+ }
+
+ global::OpenStudio.EnergyPlusForwardTranslator forwardTranslator = new global::OpenStudio.EnergyPlusForwardTranslator();
+ global::OpenStudio.Workspace workspace = forwardTranslator.translateModel(loaded.get());
+ foreach (global::OpenStudio.LogMessage message in forwardTranslator.errors())
+ {
+ manifest.TranslatorMessages.Add("Error " + message.logMessage());
+ }
+
+ foreach (global::OpenStudio.LogMessage message in forwardTranslator.warnings())
+ {
+ manifest.TranslatorMessages.Add("Warning " + message.logMessage());
+ }
+
+ string idfPath = Path.Combine(outputDirectory, IdfFile);
+ if (!workspace.toIdfFile().save(global::OpenStudio.OpenStudioUtilitiesCore.toPath(idfPath), true))
+ {
+ throw new IOException("The IDF could not be saved");
+ }
+
+ manifest.OsmSha256 = Hashing.Sha256(osmPath);
+ manifest.IdfSha256 = Hashing.Sha256(idfPath);
+ manifest.FinishedUtc = DateTime.UtcNow;
+ manifest.Save(Path.Combine(outputDirectory, ManifestFile));
+
+ Console.WriteLine("export: {0} (valid {1}, {2} diagnostics, {3} translator messages)", outputDirectory, manifest.ConversionValid, manifest.Diagnostics.Count, manifest.TranslatorMessages.Count);
+ return 0;
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/Negative.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/Negative.cs
new file mode 100644
index 0000000..f9e938b
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/Negative.cs
@@ -0,0 +1,429 @@
+// 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.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Cli
+{
+ ///
+ /// Disposable negative checks: a copy of an export with one targeted mutation of the native files, so the read and
+ /// compare steps can be shown to refuse it. The original run is never touched. The copy's export.json records the
+ /// mutation and the mutated hashes (the comparison judges the mutated files, not a hash mismatch).
+ ///
+ internal static class Negative
+ {
+ public static readonly string[] Cases =
+ {
+ "neg-identity-swap-names",
+ "neg-schedule-swap",
+ "neg-v1-unscheduled",
+ "neg-missing-dsoa-link",
+ "neg-missing-schedule-object",
+ "neg-people-density",
+ "neg-layer-conductivity",
+ "neg-osm-dsoa-schedule",
+ "neg-zone-multiplier",
+ "neg-window-multiplier",
+ "neg-north-axis",
+ "neg-zone-multiplier-both",
+ "neg-window-multiplier-both",
+ "neg-north-axis-both",
+ "neg-osm-missing-thermal-zone",
+ "neg-osm-missing-subsurfaces",
+ "neg-lighting-previous-mapping",
+ "neg-lighting-visible-zero",
+ "neg-lighting-radiant-is-p",
+ "neg-lighting-return-air",
+ "neg-lighting-swapped",
+ "neg-lighting-sum-above-one",
+ "neg-lighting-radiant-to-visible",
+ "neg-lighting-plenum-yes",
+ "neg-lighting-plenum-unknown",
+ "neg-lighting-plenum-relabelled",
+ "ctl-lighting-plenum-no"
+ };
+
+ public static int Run(string runDirectory, string caseId, string outputDirectory)
+ {
+ if (!Cases.Contains(caseId))
+ {
+ throw new ArgumentException("unknown case '" + caseId + "'; cases: " + string.Join(", ", Cases));
+ }
+
+ if (Directory.Exists(outputDirectory) && Directory.EnumerateFileSystemEntries(outputDirectory).Any())
+ {
+ throw new IOException("The negative directory must be new or empty: " + outputDirectory);
+ }
+
+ Directory.CreateDirectory(outputDirectory);
+ foreach (string file in new[] { Export.ModelFile, Export.EpwFile, Export.OsmFile, Export.IdfFile, Export.ManifestFile })
+ {
+ File.Copy(Path.Combine(runDirectory, file), Path.Combine(outputDirectory, file));
+ }
+
+ string idfPath = Path.Combine(outputDirectory, Export.IdfFile);
+ string osmPath = Path.Combine(outputDirectory, Export.OsmFile);
+ string idf = File.ReadAllText(idfPath);
+ string osm = File.ReadAllText(osmPath);
+ string description = Apply(caseId, ref idf, ref osm);
+ File.WriteAllText(idfPath, idf);
+ File.WriteAllText(osmPath, osm);
+
+ ExportManifest manifest = ExportManifest.Load(Path.Combine(outputDirectory, Export.ManifestFile));
+ manifest.Label = (manifest.Label ?? string.Empty) + " / " + caseId;
+ manifest.Mutation = caseId + ": " + description;
+ manifest.OsmSha256 = Hashing.Sha256(osmPath);
+ manifest.IdfSha256 = Hashing.Sha256(idfPath);
+ manifest.Save(Path.Combine(outputDirectory, Export.ManifestFile));
+ Console.WriteLine("negative {0}: {1}", caseId, description);
+ return 0;
+ }
+
+ internal static string Apply(string caseId, ref string idf, ref string osm)
+ {
+ List idfObjects = IdfText.Parse(idf);
+ switch (caseId)
+ {
+ case "neg-identity-swap-names":
+ {
+ // Two plain external walls of one space trade names (every reference follows): the geometry stays, the
+ // writer's GUID carrier now contradicts it.
+ HashSet hosts = new HashSet(idfObjects.Where(x => x.Type == "FenestrationSurface:Detailed").Select(x => x.Value(3)), StringComparer.OrdinalIgnoreCase);
+ IGrouping space = idfObjects.Where(x => x.Type == "BuildingSurface:Detailed" && x.Value(1) == "Wall" && x.Value(5) == "Outdoors" && !hosts.Contains(x.Name)).GroupBy(x => x.Value(4)).First(x => x.Count() >= 2);
+ string a = space.ElementAt(0).Name, b = space.ElementAt(1).Name;
+ idf = Regex.Replace(idf, Regex.Escape(a) + "|" + Regex.Escape(b), m => m.Value == a ? b : a);
+ return "IDF surfaces '" + a + "' and '" + b + "' trade names";
+ }
+
+ case "neg-schedule-swap":
+ {
+ IdfObject dsoa = idfObjects.First(x => x.Type == "DesignSpecification:OutdoorAir");
+ string occupancy = idfObjects.First(x => x.Type == "People").Value(2);
+ idf = SetField(idf, "DesignSpecification:OutdoorAir", dsoa.Name, "Outdoor Air Schedule Name", occupancy);
+ return "IDF DSOA '" + dsoa.Name + "' schedule -> the occupancy schedule '" + occupancy + "'";
+ }
+
+ case "neg-v1-unscheduled":
+ {
+ // The pre-S09 behaviour: the requirement delivered unscheduled (fraction 1), in both native views.
+ foreach (IdfObject dsoa in idfObjects.Where(x => x.Type == "DesignSpecification:OutdoorAir"))
+ {
+ idf = SetField(idf, "DesignSpecification:OutdoorAir", dsoa.Name, "Outdoor Air Schedule Name", string.Empty);
+ }
+
+ foreach (IdfObject dsoa in IdfText.Parse(osm).Where(x => x.Type == "OS:DesignSpecification:OutdoorAir"))
+ {
+ osm = SetField(osm, "OS:DesignSpecification:OutdoorAir", dsoa.Value(1), "Outdoor Air Flow Rate Fraction Schedule Name", string.Empty, true);
+ }
+
+ return "every DSOA fraction schedule removed in the IDF and the OSM (unscheduled delivery)";
+ }
+
+ case "neg-missing-dsoa-link":
+ {
+ IdfObject idealLoads = idfObjects.First(x => x.Type == "ZoneHVAC:IdealLoadsAirSystem");
+ idf = SetField(idf, "ZoneHVAC:IdealLoadsAirSystem", idealLoads.Name, "Design Specification Outdoor Air Object Name", string.Empty);
+ return "IDF Ideal Loads '" + idealLoads.Name + "' names no DSOA";
+ }
+
+ case "neg-missing-schedule-object":
+ {
+ string schedule = idfObjects.First(x => x.Type == "DesignSpecification:OutdoorAir").Value(6);
+ idf = SetField(idf, "Schedule:Compact", schedule, "Name", schedule + "_MISSING");
+ return "IDF schedule '" + schedule + "' renamed: the DSOA names an undefined schedule";
+ }
+
+ case "neg-people-density":
+ {
+ IdfObject people = idfObjects.First(x => x.Type == "People");
+ double value = double.Parse(people.Value(5), CultureInfo.InvariantCulture) * 1.01;
+ idf = SetField(idf, "People", people.Name, "People per Floor Area", value.ToString("R", CultureInfo.InvariantCulture));
+ return "IDF People '" + people.Name + "' density x 1.01";
+ }
+
+ case "neg-layer-conductivity":
+ {
+ IdfObject material = idfObjects.First(x => x.Type == "Material");
+ double value = double.Parse(material.Value(3), CultureInfo.InvariantCulture) * 1.001;
+ idf = SetField(idf, "Material", material.Name, "Conductivity", value.ToString("R", CultureInfo.InvariantCulture));
+ return "IDF Material '" + material.Name + "' conductivity x 1.001";
+ }
+
+ case "neg-osm-dsoa-schedule":
+ {
+ // OSM only: the SpaceType DSOA loses its schedule; the IDF is unchanged, so the two native views disagree.
+ IdfObject dsoa = IdfText.Parse(osm).First(x => x.Type == "OS:DesignSpecification:OutdoorAir");
+ osm = SetField(osm, "OS:DesignSpecification:OutdoorAir", dsoa.Value(1), "Outdoor Air Flow Rate Fraction Schedule Name", string.Empty, true);
+ return "OSM DSOA '" + dsoa.Value(1) + "' fraction schedule removed (IDF unchanged)";
+ }
+
+ // Multiplicity and orientation (independent review of d6d1cc5b). The plain cases are the reviewer's IDF-only
+ // mutations; the "-both" cases make the OSM agree, so only the check against SAM intent can refuse them.
+ case "neg-zone-multiplier":
+ case "neg-zone-multiplier-both":
+ {
+ IdfObject zone = idfObjects.First(x => x.Type == "Zone");
+ idf = SetField(idf, "Zone", zone.Name, "Multiplier", "2");
+ if (caseId.EndsWith("-both", StringComparison.Ordinal))
+ {
+ osm = SetField(osm, "OS:ThermalZone", zone.Name, "Multiplier", "2", true);
+ }
+
+ return "IDF Zone '" + zone.Name + "' Multiplier 1 -> 2" + (caseId.EndsWith("-both", StringComparison.Ordinal) ? " and OSM OS:ThermalZone Multiplier blank -> 2" : " (OSM unchanged)");
+ }
+
+ case "neg-window-multiplier":
+ case "neg-window-multiplier-both":
+ {
+ List names = idfObjects.Where(x => x.Type == "FenestrationSurface:Detailed").Select(x => x.Name).ToList();
+ foreach (string name in names)
+ {
+ idf = SetField(idf, "FenestrationSurface:Detailed", name, "Multiplier", "2");
+ if (caseId.EndsWith("-both", StringComparison.Ordinal))
+ {
+ osm = SetField(osm, "OS:SubSurface", name, "Multiplier", "2", true);
+ }
+ }
+
+ return "IDF FenestrationSurface:Detailed Multiplier blank -> 2 on " + names.Count + " subsurface(s)" + (caseId.EndsWith("-both", StringComparison.Ordinal) ? " and OSM OS:SubSurface Multiplier blank -> 2" : " (OSM unchanged)");
+ }
+
+ case "neg-north-axis":
+ case "neg-north-axis-both":
+ {
+ IdfObject building = idfObjects.First(x => x.Type == "Building");
+ idf = SetField(idf, "Building", building.Name, "North Axis", "90");
+ if (caseId.EndsWith("-both", StringComparison.Ordinal))
+ {
+ osm = SetField(osm, "OS:Building", IdfText.Parse(osm).First(x => x.Type == "OS:Building").Value(1), "North Axis", "90", true);
+ }
+
+ return "IDF Building '" + building.Name + "' North Axis blank -> 90" + (caseId.EndsWith("-both", StringComparison.Ordinal) ? " and OSM OS:Building North Axis blank -> 90" : " (OSM unchanged)");
+ }
+
+ // Missing OSM counterparts (independent review of 6f7e6b7d): the IDF is unchanged, so the IDF object has no
+ // OSM object to be consistent with. The reviewed head skipped the check and kept its verdict.
+ case "neg-osm-missing-thermal-zone":
+ {
+ string zone = idfObjects.First(x => x.Type == "Zone").Name;
+ osm = RemoveObject(osm, "OS:ThermalZone", zone, true);
+ return "OSM OS:ThermalZone '" + zone + "' removed (IDF unchanged)";
+ }
+
+ case "neg-osm-missing-subsurfaces":
+ {
+ List names = idfObjects.Where(x => x.Type == "FenestrationSurface:Detailed").Select(x => x.Name).ToList();
+ foreach (string name in names)
+ {
+ osm = RemoveObject(osm, "OS:SubSurface", name, true);
+ }
+
+ return "OSM OS:SubSurface removed for " + names.Count + " subsurface(s) (IDF unchanged)";
+ }
+
+ // S07 §5.1 T-LIGHT-SPLIT (amendment L1): the first IDF Lights object, whose written p = Fraction Radiant +
+ // Fraction Visible is positive on both fixtures. The OSM is unchanged; the lighting rows must refuse it.
+ case "neg-lighting-previous-mapping":
+ case "neg-lighting-visible-zero":
+ case "neg-lighting-radiant-is-p":
+ case "neg-lighting-return-air":
+ case "neg-lighting-swapped":
+ {
+ IdfObject lights = idfObjects.First(x => x.Type == "Lights");
+ string radiant = lights.Value(8), visible = lights.Value(9);
+ string p = (double.Parse(radiant, CultureInfo.InvariantCulture) + double.Parse(visible, CultureInfo.InvariantCulture)).ToString("R", CultureInfo.InvariantCulture);
+ switch (caseId)
+ {
+ case "neg-lighting-previous-mapping":
+ // The pre-L1 writer: Fraction Radiant = p and the view-coefficient default 0.25 as Fraction Visible.
+ idf = SetField(idf, "Lights", lights.Name, "Fraction Radiant", p);
+ idf = SetField(idf, "Lights", lights.Name, "Fraction Visible", "0.25");
+ return "IDF Lights '" + lights.Name + "' Fraction Radiant -> p = " + p + ", Fraction Visible -> 0.25 (the previous mapping)";
+ case "neg-lighting-visible-zero":
+ idf = SetField(idf, "Lights", lights.Name, "Fraction Visible", "0");
+ return "IDF Lights '" + lights.Name + "' Fraction Visible " + visible + " -> 0 with a positive authored p";
+ case "neg-lighting-radiant-is-p":
+ idf = SetField(idf, "Lights", lights.Name, "Fraction Radiant", p);
+ return "IDF Lights '" + lights.Name + "' Fraction Radiant -> p = " + p + " with Fraction Visible " + visible + " kept";
+ case "neg-lighting-return-air":
+ idf = SetField(idf, "Lights", lights.Name, "Return Air Fraction", "0.1");
+ return "IDF Lights '" + lights.Name + "' Return Air Fraction blank -> 0.1";
+ default:
+ idf = SetField(idf, "Lights", lights.Name, "Fraction Radiant", visible);
+ idf = SetField(idf, "Lights", lights.Name, "Fraction Visible", radiant);
+ return "IDF Lights '" + lights.Name + "' Fraction Radiant " + radiant + " and Fraction Visible " + visible + " swapped";
+ }
+ }
+
+ // Native-domain guards of T-LIGHT-SPLIT (independent review of 31af6022). Each shift is within the 0.001
+ // tolerance of every row, so only the domain guard can refuse it. They are invalid on the export they are
+ // meant for: sum-above-one on an authored p = 1 (sum 1.0008), radiant-to-visible on an authored p = 0
+ // (Fraction Radiant -0.0004). On the fixtures (p 0.34-0.41) both stay valid and are not refused.
+ case "neg-lighting-sum-above-one":
+ case "neg-lighting-radiant-to-visible":
+ {
+ IdfObject lights = idfObjects.First(x => x.Type == "Lights");
+ double radiant = double.Parse(lights.Value(8), CultureInfo.InvariantCulture), visible = double.Parse(lights.Value(9), CultureInfo.InvariantCulture);
+ double newRadiant = caseId == "neg-lighting-sum-above-one" ? radiant + 0.0004 : radiant - 0.0004;
+ double newVisible = visible + 0.0004;
+ idf = SetField(idf, "Lights", lights.Name, "Fraction Radiant", newRadiant.ToString("R", CultureInfo.InvariantCulture));
+ idf = SetField(idf, "Lights", lights.Name, "Fraction Visible", newVisible.ToString("R", CultureInfo.InvariantCulture));
+ return "IDF Lights '" + lights.Name + "' Fraction Radiant " + lights.Value(8) + " -> " + newRadiant.ToString("R", CultureInfo.InvariantCulture) + ", Fraction Visible " + lights.Value(9) + " -> " + newVisible.ToString("R", CultureInfo.InvariantCulture);
+ }
+
+ // The plenum-calculated return-air setting (index 12, after End-Use Subcategory), which the writer leaves blank (No). Yes, an unknown value
+ // and a relabelled field must block; an explicit No with coefficients is the valid control.
+ case "neg-lighting-plenum-yes":
+ case "neg-lighting-plenum-unknown":
+ case "neg-lighting-plenum-relabelled":
+ case "ctl-lighting-plenum-no":
+ {
+ IdfObject lights = idfObjects.First(x => x.Type == "Lights");
+ string setting = caseId == "neg-lighting-plenum-yes" ? "Yes" : caseId == "neg-lighting-plenum-unknown" ? "Maybe" : "No";
+ string label = caseId == "neg-lighting-plenum-relabelled" ? "Return Air Plenum Mode" : "Return Air Fraction Calculated from Plenum Temperature";
+ idf = AppendFields(idf, "Lights", lights.Name, (setting, label), ("0.2", "Return Air Fraction Function of Plenum Temperature Coefficient 1"), ("0", "Return Air Fraction Function of Plenum Temperature Coefficient 2"));
+ return "IDF Lights '" + lights.Name + "' gains '" + label + "' = " + setting + " with coefficients 0.2, 0";
+ }
+ }
+
+ throw new ArgumentException(caseId);
+ }
+
+ ///
+ /// Removes the object of named (located as in ),
+ /// from its class line through the line that ends it, with the blank line after it. Line-based: the rest of the
+ /// file is kept byte for byte.
+ ///
+ internal static string RemoveObject(string text, string type, string name, bool osm = false)
+ {
+ string newline = text.Contains("\r\n") ? "\r\n" : "\n";
+ List lines = text.Split(new[] { newline }, StringSplitOptions.None).ToList();
+ for (int i = 0; i < lines.Count; i++)
+ {
+ int nameLine = i + (osm ? 2 : 1);
+ if (!string.Equals(lines[i].Trim(), type + ",", StringComparison.OrdinalIgnoreCase) || nameLine >= lines.Count || !string.Equals(Data(lines[nameLine]), name, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ int end = i + 1;
+ while (end < lines.Count && !Code(lines[end]).TrimEnd().EndsWith(";", StringComparison.Ordinal))
+ {
+ end++;
+ }
+
+ if (end >= lines.Count)
+ {
+ break;
+ }
+
+ if (end + 1 < lines.Count && lines[end + 1].Trim().Length == 0)
+ {
+ end++;
+ }
+
+ lines.RemoveRange(i, end - i + 1);
+ return string.Join(newline, lines);
+ }
+
+ throw new InvalidOperationException(type + " '" + name + "' not found");
+ }
+
+ ///
+ /// Appends labelled fields after the last field of the object of named
+ /// (located as in ): its terminator becomes a comma and the new last
+ /// field ends the object. Line-based: the rest of the file is kept byte for byte.
+ ///
+ internal static string AppendFields(string text, string type, string name, params (string Value, string Label)[] fields)
+ {
+ string newline = text.Contains("\r\n") ? "\r\n" : "\n";
+ List lines = text.Split(new[] { newline }, StringSplitOptions.None).ToList();
+ for (int i = 0; i < lines.Count; i++)
+ {
+ if (!string.Equals(lines[i].Trim(), type + ",", StringComparison.OrdinalIgnoreCase) || i + 1 >= lines.Count || !string.Equals(Data(lines[i + 1]), name, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ int end = i + 1;
+ while (end < lines.Count && !Code(lines[end]).TrimEnd().EndsWith(";", StringComparison.Ordinal))
+ {
+ end++;
+ }
+
+ if (end >= lines.Count)
+ {
+ break;
+ }
+
+ int semicolon = Code(lines[end]).LastIndexOf(';');
+ lines[end] = lines[end].Substring(0, semicolon) + "," + lines[end].Substring(semicolon + 1);
+ lines.InsertRange(end + 1, fields.Select((x, k) => " " + x.Value + (k == fields.Length - 1 ? ";" : ",") + " !- " + x.Label));
+ return string.Join(newline, lines);
+ }
+
+ throw new InvalidOperationException(type + " '" + name + "' not found");
+ }
+
+ private static string Code(string line)
+ {
+ int bang = line.IndexOf('!');
+ return bang >= 0 ? line.Substring(0, bang) : line;
+ }
+
+ ///
+ /// Replaces the value of the field labelled in the object of named
+ /// (the first field, or the second in an OSM where the handle comes first). Line-based:
+ /// the file's formatting is kept.
+ ///
+ internal static string SetField(string text, string type, string name, string label, string value, bool osm = false)
+ {
+ string newline = text.Contains("\r\n") ? "\r\n" : "\n";
+ List lines = text.Split(new[] { newline }, StringSplitOptions.None).ToList();
+ for (int i = 0; i < lines.Count; i++)
+ {
+ if (!string.Equals(lines[i].Trim(), type + ",", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ int nameLine = i + (osm ? 2 : 1);
+ if (nameLine >= lines.Count || !string.Equals(Data(lines[nameLine]), name, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ for (int j = i + 1; j < lines.Count; j++)
+ {
+ int bang = lines[j].IndexOf("!-", StringComparison.Ordinal);
+ if (bang >= 0 && lines[j].Substring(bang + 2).Trim().StartsWith(label, StringComparison.OrdinalIgnoreCase))
+ {
+ string data = lines[j].Substring(0, bang).TrimEnd();
+ char terminator = data[data.Length - 1];
+ string indent = lines[j].Substring(0, lines[j].Length - lines[j].TrimStart().Length);
+ lines[j] = (indent + value + terminator).PadRight(bang) + lines[j].Substring(bang);
+ return string.Join(newline, lines);
+ }
+
+ if (lines[j].Contains(";"))
+ {
+ break;
+ }
+ }
+ }
+
+ throw new InvalidOperationException("field '" + label + "' of " + type + " '" + name + "' not found");
+ }
+
+ private static string Data(string line)
+ {
+ int bang = line.IndexOf('!');
+ return (bang >= 0 ? line.Substring(0, bang) : line).Trim().TrimEnd(',', ';').Trim();
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/Program.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/Program.cs
new file mode 100644
index 0000000..c30b636
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/Program.cs
@@ -0,0 +1,98 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using System;
+using System.Collections.Generic;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Cli
+{
+ ///
+ /// os-input-readback: the S09 OpenStudio native-input read-back harness.
+ ///
+ /// - export --model <frozen.json> --epw <file.epw> --out <new dir> [--label x]
+ /// - read --run <export dir> (a separate process: reads model.osm and in.idf as text into native.json)
+ /// - compare --run <export dir> --fixture-id <id> [--rule-set interim|provisional|approved] [--out dir]
+ ///
+ /// Exit codes follow S08: 0 PASS, 10 FAIL, 11 BLOCKED technical, 12 BLOCKED owner, 13 NOT RUN, 14 accepted
+ /// limitation, 64 usage, 70 infrastructure.
+ ///
+ public static class Program
+ {
+ public static int Main(string[] args)
+ {
+ if (args == null || args.Length == 0)
+ {
+ return Usage();
+ }
+
+ Dictionary options = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (int i = 1; i + 1 < args.Length; i += 2)
+ {
+ if (!args[i].StartsWith("--", StringComparison.Ordinal))
+ {
+ return Usage();
+ }
+
+ options[args[i].Substring(2)] = args[i + 1];
+ }
+
+ try
+ {
+ switch (args[0].ToLowerInvariant())
+ {
+ case "export":
+ return Export.Run(Required(options, "model"), Required(options, "epw"), Required(options, "out"), Optional(options, "label"));
+ case "read":
+ return ReadBackRead.Run(Required(options, "run"));
+ case "negative":
+ return Negative.Run(Required(options, "run"), Required(options, "case"), Required(options, "out"));
+ case "compare":
+ return ReadBackCompare.Run(Required(options, "run"), Required(options, "fixture-id"), Optional(options, "rule-set") ?? "approved", Optional(options, "out"));
+ default:
+ return Usage();
+ }
+ }
+ catch (UsageException exception)
+ {
+ Console.Error.WriteLine(exception.Message);
+ return Usage();
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine("infrastructure: " + exception);
+ return ReadBackExit.Infrastructure;
+ }
+ }
+
+ private static string Required(Dictionary options, string name)
+ {
+ if (!options.TryGetValue(name, out string value) || string.IsNullOrWhiteSpace(value))
+ {
+ throw new UsageException("missing --" + name);
+ }
+
+ return value;
+ }
+
+ private static string Optional(Dictionary options, string name)
+ {
+ return options.TryGetValue(name, out string value) ? value : null;
+ }
+
+ private static int Usage()
+ {
+ Console.Error.WriteLine("usage: os-input-readback export --model --epw --out [--label x]");
+ Console.Error.WriteLine(" os-input-readback read --run ");
+ Console.Error.WriteLine(" os-input-readback compare --run --fixture-id [--rule-set interim|provisional|approved] [--out dir]");
+ Console.Error.WriteLine(" os-input-readback negative --run --case <" + string.Join("|", Negative.Cases) + "> --out ");
+ return ReadBackExit.Usage;
+ }
+
+ private sealed class UsageException : Exception
+ {
+ public UsageException(string message) : base(message)
+ {
+ }
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/ReadBackRead.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/ReadBackRead.cs
new file mode 100644
index 0000000..76bbb16
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/ReadBackRead.cs
@@ -0,0 +1,142 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using SAM.Analytical.InputEquivalence;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Cli
+{
+ /// The read step: a separate process that reads model.osm and in.idf as text into native.json.
+ internal static class ReadBackRead
+ {
+ public const string SnapshotFile = "native.json";
+
+ public static int Run(string runDirectory)
+ {
+ string osm = Path.Combine(runDirectory, Export.OsmFile);
+ string idf = Path.Combine(runDirectory, Export.IdfFile);
+ NativeSnapshot snapshot = NativeReader.Read(osm, idf);
+ snapshot.Save(Path.Combine(runDirectory, SnapshotFile));
+ Console.WriteLine("read: {0} IDF objects, {1} OSM objects, {2} IDF schedules ({3} unreadable), {4} OSM schedules; pid {5}", snapshot.Idf.Count, snapshot.Osm.Count, snapshot.IdfSchedules.Count, snapshot.IdfSchedules.Count(x => x.Values == null), snapshot.OsmSchedules.Count, snapshot.ProcessId);
+ return 0;
+ }
+ }
+
+ /// The compare step: SAM intent from the frozen model against native.json, gated by the S08 core.
+ internal static class ReadBackCompare
+ {
+ public static int Run(string runDirectory, string fixtureId, string ruleSetName, string outputDirectory)
+ {
+ GateRuleSet ruleSet;
+ switch ((ruleSetName ?? "approved").ToLowerInvariant())
+ {
+ case "approved":
+ ruleSet = GateRuleSet.Approved;
+ break;
+ case "provisional":
+ ruleSet = GateRuleSet.ProvisionalProposed;
+ break;
+ case "interim":
+ ruleSet = GateRuleSet.Interim;
+ break;
+ default:
+ Console.Error.WriteLine("unknown rule set: " + ruleSetName);
+ return ReadBackExit.Usage;
+ }
+
+ ExportManifest manifest = ExportManifest.Load(Path.Combine(runDirectory, Export.ManifestFile));
+ NativeSnapshot snapshot = NativeSnapshot.Load(Path.Combine(runDirectory, ReadBackRead.SnapshotFile));
+ string modelPath = Path.Combine(runDirectory, Export.ModelFile);
+ string epwPath = Path.Combine(runDirectory, Export.EpwFile);
+
+ List problems = Infrastructure(manifest, snapshot, runDirectory, modelPath, epwPath);
+ if (problems.Count > 0)
+ {
+ foreach (string problem in problems)
+ {
+ Console.Error.WriteLine("infrastructure: " + problem);
+ }
+
+ return ReadBackExit.Infrastructure;
+ }
+
+ AnalyticalModel model = Core.Convert.ToSAM(modelPath)?.FirstOrDefault();
+ OsComparison comparison = OsComparison.Run(model, snapshot, epwPath, fixtureId, ruleSet);
+ Dictionary inputs = new Dictionary
+ {
+ ["label"] = manifest.Label,
+ ["modelSha256"] = manifest.ModelSha256,
+ ["epwSha256"] = manifest.EpwSha256,
+ ["osmSha256"] = manifest.OsmSha256,
+ ["idfSha256"] = manifest.IdfSha256,
+ ["openStudioSdk"] = manifest.OpenStudioSdkVersion,
+ ["writerSha256"] = manifest.WriterAssemblySha256,
+ ["readerSha256"] = snapshot.ReaderAssemblySha256,
+ ["exportPid"] = manifest.ProcessId.ToString(),
+ ["readerPid"] = snapshot.ProcessId.ToString(),
+ ["comparePid"] = Environment.ProcessId.ToString(),
+ ["exportProcessStartedUtc"] = manifest.ProcessStartedUtc.ToString("O", CultureInfo.InvariantCulture),
+ ["readerProcessStartedUtc"] = snapshot.ProcessStartedUtc.ToString("O", CultureInfo.InvariantCulture),
+ ["compareProcessStartedUtc"] = ProcessIdentity.CurrentStartedUtc().ToString("O", CultureInfo.InvariantCulture),
+ ["mutation"] = manifest.Mutation,
+ ["writerDiagnostics"] = string.Join(" | ", manifest.Diagnostics.Where(x => !x.StartsWith("Information SAM-OS-MAT-002 Vapour", StringComparison.Ordinal))),
+ ["translatorMessages"] = manifest.TranslatorMessages.Count.ToString()
+ };
+
+ OsReport report = OsReport.From(comparison, inputs);
+ report.Save(outputDirectory ?? Path.Combine(runDirectory, "compare-" + ruleSetName));
+ Console.Write(report.Text());
+ return report.ExitCode;
+ }
+
+ ///
+ /// The run is what it claims (as S08): the reader ran in its own process after the export finished; the OSM and IDF
+ /// it read are the exported ones and were not changed while it read; the frozen model and EPW are the exported bytes.
+ ///
+ private static List Infrastructure(ExportManifest manifest, NativeSnapshot snapshot, string runDirectory, string modelPath, string epwPath)
+ {
+ List result = new List();
+ if (snapshot.Schema != "s09-native/2" || manifest.Schema != "s09-export/2")
+ {
+ result.Add("unknown export or snapshot schema");
+ }
+
+ // A process is its id and its creation time: Windows reuses the id of an exited process, so a fresh reader can
+ // carry the export's id, and a fresh comparison the reader's (ProcessIdentity).
+ if (!ProcessIdentity.IsRecorded(manifest.ProcessId, manifest.ProcessStartedUtc) || !ProcessIdentity.IsRecorded(snapshot.ProcessId, snapshot.ProcessStartedUtc))
+ {
+ result.Add("the export or reader process identity is not recorded: a fresh read is not proven");
+ }
+ else if (ProcessIdentity.Same(snapshot.ProcessId, snapshot.ProcessStartedUtc, manifest.ProcessId, manifest.ProcessStartedUtc) || snapshot.StartedUtc <= manifest.FinishedUtc)
+ {
+ result.Add("the reader did not run in a fresh process after the export");
+ }
+
+ if (ProcessIdentity.Same(snapshot.ProcessId, snapshot.ProcessStartedUtc, Environment.ProcessId, ProcessIdentity.CurrentStartedUtc()))
+ {
+ result.Add("the comparison runs in the reader's process");
+ }
+
+ if (snapshot.OsmSha256Before != manifest.OsmSha256 || snapshot.OsmSha256After != manifest.OsmSha256 || Hashing.Sha256(Path.Combine(runDirectory, Export.OsmFile)) != manifest.OsmSha256)
+ {
+ result.Add("the OSM read is not the exported OSM, or it changed");
+ }
+
+ if (snapshot.IdfSha256Before != manifest.IdfSha256 || snapshot.IdfSha256After != manifest.IdfSha256 || Hashing.Sha256(Path.Combine(runDirectory, Export.IdfFile)) != manifest.IdfSha256)
+ {
+ result.Add("the IDF read is not the exported IDF, or it changed");
+ }
+
+ if (Hashing.Sha256(modelPath) != manifest.ModelSha256 || Hashing.Sha256(epwPath) != manifest.EpwSha256)
+ {
+ result.Add("the frozen model or EPW is not the exported one");
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/SAM.Analytical.OpenStudio.InputReadBack.Cli.csproj b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/SAM.Analytical.OpenStudio.InputReadBack.Cli.csproj
new file mode 100644
index 0000000..da95c77
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Cli/SAM.Analytical.OpenStudio.InputReadBack.Cli.csproj
@@ -0,0 +1,56 @@
+
+
+
+ Exe
+ net8.0
+ SAM.Analytical.OpenStudio.InputReadBack.Cli
+ os-input-readback
+ false
+ x64
+ x64
+ x64
+ latest
+ disable
+ disable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ..\..\..\SAM\build\SAM.Core.dll
+
+
+ ..\..\..\SAM\build\SAM.Geometry.dll
+
+
+ ..\..\..\SAM\build\SAM.Analytical.dll
+
+
+ ..\..\..\SAM\build\SAM.Architectural.dll
+
+
+ ..\..\..\SAM\build\SAM.Weather.dll
+
+
+
+
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/NativeReaderTests.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/NativeReaderTests.cs
new file mode 100644
index 0000000..dfa290e
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/NativeReaderTests.cs
@@ -0,0 +1,70 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using NUnit.Framework;
+using System.Linq;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Tests
+{
+ /// The text reader and the schedule expansion: unknown layouts and forms are unreadable, never guessed.
+ [TestFixture]
+ public class NativeReaderTests
+ {
+ private const string Idf =
+ "Zone,\n Z1, !- Name\n , !- Direction of Relative North {deg}\n 1, 2, 3; !- Z Origin {m}\n\n" +
+ "BuildingSurface:Detailed,\n S1, !- Name\n Wall, !- Surface Type\n 0, 0, 0, !- X,Y,Z Vertex 1 {m}\n 1, 0, 0; !- X,Y,Z Vertex 2 {m}\n";
+
+ [Test]
+ public void Parse_KeepsFieldsAndTheFileLabels()
+ {
+ IdfObject zone = IdfText.Parse(Idf).First();
+ Assert.That(zone.Type, Is.EqualTo("Zone"));
+ Assert.That(zone.Name, Is.EqualTo("Z1"));
+ Assert.That(zone.Fields.Select(x => x.Value), Is.EqualTo(new[] { "Z1", string.Empty, "1", "2", "3" }));
+ Assert.That(zone.Fields[4].Label, Is.EqualTo("Z Origin {m}"), "a label describes the last field on its line");
+ Assert.That(zone.Value(1, "Direction of Relative North", out string problem), Is.EqualTo(string.Empty));
+ Assert.That(problem, Is.Null);
+ }
+
+ [Test]
+ public void AFieldWhoseLabelDisagrees_IsUnreadable()
+ {
+ IdfObject zone = IdfText.Parse(Idf).First();
+ Assert.That(zone.Value(1, "Multiplier", out string problem), Is.Null);
+ Assert.That(problem, Does.Contain("labelled 'Direction of Relative North"));
+ Assert.That(NativeModel.Number(zone, 1, "Multiplier").Kind, Is.EqualTo(SAM.Analytical.InputEquivalence.ReaderKind.Unreadable));
+ Assert.That(NativeModel.Number(zone, 1, "Direction of Relative North").Kind, Is.EqualTo(SAM.Analytical.InputEquivalence.ReaderKind.Absent), "blank is absent (EnergyPlus default), not zero");
+ }
+
+ private static NativeSchedule Compact(string body)
+ {
+ return ScheduleReader.Read(IdfText.Parse("Schedule:Compact,\n S, !- Name\n F, !- Schedule Type Limits Name\n" + body).Single(), 365, "idf");
+ }
+
+ [Test]
+ public void Compact_UntilLabelsEndTheirHour_AndCoverTheYear()
+ {
+ NativeSchedule schedule = Compact(" Through: 06/30, For: AllDays, Until: 07:00, 0.1, Until: 24:00, 0.9,\n Through: 12/31, For: AllDays, Until: 24:00, 0.5;\n");
+ Assert.That(schedule.Problem, Is.Null);
+ Assert.That(schedule.Values.Length, Is.EqualTo(8760));
+ Assert.That(schedule.Values[6], Is.EqualTo(0.1), "hour index 6 = 06:00-07:00, ended by 'Until: 07:00'");
+ Assert.That(schedule.Values[7], Is.EqualTo(0.9));
+ Assert.That(schedule.Values[181 * 24], Is.EqualTo(0.5), "1 July (day index 181)");
+ Assert.That(schedule.Values[181 * 24 - 1], Is.EqualTo(0.9), "30 June 23:00");
+ Assert.That(schedule.AlignmentVerified, Is.True);
+ }
+
+ [TestCase(" Through: 12/31, For: Weekdays, Until: 24:00, 1;\n", "For: AllDays")]
+ [TestCase(" Through: 12/31, For: AllDays, Until: 07:30, 1, Until: 24:00, 0;\n", "whole hour")]
+ [TestCase(" Through: 12/30, For: AllDays, Until: 24:00, 1;\n", "covers 364 of 365")]
+ [TestCase(" Through: 12/31, For: AllDays, Until: 12:00, 1;\n", "not 24:00")]
+ [TestCase(" Through: 12/31, For: AllDays, Until: 24:00, x;\n", "not a finite number")]
+ public void Compact_AnyOtherForm_IsUnreadable(string body, string reason)
+ {
+ NativeSchedule schedule = Compact(body);
+ Assert.That(schedule.Values, Is.Null);
+ Assert.That(schedule.AlignmentVerified, Is.False);
+ Assert.That(schedule.Problem, Does.Contain(reason));
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/OpaqueFaceScopeTests.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/OpaqueFaceScopeTests.cs
new file mode 100644
index 0000000..d63c0cf
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/OpaqueFaceScopeTests.cs
@@ -0,0 +1,490 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using NUnit.Framework;
+using SAM.Analytical.InputEquivalence;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using EQ = SAM.Analytical.InputEquivalence.Query;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Tests
+{
+ ///
+ /// S07 §5.2 (amendment U1, SAM#218 b06dbe8c): row-3a opaque optics on faces EnergyPlus 25.1 does not use are
+ /// OUT-OF-ENGINE-SCOPE, informational and not gated, but only when the face's role is established independently and
+ /// both values are valid and comparable. Every used face, every thermal layer property and every invalid, unreadable
+ /// or undeterminable row stays gated, and an informational row never hides a defect elsewhere. Each case runs the CLI
+ /// (export, fresh-process read and compare) and checks its report and exit code.
+ ///
+ public partial class ReadBackTests
+ {
+ private static readonly string[] OpticalProperties = { "externalSolarReflectance", "internalSolarReflectance", "externalLightReflectance", "internalLightReflectance", "externalEmissivity", "internalEmissivity" };
+
+ ///
+ /// Every row-3a optical key on a face EnergyPlus does not use, with its scope, derived here from the frozen fixture
+ /// alone (GetSpaces() order, adjacency, the adiabatic flag and the panel type), never from the writer or from S09.
+ /// SAM lists a construction inside → outside from its first space; EnergyPlus lists it outside first.
+ ///
+ private static Dictionary UnusedFaces(AnalyticalModel model)
+ {
+ AdjacencyCluster cluster = model.AdjacencyCluster;
+ List spaces = cluster.GetSpaces();
+ Dictionary result = new Dictionary(StringComparer.Ordinal);
+ foreach (Panel panel in cluster.GetPanels())
+ {
+ List related = cluster.GetRelatedObjects(panel).OrderBy(x => spaces.FindIndex(s => s.Guid == x.Guid)).ToList();
+ bool adiabatic = panel.TryGetValue(PanelParameter.Adiabatic, out bool flag) && flag;
+ bool ground = panel.PanelType == PanelType.SlabOnGrade || panel.PanelType == PanelType.UndergroundSlab || panel.PanelType == PanelType.UndergroundWall || panel.PanelType == PanelType.UndergroundCeiling;
+ bool interzone = related.Count == 2;
+ int count = panel.Construction.ConstructionLayers.Count;
+ for (int i = 0; i < related.Count; i++)
+ {
+ string side = "panel:" + panel.Guid.ToString("D") + "@space:" + related[i].Guid.ToString("D");
+ for (int k = 1; k <= count; k++)
+ {
+ foreach (string face in new[] { "internal", "external" })
+ {
+ bool towardsZone = (face == "internal") == (i == 0);
+ bool zoneFace = k == count && towardsZone;
+ bool outerFace = k == 1 && !towardsZone;
+ if (zoneFace || (outerFace && !interzone && !adiabatic && !ground))
+ {
+ continue;
+ }
+
+ string scope = !outerFace ? "buried layer face" : interzone ? "interzone surface outer face" : adiabatic ? "adiabatic surface outer face" : "ground surface outer face";
+ foreach (string optic in Optics)
+ {
+ result.Add(side + "#layer" + k + "." + face + optic, scope);
+ }
+ }
+ }
+ }
+ }
+
+ return result;
+ }
+
+ /// A SAM opaque material's authored face value, from the frozen model.
+ private static double SamFace(AnalyticalModel model, string materialName, string property)
+ {
+ Core.OpaqueMaterial material = model.MaterialLibrary.GetMaterials().OfType().Single(x => x.Name == materialName);
+ OpaqueMaterialParameter parameter = (OpaqueMaterialParameter)Enum.Parse(typeof(OpaqueMaterialParameter), char.ToUpperInvariant(property[0]) + property.Substring(1));
+ Assert.That(material.TryGetValue(parameter, out double value), Is.True, materialName + " " + property);
+ return value;
+ }
+
+ private static string OtherFace(string property)
+ {
+ return property.StartsWith("internal", StringComparison.Ordinal) ? "external" + property.Substring("internal".Length) : "internal" + property.Substring("external".Length);
+ }
+
+ private static string SideKey(Panel panel, Space space) => "panel:" + panel.Guid.ToString("D") + "@space:" + space.Guid.ToString("D");
+
+ private static AnalyticalModel WithMaterial(string fixtureId, string materialName, Action change)
+ {
+ AnalyticalModel model = TestSupport.Load(fixtureId);
+ Core.MaterialLibrary materialLibrary = model.MaterialLibrary;
+ Core.OpaqueMaterial material = materialLibrary.GetMaterials().OfType().Single(x => x.Name == materialName);
+ change(material);
+ materialLibrary.Add(material);
+ return new AnalyticalModel(model, model.AdjacencyCluster, materialLibrary, model.ProfileLibrary);
+ }
+
+ private static AnalyticalModel WithAdiabaticPanel(string fixtureId, Func select, out Panel selected)
+ {
+ AnalyticalModel model = TestSupport.Load(fixtureId);
+ AdjacencyCluster cluster = model.AdjacencyCluster;
+ Panel panel = Analytical.Create.Panel(select(cluster));
+ Assert.That(panel.SetValue(PanelParameter.Adiabatic, true), Is.True);
+ Assert.That(cluster.AddObject(panel), Is.True);
+ selected = panel;
+ return new AnalyticalModel(model, cluster);
+ }
+
+ private static string Text(string run) => File.ReadAllText(Path.Combine(run, "compare-approved", "report.txt"));
+
+ // ---- both fixtures ------------------------------------------------------------------------------------------------
+
+ ///
+ /// Every unused-face row, and only those, is OUT-OF-ENGINE-SCOPE, with its authored value, its native value, the
+ /// difference, side, layer, scope and the amendment. The rows that differ are exactly the single-sided difference
+ /// of revisions 8-11 (the SAM face differs from its other face and the native holds that other face): 69 / 138,
+ /// still visible. Equal-valued unused rows qualify too, so the total is larger and is not forced to 69 / 138.
+ ///
+ [TestCase(TestSupport.Box, 69)]
+ [TestCase(TestSupport.Two, 138)]
+ public void EveryUnusedFaceRow_IsOutOfEngineScope_WithBothValuesAndItsReason(string fixtureId, int historicalDiffering)
+ {
+ OsReport report = fixtureId == TestSupport.Box ? box : two;
+ string run = fixtureId == TestSupport.Box ? boxRun : twoRun;
+ AnalyticalModel model = TestSupport.Load(fixtureId);
+ Dictionary unused = UnusedFaces(model);
+ Dictionary rows = report.OutOfEngineScope.Rows.ToDictionary(x => x.Key, StringComparer.Ordinal);
+ Assert.That(rows.Keys.OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(unused.Keys.OrderBy(x => x, StringComparer.Ordinal)), "exactly the independently derived unused faces");
+ Assert.That(report.OutOfEngineScope.Count, Is.EqualTo(unused.Count));
+ Assert.That(report.OutOfEngineScope.Amendment, Is.EqualTo(OsComparison.OpaqueFaceScope));
+ Assert.That(report.OutOfEngineScope.CountByScope, Is.EquivalentTo(unused.GroupBy(x => x.Value).ToDictionary(x => x.Key, x => x.Count())));
+ Assert.That(report.OutOfEngineScope.CountByScope.Keys, Has.Member(fixtureId == TestSupport.Two ? "interzone surface outer face" : "ground surface outer face"));
+
+ ToleranceRule tolerance = QuantityCatalog.Get("3a").Tolerance;
+ Dictionary results = LayerResults(run, fixtureId);
+ List singleSided = new List();
+ foreach (OutOfEngineScopeRow row in rows.Values)
+ {
+ Assert.That(row.Quantity, Is.EqualTo("3a"));
+ Assert.That(row.Amendment, Is.EqualTo(OsComparison.OpaqueFaceScope));
+ Assert.That(row.Key, Is.EqualTo(row.Side + "#layer" + row.Layer + "." + row.Property));
+ Assert.That(row.Property, Does.StartWith(row.SamFace));
+ Assert.That(row.ScopeReason, Does.StartWith(unused[row.Key]), row.Key);
+ Assert.That(row.SamValue, Is.EqualTo(SamFace(model, row.SamMaterial, row.Property)), row.Key + ": the authored value, as the frozen model states it");
+ Assert.That(row.Delta, Is.EqualTo(Math.Abs(row.NativeValue - row.SamValue)), row.Key);
+ Assert.That(row.Differs, Is.EqualTo(!EQ.Compare(row.SamValue, row.NativeValue, tolerance).IsWithin), row.Key);
+ Assert.That(row.NativeObject, Does.Contain(row.SamMaterial), row.Key);
+
+ QuantityResult result = results[row.Key];
+ Assert.That(result.GatesFixture, Is.False, row.Key + ": informational");
+ Assert.That(result.Input.Native.Value, Is.EqualTo(row.NativeValue), row.Key);
+ Assert.That(result.Reason, Does.StartWith("OUT-OF-ENGINE-SCOPE (" + OsComparison.OpaqueFaceScope + ")"), row.Key);
+
+ double other = SamFace(model, row.SamMaterial, OtherFace(row.Property));
+ if (!EQ.Compare(other, row.SamValue, tolerance).IsWithin && EQ.Compare(other, row.NativeValue, tolerance).IsWithin)
+ {
+ singleSided.Add(row.Key);
+ }
+ }
+
+ Assert.That(rows.Values.Where(x => x.Differs).Select(x => x.Key).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(singleSided.OrderBy(x => x, StringComparer.Ordinal)), "every differing row is the single-sided difference");
+ Assert.That(report.OutOfEngineScope.Differing, Is.EqualTo(historicalDiffering), "the rows MAPPING-DIFFERENCE named in revisions 8-11 stay visible");
+ Assert.That(report.OutOfEngineScope.Equal, Is.EqualTo(report.OutOfEngineScope.Count - historicalDiffering));
+ Assert.That(report.OutOfEngineScope.Equal, Is.GreaterThan(0), "equal-valued unused faces are valid and comparable, so they are in scope too");
+ Assert.That(report.NonPass.Select(x => x.Key).Intersect(rows.Keys), Is.Empty);
+
+ string text = Text(run);
+ Assert.That(text, Does.Contain("; gated non-PASS 0; out-of-engine-scope " + report.OutOfEngineScope.Count + " (informational, not gated)"));
+ Assert.That(text, Does.Contain("OUT-OF-ENGINE-SCOPE (informational, not gated; " + OsComparison.OpaqueFaceScope + "): " + report.OutOfEngineScope.Count + " row(s), " + historicalDiffering + " differing, " + report.OutOfEngineScope.Equal + " equal"));
+ Assert.That(text, Does.Contain(OsComparison.OutOfEngineScopeOutcome + " " + report.OutOfEngineScope.Count));
+ Assert.That(text, Does.Not.Contain("NOT PASS"));
+ }
+
+ // ---- used faces stay gated -----------------------------------------------------------------------------------------
+
+ ///
+ /// The outdoor face: the outermost layer's absorptance changed natively. Exactly the outdoor-face rows of the sides
+ /// with that layer outermost FAIL; the same object's buried face stays OUT-OF-ENGINE-SCOPE with the new value
+ /// (U1 does not require it to equal the other face), and it cannot mask the failure.
+ ///
+ [Test]
+ public void AWrongOutdoorFaceValue_Fails_WhileTheSameLayersBuriedFaceStaysInformational()
+ {
+ // An outdoor side: its outermost layer's outer face is used, so only the inner face of that layer is in scope.
+ HashSet keys = new HashSet(box.OutOfEngineScope.Rows.Select(x => x.Key), StringComparer.Ordinal);
+ bool Outdoor(OutOfEngineScopeRow x) => x.Layer == 1 && x.Property == "internalSolarReflectance" && !keys.Contains(x.Side + "#layer1.externalSolarReflectance");
+ OutOfEngineScopeRow buried = box.OutOfEngineScope.Rows.First(Outdoor);
+ List sides = box.OutOfEngineScope.Rows.Where(x => Outdoor(x) && x.NativeObject == buried.NativeObject).Select(x => x.Side).ToList();
+ OsReport report = MutatedIdf(boxRun, TestSupport.Box, "u1-outdoor-face", (idf, model) => Cli.Negative.SetField(idf, "Material", buried.NativeObject, "Solar Absorptance", "0.5"));
+
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.Fail), report.Status);
+ List failed = report.NonPass.Where(x => x.Status == "Fail").Select(x => x.Key).OrderBy(x => x, StringComparer.Ordinal).ToList();
+ Assert.That(failed, Is.EqualTo(sides.Select(x => x + "#layer1.externalSolarReflectance").OrderBy(x => x, StringComparer.Ordinal)), "exactly the outdoor faces of that layer");
+ foreach (string side in sides)
+ {
+ OutOfEngineScopeRow row = report.OutOfEngineScope.Rows.Single(x => x.Key == side + "#layer1.internalSolarReflectance");
+ Assert.That(row.NativeValue, Is.EqualTo(0.5));
+ Assert.That(row.Differs, Is.True);
+ }
+
+ Assert.That(report.OutOfEngineScope.Count, Is.EqualTo(box.OutOfEngineScope.Count), "no row moves in or out of scope");
+ }
+
+ ///
+ /// Interzone, both directions: the object that is one side's zone face is the other side's outer face. Changing it
+ /// FAILs the zone face (and every other zone face that object holds); the partner side's outer-face row stays
+ /// OUT-OF-ENGINE-SCOPE ("interzone surface outer face") with the new native value.
+ ///
+ [TestCase(true)]
+ [TestCase(false)]
+ public void AnInterzoneZoneFaceError_Fails_WhileThePartnersOuterFaceStaysInformational(bool firstSpaceZoneFace)
+ {
+ AnalyticalModel model = TestSupport.Load(TestSupport.Two);
+ AdjacencyCluster cluster = model.AdjacencyCluster;
+ List spaces = cluster.GetSpaces();
+ Panel partition = cluster.GetPanels().Single(x => cluster.GetRelatedObjects(x).Count == 2);
+ List related = cluster.GetRelatedObjects(partition).OrderBy(x => spaces.FindIndex(s => s.Guid == x.Guid)).ToList();
+ int count = partition.Construction.ConstructionLayers.Count;
+ string first = SideKey(partition, related[0]), second = SideKey(partition, related[1]);
+ string zoneKey = firstSpaceZoneFace ? first + "#layer" + count + ".internalSolarReflectance" : second + "#layer" + count + ".externalSolarReflectance";
+ string outerKey = firstSpaceZoneFace ? second + "#layer1.internalSolarReflectance" : first + "#layer1.externalSolarReflectance";
+ OutOfEngineScopeRow outer = two.OutOfEngineScope.Rows.Single(x => x.Key == outerKey);
+ Assert.That(outer.ScopeReason, Does.StartWith("interzone surface outer face"));
+
+ OsReport report = MutatedIdf(twoRun, TestSupport.Two, "u1-interzone-" + (firstSpaceZoneFace ? "first" : "second"), (idf, m) => Cli.Negative.SetField(idf, "Material", outer.NativeObject, "Solar Absorptance", "0.5"));
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.Fail), report.Status);
+ List failed = report.NonPass.Where(x => x.Status == "Fail").ToList();
+ List zoneFaces = ZoneFaceKeys(TestSupport.Two);
+ Assert.That(failed.Select(x => x.Key), Has.Member(zoneKey));
+ Assert.That(failed.All(x => zoneFaces.Contains(x.Key) && x.Key.EndsWith("SolarReflectance", StringComparison.Ordinal)), Is.True, string.Join(" | ", failed.Select(x => x.Key)));
+
+ OutOfEngineScopeRow after = report.OutOfEngineScope.Rows.Single(x => x.Key == outerKey);
+ Assert.That(after.NativeValue, Is.EqualTo(0.5));
+ Assert.That(report.NonPass.Select(x => x.Key), Has.No.Member(outerKey));
+ Assert.That(report.OutOfEngineScope.Count, Is.EqualTo(two.OutOfEngineScope.Count));
+ }
+
+ /// A wrong thickness on a buried layer FAILs its thickness rows (and the derived U); its faces stay informational and mask nothing.
+ [Test]
+ public void AWrongBuriedLayerThickness_Fails_AndTheInformationalRowsMaskNothing()
+ {
+ OutOfEngineScopeRow buried = box.OutOfEngineScope.Rows.First(x => x.Layer > 1 && x.Layer < x.LayerCount);
+ List thickness = box.OutOfEngineScope.Rows.Where(x => x.NativeObject == buried.NativeObject).Select(x => x.Side + "#layer" + x.Layer + ".thickness").Distinct().ToList();
+ OsReport report = MutatedIdf(boxRun, TestSupport.Box, "u1-buried-thickness", (idf, model) =>
+ {
+ IdfObject material = IdfText.Parse(idf).Single(x => x.Type == "Material" && x.Name == buried.NativeObject);
+ double value = double.Parse(material.Value(2), System.Globalization.CultureInfo.InvariantCulture) * 1.1;
+ return Cli.Negative.SetField(idf, "Material", buried.NativeObject, "Thickness", value.ToString("R", System.Globalization.CultureInfo.InvariantCulture));
+ });
+
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.Fail), report.Status);
+ List failed = report.NonPass.Where(x => x.Status == "Fail").ToList();
+ Assert.That(failed.Where(x => x.Quantity == "3a").Select(x => x.Key).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(thickness.OrderBy(x => x, StringComparer.Ordinal)));
+ Assert.That(failed.All(x => (x.Quantity == "3a" && x.Key.EndsWith(".thickness", StringComparison.Ordinal)) || x.Quantity == "3b"), Is.True, string.Join(" | ", failed.Select(x => x.Key)));
+ Assert.That(report.OutOfEngineScope.Count, Is.EqualTo(box.OutOfEngineScope.Count));
+ Assert.That(report.NonPass.Select(x => x.Key).Intersect(report.OutOfEngineScope.Rows.Select(x => x.Key)), Is.Empty);
+ }
+
+ /// An informational row coexists with a genuine blocker elsewhere: the blocker decides, both stay visible, and the counts are unchanged.
+ [TestCase("neg-layer-conductivity", TestSupport.Two)]
+ [TestCase("neg-lighting-return-air", TestSupport.Box)]
+ public void AnInformationalRow_NeverMasksAGatedDefect(string caseId, string fixture)
+ {
+ OsReport original = fixture == TestSupport.Box ? box : two;
+ string run = TestSupport.NewDirectory("u1-" + caseId + "-" + fixture);
+ Assert.That(TestSupport.Cli(out string output, "negative", "--run", fixture == TestSupport.Box ? boxRun : twoRun, "--case", caseId, "--out", run), Is.EqualTo(0), output);
+ OsReport report = TestSupport.ReadAndCompare(run, fixture);
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.Fail), report.Status);
+ Assert.That(report.GatedNonPass, Is.EqualTo(report.NonPass.Count).And.GreaterThan(0));
+ Assert.That(report.OutOfEngineScope.Count, Is.EqualTo(original.OutOfEngineScope.Count));
+ Assert.That(report.OutOfEngineScope.Differing, Is.EqualTo(original.OutOfEngineScope.Differing));
+ Assert.That(report.CountByOutcome[OsComparison.OutOfEngineScopeOutcome], Is.EqualTo(original.OutOfEngineScope.Count));
+ Assert.That(report.NonPass.Select(x => x.Key).Intersect(report.OutOfEngineScope.Rows.Select(x => x.Key)), Is.Empty);
+ string text = Text(run);
+ Assert.That(text, Does.Contain("NOT PASS ").And.Contain("OUT-OF-ENGINE-SCOPE (informational, not gated;"));
+ }
+
+ // ---- authored zero and equal values --------------------------------------------------------------------------------
+
+ ///
+ /// An authored zero is a value (S-ZERO), not absence. On the box floor's buried EPS layer: internal emissivity 0
+ /// against the native external value (differing), and both light reflectances 0 against a native 0 (equal). All
+ /// three are OUT-OF-ENGINE-SCOPE and the fixture still PASSes.
+ ///
+ [Test]
+ public void AnAuthoredZeroOnAnUnusedFace_IsAValue_AndOutOfEngineScope()
+ {
+ AnalyticalModel model = WithMaterial(TestSupport.Box, "FIX_EPS", material =>
+ {
+ material.SetValue(OpaqueMaterialParameter.InternalEmissivity, 0.0);
+ material.SetValue(OpaqueMaterialParameter.InternalLightReflectance, 0.0);
+ material.SetValue(OpaqueMaterialParameter.ExternalLightReflectance, 0.0);
+ });
+
+ OsReport report = TestSupport.Pipeline(TestSupport.Write(model, "u1-zero"), TestSupport.Box, "u1-zero", out string run);
+ Assert.That(report.NonPass, Is.Empty, string.Join(" | ", report.NonPass.Select(x => x.Key + " " + x.Reason)));
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.Pass), report.Status);
+
+ Dictionary eps = report.OutOfEngineScope.Rows.Where(x => x.SamMaterial == "FIX_EPS").ToDictionary(x => x.Property);
+ Assert.That(eps.Count, Is.EqualTo(6), "both faces of a buried layer");
+ Assert.That(eps["internalEmissivity"].SamValue, Is.EqualTo(0.0));
+ Assert.That(eps["internalEmissivity"].Differs, Is.True);
+ foreach (string property in new[] { "internalLightReflectance", "externalLightReflectance" })
+ {
+ Assert.That(eps[property].SamValue, Is.EqualTo(0.0), property);
+ Assert.That(eps[property].NativeValue, Is.EqualTo(0.0), property);
+ Assert.That(eps[property].Differs, Is.False, property);
+ Assert.That(eps[property].NumericVerdict, Is.EqualTo("Identical"), property);
+ }
+
+ Dictionary results = LayerResults(run, TestSupport.Box);
+ Assert.That(results[eps["internalEmissivity"].Key].Input.Expected.State, Is.EqualTo(SamInputState.Zero));
+ Assert.That(report.OutOfEngineScope.Count, Is.EqualTo(box.OutOfEngineScope.Count));
+ }
+
+ // ---- not valid and comparable: never excluded --------------------------------------------------------------------
+
+ /// A native value on an unused face that is NaN, blank (absent), unreadable or outside [0, 1] keeps its gated result and is never informational.
+ [TestCase("NaN", "ExportDefect", "Fail", ReadBackExit.Fail, "not a read value")]
+ [TestCase("", "ExportDefect", "Fail", ReadBackExit.Fail, "not a read value")]
+ [TestCase("abc", "UnverifiedUnreadable", "BlockedTechnical", ReadBackExit.BlockedTechnical, "not a read value")]
+ [TestCase("1.25", "ExportDefect", "Fail", ReadBackExit.Fail, "is outside [0, 1]")]
+ public void AnInvalidNativeValueOnAnUnusedFace_IsNeverExcluded(string value, string outcome, string status, int exit, string fragment)
+ {
+ List eps = box.OutOfEngineScope.Rows.Where(x => x.SamMaterial == "FIX_EPS" && x.Property.EndsWith("SolarReflectance", StringComparison.Ordinal)).ToList();
+ Assert.That(eps.Count, Is.EqualTo(2), "the buried EPS layer's two solar faces");
+ OsReport report = MutatedIdf(boxRun, TestSupport.Box, "u1-native-" + (value.Length == 0 ? "blank" : value), (idf, model) => Cli.Negative.SetField(idf, "Material", eps[0].NativeObject, "Solar Absorptance", value));
+
+ Assert.That(report.ExitCode, Is.EqualTo(exit), report.Status);
+ Assert.That(report.NonPass.Select(x => x.Key).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(eps.Select(x => x.Key).OrderBy(x => x, StringComparer.Ordinal)), "exactly the two rows, gated");
+ Assert.That(report.NonPass.All(x => x.Outcome == outcome && x.Status == status && x.Reason.Contains(fragment) && x.Reason.Contains("Not OUT-OF-ENGINE-SCOPE")), Is.True, string.Join(" | ", report.NonPass.Select(x => x.Outcome + " " + x.Status + " " + x.Reason)));
+ Assert.That(report.OutOfEngineScope.Rows.Select(x => x.Key).Intersect(eps.Select(x => x.Key)), Is.Empty);
+ Assert.That(report.OutOfEngineScope.Count, Is.EqualTo(box.OutOfEngineScope.Count - 2));
+ Assert.That(report.Coverage, Is.EqualTo("Complete"));
+ Assert.That(report.Required, Is.EqualTo(box.Required));
+ }
+
+ /// A SAM value on an unused face that is absent or NaN (S-UNSPEC) or outside [0, 1] (S-INVALID) keeps its gated result and is never informational.
+ [TestCase("absent", "ExportDefect", "Fail", ReadBackExit.Fail)]
+ [TestCase("nan", "ExportDefect", "Fail", ReadBackExit.Fail)]
+ [TestCase("out-of-domain", "InputInvalid", "BlockedTechnical", ReadBackExit.BlockedTechnical)]
+ public void AnInvalidSamValueOnAnUnusedFace_IsNeverExcluded(string what, string outcome, string status, int exit)
+ {
+ string key = box.OutOfEngineScope.Rows.Single(x => x.SamMaterial == "FIX_EPS" && x.Property == "internalSolarReflectance").Key;
+ AnalyticalModel model = WithMaterial(TestSupport.Box, "FIX_EPS", material =>
+ {
+ switch (what)
+ {
+ case "absent":
+ material.RemoveValue(OpaqueMaterialParameter.InternalSolarReflectance);
+ break;
+ case "nan":
+ material.SetValue(OpaqueMaterialParameter.InternalSolarReflectance, double.NaN);
+ break;
+ default:
+ material.SetValue(OpaqueMaterialParameter.InternalSolarReflectance, 1.4);
+ break;
+ }
+ });
+
+ OsReport report = TestSupport.Pipeline(TestSupport.Write(model, "u1-sam-" + what), TestSupport.Box, "u1-sam-" + what, out string run);
+ Assert.That(report.ExitCode, Is.EqualTo(exit), report.Status);
+ ReportItem item = report.NonPass.Single();
+ Assert.That(item.Key, Is.EqualTo(key));
+ Assert.That(item.Outcome, Is.EqualTo(outcome), item.Reason);
+ Assert.That(item.Status, Is.EqualTo(status), item.Reason);
+ Assert.That(item.Reason, Does.Contain("Not OUT-OF-ENGINE-SCOPE"));
+ Assert.That(report.OutOfEngineScope.Rows.Select(x => x.Key), Has.No.Member(key));
+ Assert.That(report.OutOfEngineScope.Count, Is.EqualTo(box.OutOfEngineScope.Count - 1));
+ }
+
+ // ---- face roles ----------------------------------------------------------------------------------------------------
+
+ ///
+ /// The control: a box wall made adiabatic in SAM. Its outermost layer's outer face is now unused ("adiabatic surface
+ /// outer face"); the role is established from the SAM flag and the native Adiabatic boundary, and the fixture passes.
+ ///
+ [Test]
+ public void AnAdiabaticWall_HasItsOuterFaceOutOfEngineScope()
+ {
+ AnalyticalModel model = WithAdiabaticPanel(TestSupport.Box, cluster => cluster.GetPanels().First(x => x.PanelType == PanelType.WallExternal && (x.Apertures == null || x.Apertures.Count == 0)), out Panel wall);
+ OsReport report = TestSupport.Pipeline(TestSupport.Write(model, "u1-adiabatic"), TestSupport.Box, "u1-adiabatic", out string run);
+ Assert.That(report.Unsupported, Is.Empty, string.Join(" | ", report.Unsupported));
+ Assert.That(report.Consistency, Is.Empty, string.Join(" | ", report.Consistency));
+ Assert.That(report.NonPass, Is.Empty, string.Join(" | ", report.NonPass.Select(x => x.Key + " " + x.Reason)));
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.Pass), report.Status);
+ string side = SideKey(wall, model.AdjacencyCluster.GetRelatedObjects(wall).Single());
+ List outer = report.OutOfEngineScope.Rows.Where(x => x.Side == side && x.ScopeReason == "adiabatic surface outer face").ToList();
+ Assert.That(outer.Select(x => x.Key).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(UnusedFaces(model).Where(x => x.Value == "adiabatic surface outer face").Select(x => x.Key).OrderBy(x => x, StringComparer.Ordinal)));
+ Assert.That(outer.Count, Is.EqualTo(3));
+ Assert.That(report.OutOfEngineScope.Count, Is.EqualTo(box.OutOfEngineScope.Count + 3));
+ }
+
+ ///
+ /// A face role that cannot be established is never excluded: an unsupported native ground form, an adiabatic SAM
+ /// panel written as Ground, an interzone partner that is not this side's mirror or does not name it back, and a SAM
+ /// panel that bounds two spaces and is adiabatic. Each is named as unsupported (BLOCKED technical at least), none of
+ /// the side's rows is OUT-OF-ENGINE-SCOPE, and the fixture never passes.
+ ///
+ [TestCase("ground-preprocessor")]
+ [TestCase("adiabatic-written-as-ground")]
+ [TestCase("interzone-not-mirrored")]
+ [TestCase("interzone-partner-missing")]
+ [TestCase("interzone-adiabatic")]
+ public void AFaceRoleThatCannotBeEstablished_IsNeverExcluded(string what)
+ {
+ OsReport report;
+ List sides;
+ string fragment;
+ switch (what)
+ {
+ case "ground-preprocessor":
+ {
+ AnalyticalModel model = TestSupport.Load(TestSupport.Box);
+ Panel floor = model.AdjacencyCluster.GetPanels().Single(x => x.PanelType == PanelType.SlabOnGrade);
+ sides = new List { SideKey(floor, model.AdjacencyCluster.GetRelatedObjects(floor).Single()) };
+ report = MutatedIdf(boxRun, TestSupport.Box, "u1-ground-preprocessor", (idf, m) =>
+ {
+ IdfObject surface = IdfText.Parse(idf).Single(x => x.Type == "BuildingSurface:Detailed" && string.Equals(x.Value(5), "Ground", StringComparison.OrdinalIgnoreCase));
+ return Cli.Negative.SetField(idf, "BuildingSurface:Detailed", surface.Name, "Outside Boundary Condition", "GroundFCfactorMethod");
+ });
+ fragment = "the native outside boundary 'GroundFCfactorMethod' is not the SAM boundary 'Ground'";
+ Assert.That(report.Consistency, Is.Not.Empty, "the boundary itself also disagrees with SAM");
+ break;
+ }
+
+ case "adiabatic-written-as-ground":
+ {
+ AnalyticalModel model = WithAdiabaticPanel(TestSupport.Box, cluster => cluster.GetPanels().First(x => x.PanelType == PanelType.WallExternal && (x.Apertures == null || x.Apertures.Count == 0)), out Panel wall);
+ TestSupport.Pipeline(TestSupport.Write(model, "u1-adiabatic-source"), TestSupport.Box, "u1-adiabatic-source", out string source);
+ sides = new List { SideKey(wall, model.AdjacencyCluster.GetRelatedObjects(wall).Single()) };
+ report = MutatedIdf(source, TestSupport.Box, "u1-adiabatic-as-ground", (idf, m) =>
+ {
+ IdfObject surface = IdfText.Parse(idf).Single(x => x.Type == "BuildingSurface:Detailed" && string.Equals(x.Value(5), "Adiabatic", StringComparison.OrdinalIgnoreCase));
+ return Cli.Negative.SetField(idf, "BuildingSurface:Detailed", surface.Name, "Outside Boundary Condition", "Ground");
+ });
+ fragment = "the native outside boundary 'Ground' is not the SAM boundary 'Adiabatic'";
+ break;
+ }
+
+ case "interzone-not-mirrored":
+ case "interzone-partner-missing":
+ {
+ AnalyticalModel model = TestSupport.Load(TestSupport.Two);
+ Panel partition = model.AdjacencyCluster.GetPanels().Single(x => model.AdjacencyCluster.GetRelatedObjects(x).Count == 2);
+ sides = model.AdjacencyCluster.GetRelatedObjects(partition).Select(x => SideKey(partition, x)).ToList();
+ if (what == "interzone-not-mirrored")
+ {
+ report = MutatedIdf(twoRun, TestSupport.Two, "u1-interzone-not-mirrored", (idf, m) =>
+ {
+ IdfObject reverse = IdfText.Parse(idf).Single(x => x.Type == "Construction" && x.Name.Contains("PARTITION_Reverse"));
+ idf = Cli.Negative.SetField(idf, "Construction", reverse.Name, "Layer 2", reverse.Value(3));
+ return Cli.Negative.SetField(idf, "Construction", reverse.Name, "Layer 3", reverse.Value(2));
+ });
+ fragment = "the interzone partner's construction is not this side's construction reversed";
+ }
+ else
+ {
+ report = MutatedIdf(twoRun, TestSupport.Two, "u1-interzone-partner-missing", (idf, m) =>
+ {
+ IdfObject surface = IdfText.Parse(idf).First(x => x.Type == "BuildingSurface:Detailed" && string.Equals(x.Value(5), "Surface", StringComparison.OrdinalIgnoreCase));
+ return Cli.Negative.SetField(idf, "BuildingSurface:Detailed", surface.Name, "Outside Boundary Condition Object", "S09_NoSuchSurface");
+ });
+ fragment = "the interzone partner";
+ }
+
+ break;
+ }
+
+ default:
+ {
+ AnalyticalModel model = WithAdiabaticPanel(TestSupport.Two, cluster => cluster.GetPanels().Single(x => cluster.GetRelatedObjects(x).Count == 2), out Panel partition);
+ sides = model.AdjacencyCluster.GetRelatedObjects(partition).Select(x => SideKey(partition, x)).ToList();
+ report = TestSupport.Pipeline(TestSupport.Write(model, "u1-interzone-adiabatic"), TestSupport.Two, "u1-interzone-adiabatic", out string _);
+ fragment = "contradictory SAM boundary: the panel bounds two spaces and is adiabatic";
+ break;
+ }
+ }
+
+ Assert.That(report.ExitCode, Is.AnyOf(ReadBackExit.Fail, ReadBackExit.BlockedTechnical), what + ": " + report.Status);
+ Assert.That(report.Status, Is.Not.EqualTo(GateStatus.Pass.ToString()));
+ foreach (string side in sides)
+ {
+ Assert.That(report.Unsupported.Count(x => x.StartsWith("optics role: " + side + ": ", StringComparison.Ordinal) && x.Contains(fragment) && x.EndsWith("no optical face of this side is OUT-OF-ENGINE-SCOPE (" + OsComparison.OpaqueFaceScope + ")", StringComparison.Ordinal)), Is.EqualTo(1), side + ": " + string.Join(" | ", report.Unsupported));
+ Assert.That(report.OutOfEngineScope.Rows.Where(x => x.Side == side), Is.Empty, side);
+ }
+
+ Assert.That(report.NonPass.Where(x => sides.Any(s => x.Key.StartsWith(s + "#", StringComparison.Ordinal)) && OpticalProperties.Any(p => x.Key.EndsWith("." + p, StringComparison.Ordinal))).All(x => x.Reason.Contains("Not OUT-OF-ENGINE-SCOPE") || x.Status == "Fail"), Is.True);
+ Assert.That(report.Coverage, Is.EqualTo("Complete"));
+ }
+ }
+}
diff --git a/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/ReadBackTests.cs b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/ReadBackTests.cs
new file mode 100644
index 0000000..d15ad5a
--- /dev/null
+++ b/benchmark/SAM.Analytical.OpenStudio.InputReadBack.Tests/ReadBackTests.cs
@@ -0,0 +1,834 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using NUnit.Framework;
+using SAM.Analytical.InputEquivalence;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace SAM.Analytical.OpenStudio.InputReadBack.Tests
+{
+ ///
+ /// The S09 read-back end to end, offline: export, fresh-process read and compare on the S08 signed-off fixtures with the
+ /// Budapest EPW. Each fixture has complete S07 coverage. Opaque optics on faces EnergyPlus does not use are
+ /// OUT-OF-ENGINE-SCOPE (S07 §5.2, amendment U1): informational, not gated, never MATCH. Every gated quantity passes, so
+ /// both fixtures PASS scoped native-input conformance (exit 0); this is not simulation equivalence. The lighting split
+ /// is the S07 §5.1 T-LIGHT-SPLIT of the authored p. The ventilation policy holds hour by hour, and every targeted
+ /// mutation is refused.
+ ///
+ [TestFixture]
+ public partial class ReadBackTests
+ {
+ private string boxRun;
+ private string twoRun;
+ private OsReport box;
+ private OsReport two;
+
+ [OneTimeSetUp]
+ public void ExportBothFixtures()
+ {
+ Assert.That(Hashing.Sha256(TestSupport.Fixture(TestSupport.Box)), Is.EqualTo(TestSupport.BoxSha256), "the S08 signed-off box fixture, byte for byte");
+ Assert.That(Hashing.Sha256(TestSupport.Fixture(TestSupport.Two)), Is.EqualTo(TestSupport.TwoSha256), "the S08 signed-off two-zone fixture, byte for byte");
+ box = TestSupport.Pipeline(TestSupport.Fixture(TestSupport.Box), TestSupport.Box, "box", out boxRun);
+ two = TestSupport.Pipeline(TestSupport.Fixture(TestSupport.Two), TestSupport.Two, "two", out twoRun);
+ }
+
+ private static string Ic(Guid space, string what) => "space:" + space.ToString("D") + ".ic." + what;
+
+ ///
+ /// The gating non-PASS items of a fixture; must be empty. Since S07 §5.2 (amendment U1) no gated item is excepted:
+ /// the unused-face optics rows are OUT-OF-ENGINE-SCOPE and never in . The lighting rows
+ /// have no exception (S07 §5.1 removed the view-coefficient comparison and its DOCUMENTED-DEFAULT).
+ ///
+ private static List Unexplained(OsReport report)
+ {
+ return report.NonPass.ToList();
+ }
+
+ private static readonly string[] LightingRows = { "lighting.returnAir", "lighting.radiantTotal", "lighting.visible", "lighting.radiant", "lighting.convective" };
+
+ ///
+ /// Every T-LIGHT-SPLIT row of a space, recomputed in this process from the run's frozen model and the snapshot the
+ /// fresh-process reader wrote (the report lists only the non-PASS rows). The verdict itself is the CLI's.
+ ///
+ private static Dictionary LightingResults(string run, string fixtureId, Guid space)
+ {
+ AnalyticalModel model = Core.Convert.ToSAM(Path.Combine(run, "frozen-model.json")).Single();
+ NativeSnapshot snapshot = NativeSnapshot.Load(Path.Combine(run, "native.json"));
+ OsComparison comparison = OsComparison.Run(model, snapshot, Path.Combine(run, "weather.epw"), fixtureId, GateRuleSet.Approved);
+ return LightingRows.ToDictionary(x => x, x => comparison.Results.Single(r => r.Input.ObjectKey == Ic(space, x)));
+ }
+
+ [TestCase(TestSupport.Box, "5080c000-0000-0000-0000-00000000c001", 0.37)]
+ [TestCase(TestSupport.Two, "5080c000-0000-0000-0000-00000000c0a1", 0.34)]
+ [TestCase(TestSupport.Two, "5080c000-0000-0000-0000-00000000c0b1", 0.41)]
+ public void TheAuthoredP_IsWrittenAsTheAcceptedLightingSplit(string fixtureId, string spaceId, double p)
+ {
+ // S07 §5.1 expected vectors (tolerance 0.001): F_V, F_R, F_C.
+ Dictionary vectors = new Dictionary
+ {
+ [0.37] = new[] { 0.155130, 0.214870, 0.63 },
+ [0.34] = new[] { 0.142552, 0.197448, 0.66 },
+ [0.41] = new[] { 0.171901, 0.238099, 0.59 },
+ };
+
+ Dictionary rows = LightingResults(fixtureId == TestSupport.Box ? boxRun : twoRun, fixtureId, Guid.Parse(spaceId));
+ Assert.That(rows["lighting.returnAir"].Outcome, Is.EqualTo(EquivalenceOutcome.Match), rows["lighting.returnAir"].Reason);
+ Assert.That(rows["lighting.returnAir"].Input.Native.Kind, Is.EqualTo(ReaderKind.Zero), "blank reads as 0");
+ foreach (string row in new[] { "lighting.radiantTotal", "lighting.visible", "lighting.radiant", "lighting.convective" })
+ {
+ Assert.That(rows[row].Outcome, Is.EqualTo(EquivalenceOutcome.AcceptedTransformation), row + ": " + rows[row].Reason);
+ Assert.That(rows[row].Input.TransformationId, Is.EqualTo(OsComparison.LightSplit));
+ }
+
+ Assert.That(rows["lighting.radiantTotal"].Input.Expected.Value, Is.EqualTo(p));
+ Assert.That(rows["lighting.visible"].Input.Expected.Value, Is.EqualTo(vectors[p][0]).Within(1e-6));
+ Assert.That(rows["lighting.radiant"].Input.Expected.Value, Is.EqualTo(vectors[p][1]).Within(1e-6));
+ Assert.That(rows["lighting.convective"].Input.Expected.Value, Is.EqualTo(vectors[p][2]).Within(1e-12));
+ Assert.That(rows["lighting.visible"].Input.Native.Value, Is.EqualTo(vectors[p][0]).Within(0.001));
+ Assert.That(rows["lighting.radiant"].Input.Native.Value, Is.EqualTo(vectors[p][1]).Within(0.001));
+ }
+
+ [Test]
+ public void AnAuthoredZero_IsPreserved_AndReadsBackAllConvective()
+ {
+ AnalyticalModel model = TestSupport.Load(TestSupport.Box);
+ TestSupport.ChangeCondition(model, TestSupport.BoxSpace, ic => ic.SetValue(InternalConditionParameter.LightingRadiantProportion, 0.0));
+ OsReport report = TestSupport.Pipeline(TestSupport.Write(model, "box-p0-zero"), TestSupport.Box, "box-p-zero", out string run);
+ Assert.That(Unexplained(report), Is.Empty, string.Join(" | ", Unexplained(report).Select(x => x.Key + " " + x.Outcome + " " + x.Reason)));
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.Pass), "every gated row passes; the unused-face optics are informational (S07 §5.2)");
+
+ Dictionary rows = LightingResults(run, TestSupport.Box, TestSupport.BoxSpace);
+ foreach (string row in new[] { "lighting.returnAir", "lighting.radiantTotal", "lighting.visible", "lighting.radiant" })
+ {
+ Assert.That(rows[row].Input.Expected.State, Is.EqualTo(SamInputState.Zero), row);
+ Assert.That(rows[row].Input.Native.Kind, Is.EqualTo(ReaderKind.Zero), row);
+ Assert.That(rows[row].Outcome, Is.EqualTo(EquivalenceOutcome.Match), row);
+ }
+
+ Assert.That(rows["lighting.convective"].Input.Expected.Value, Is.EqualTo(1.0));
+ Assert.That(rows["lighting.convective"].Outcome, Is.EqualTo(EquivalenceOutcome.AcceptedTransformation));
+ }
+
+ [TestCase(0.49)]
+ [TestCase(0.0)]
+ public void TheComfortCoefficient_LeavesEveryLightingRowUnchanged(double coefficient)
+ {
+ AnalyticalModel model = TestSupport.Load(TestSupport.Box);
+ TestSupport.ChangeCondition(model, TestSupport.BoxSpace, ic => ic.SetValue(InternalConditionParameter.LightingViewCoefficient, coefficient));
+ string label = "box-c-" + coefficient.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture);
+ OsReport report = TestSupport.Pipeline(TestSupport.Write(model, label), TestSupport.Box, label, out string run);
+ Assert.That(Unexplained(report), Is.Empty, string.Join(" | ", Unexplained(report).Select(x => x.Key + " " + x.Outcome + " " + x.Reason)));
+ Assert.That(report.Required, Is.EqualTo(box.Required), "the view coefficient adds no compared row");
+ Assert.That(report.CountByOutcome, Is.EqualTo(box.CountByOutcome));
+
+ Dictionary baseline = LightingResults(boxRun, TestSupport.Box, TestSupport.BoxSpace);
+ Dictionary rows = LightingResults(run, TestSupport.Box, TestSupport.BoxSpace);
+ foreach (string row in LightingRows)
+ {
+ Assert.That(rows[row].Outcome, Is.EqualTo(baseline[row].Outcome), row);
+ Assert.That(rows[row].Input.Expected.ToString(), Is.EqualTo(baseline[row].Input.Expected.ToString()), row);
+ Assert.That(rows[row].Input.Native.Value, Is.EqualTo(baseline[row].Input.Native.Value), row);
+ }
+ }
+
+ /// An unstated (absent or NaN) p keeps S-UNSPEC on every lighting row: the p₀ split is DOCUMENTED-DEFAULT, never PASS.
+ [TestCase("absent")]
+ [TestCase("nan")]
+ public void AnUnstatedP_IsADocumentedDefault_NeverPass(string what)
+ {
+ AnalyticalModel model = TestSupport.Load(TestSupport.Box);
+ TestSupport.ChangeCondition(model, TestSupport.BoxSpace, ic =>
+ {
+ if (what == "nan")
+ {
+ ic.SetValue(InternalConditionParameter.LightingRadiantProportion, double.NaN);
+ }
+ else
+ {
+ ic.RemoveValue(InternalConditionParameter.LightingRadiantProportion);
+ }
+ });
+
+ string label = "box-p-" + what;
+ OsReport report = TestSupport.Pipeline(TestSupport.Write(model, label), TestSupport.Box, label, out string run);
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.BlockedOwner), report.Status);
+ Assert.That(report.OutOfEngineScope.Count, Is.EqualTo(box.OutOfEngineScope.Count), "the informational rows coexist and stay visible; they neither hide nor add a blocker");
+ List lighting = Unexplained(report);
+ Assert.That(lighting.Select(x => x.Key).OrderBy(x => x), Is.EqualTo(LightingRows.Select(x => Ic(TestSupport.BoxSpace, x)).OrderBy(x => x)), string.Join(" | ", lighting.Select(x => x.Key + " " + x.Outcome)));
+ Assert.That(lighting.All(x => x.Outcome == "DocumentedDefault" && x.Status == "BlockedOwnerDecision"), Is.True, string.Join(" | ", lighting.Select(x => x.Key + " " + x.Outcome + " " + x.Status)));
+
+ Dictionary rows = LightingResults(run, TestSupport.Box, TestSupport.BoxSpace);
+ Assert.That(rows.Values.All(x => x.Input.Expected.State == SamInputState.Unspecified), Is.True, "no expected value is computed from p0 as if SAM had stated it");
+ Assert.That(rows["lighting.radiantTotal"].Input.ListedDefault, Is.EqualTo(0.576));
+ Assert.That(rows["lighting.visible"].Input.Native.Value, Is.EqualTo(0.2415).Within(1e-9));
+ Assert.That(rows["lighting.radiant"].Input.Native.Value, Is.EqualTo(0.3345).Within(1e-9));
+ }
+
+ [TestCase("neg-lighting-previous-mapping", TestSupport.Box, "lighting.visible")]
+ [TestCase("neg-lighting-previous-mapping", TestSupport.Two, "lighting.visible")]
+ [TestCase("neg-lighting-visible-zero", TestSupport.Box, "lighting.visible")]
+ [TestCase("neg-lighting-visible-zero", TestSupport.Two, "lighting.visible")]
+ [TestCase("neg-lighting-radiant-is-p", TestSupport.Box, "lighting.radiant")]
+ [TestCase("neg-lighting-radiant-is-p", TestSupport.Two, "lighting.radiant")]
+ [TestCase("neg-lighting-return-air", TestSupport.Box, "lighting.returnAir")]
+ [TestCase("neg-lighting-return-air", TestSupport.Two, "lighting.returnAir")]
+ [TestCase("neg-lighting-swapped", TestSupport.Box, "lighting.visible")]
+ [TestCase("neg-lighting-swapped", TestSupport.Two, "lighting.visible")]
+ public void AnIncorrectLightingPartition_Fails_OnTheLightingRows(string caseId, string fixture, string failingRow)
+ {
+ OsReport original = fixture == TestSupport.Box ? box : two;
+ string run = TestSupport.NewDirectory(caseId + "-" + fixture);
+ Assert.That(TestSupport.Cli(out string output, "negative", "--run", fixture == TestSupport.Box ? boxRun : twoRun, "--case", caseId, "--out", run), Is.EqualTo(0), output);
+ OsReport report = TestSupport.ReadAndCompare(run, fixture);
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.Fail), caseId + ": " + report.Status);
+ List failed = report.NonPass.Where(x => x.Status == "Fail").ToList();
+ Assert.That(failed, Is.Not.Empty);
+ Assert.That(failed.All(x => x.Key.Contains(".ic.lighting.")), Is.True, string.Join(" | ", failed.Select(x => x.Key + " " + x.Reason)));
+ Assert.That(failed.Any(x => x.Key.EndsWith(".ic." + failingRow, StringComparison.Ordinal)), Is.True, string.Join(" | ", failed.Select(x => x.Key)));
+ Assert.That(report.Consistency, Is.Empty, "the OSM is not compared for the split; the lighting rows refuse it");
+ Assert.That(report.Required, Is.EqualTo(original.Required));
+ }
+
+ /// A box export with an authored p (fresh-process read and compare).
+ private static OsReport BoxWithP(double p, out string run)
+ {
+ AnalyticalModel model = TestSupport.Load(TestSupport.Box);
+ TestSupport.ChangeCondition(model, TestSupport.BoxSpace, ic => ic.SetValue(InternalConditionParameter.LightingRadiantProportion, p));
+ string label = "box-p-" + p.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
+ return TestSupport.Pipeline(TestSupport.Write(model, label), TestSupport.Box, label, out run);
+ }
+
+ /// A CLI negative (or control) case applied to a copy of , read and compared fresh.
+ private static OsReport Mutated(string source, string caseId, out string run)
+ {
+ run = TestSupport.NewDirectory(caseId);
+ Assert.That(TestSupport.Cli(out string output, "negative", "--run", source, "--case", caseId, "--out", run), Is.EqualTo(0), output);
+ return TestSupport.ReadAndCompare(run, TestSupport.Box);
+ }
+
+ /// The boundaries p = 1 and p = 0 written by the writer are valid splits: every lighting row passes.
+ [TestCase(1.0)]
+ [TestCase(0.0)]
+ public void TheBoundarySplits_PEqualOneAndZero_AreValid(double p)
+ {
+ OsReport report = BoxWithP(p, out string run);
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.Pass), report.Status);
+ Assert.That(Unexplained(report), Is.Empty, string.Join(" | ", Unexplained(report).Select(x => x.Key + " " + x.Outcome + " " + x.Reason)));
+ Assert.That(report.Unsupported, Is.Empty, string.Join(" | ", report.Unsupported));
+
+ Dictionary rows = LightingResults(run, TestSupport.Box, TestSupport.BoxSpace);
+ Assert.That(rows.Values.All(x => x.IsPass), Is.True, string.Join(" | ", rows.Select(x => x.Key + " " + x.Value.Outcome + " " + x.Value.Reason)));
+ Assert.That(rows["lighting.radiantTotal"].Input.Native.Value, Is.EqualTo(p).Within(OsQuantities.LightingFractionBoundary), "the writer's split sums to p within the floating-point boundary");
+ }
+
+ ///
+ /// Independent review of 31af6022: a native split shifted by 0.0004 per field is within the 0.001 tolerance of every
+ /// row, and the reviewed head accepted it (exit 12). On p = 1 it sums to 1.0008; on p = 0 Fraction Radiant is
+ /// -0.0004. The domain guard now fails every lighting row, named, before any tolerance is applied.
+ ///
+ [TestCase(1.0, "neg-lighting-sum-above-one", "exceeds 1")]
+ [TestCase(0.0, "neg-lighting-radiant-to-visible", "Fraction Radiant -0.0004 is outside [0, 1]")]
+ public void AnInvalidNativeSplit_FailsBeforeAnyTolerance(double p, string caseId, string fragment)
+ {
+ BoxWithP(p, out string source);
+ OsReport report = Mutated(source, caseId, out string run);
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.Fail), report.Status);
+
+ List lighting = report.NonPass.Where(x => x.Key.Contains(".ic.lighting.")).ToList();
+ Assert.That(lighting.Select(x => x.Key).OrderBy(x => x), Is.EqualTo(LightingRows.Select(x => Ic(TestSupport.BoxSpace, x)).OrderBy(x => x)));
+ Assert.That(lighting.All(x => x.Outcome == "ExportDefect" && x.Status == "Fail" && x.Reason.Contains(fragment)), Is.True, string.Join(" | ", lighting.Select(x => x.Outcome + " " + x.Reason)));
+ Assert.That(report.NonPass.Where(x => x.Status == "Fail").All(x => x.Key.Contains(".ic.lighting.")), Is.True, "only the lighting rows fail");
+
+ Dictionary rows = LightingResults(run, TestSupport.Box, TestSupport.BoxSpace);
+ Assert.That(rows.Values.Any(x => x.IsPass), Is.False, "no lighting row is accepted");
+ Assert.That(rows.Values.All(x => x.Comparison == null), Is.True, "no tolerance was applied");
+ }
+
+ ///
+ /// Independent review of 31af6022: with Return Air Fraction Calculated from Plenum Temperature = Yes the reviewed
+ /// head still accepted the static split (exit 12). Yes, an uninterpretable value and an unreadable field now block
+ /// technically (exit 11), named, with no lighting row accepted.
+ ///
+ [TestCase("neg-lighting-plenum-yes", "= Yes: the return-air share follows the plenum temperature at run time")]
+ [TestCase("neg-lighting-plenum-unknown", "= 'Maybe' is neither Yes nor No (uninterpretable)")]
+ [TestCase("neg-lighting-plenum-relabelled", "is unreadable")]
+ public void APlenumCalculatedOrUninterpretableReturnAir_BlocksTechnically(string caseId, string fragment)
+ {
+ OsReport report = Mutated(boxRun, caseId, out string run);
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.BlockedTechnical), report.Status);
+ Assert.That(report.Consistency, Is.Empty, string.Join(" | ", report.Consistency));
+ Assert.That(report.Unsupported.Single(), Does.StartWith("lighting: " + "space:" + TestSupport.BoxSpace.ToString("D") + ": Lights '").And.Contain(fragment));
+ Assert.That(report.Required, Is.EqualTo(box.Required));
+
+ List lighting = report.NonPass.Where(x => x.Key.Contains(".ic.lighting.")).ToList();
+ Assert.That(lighting.Select(x => x.Key).OrderBy(x => x), Is.EqualTo(LightingRows.Select(x => Ic(TestSupport.BoxSpace, x)).OrderBy(x => x)));
+ Assert.That(lighting.All(x => x.Outcome == "UnverifiedUnreadable" && x.Status == "BlockedTechnical" && x.Reason.Contains(fragment)), Is.True, string.Join(" | ", lighting.Select(x => x.Outcome + " " + x.Reason)));
+ Assert.That(LightingResults(run, TestSupport.Box, TestSupport.BoxSpace).Values.Any(x => x.IsPass), Is.False, "no lighting row is accepted");
+ }
+
+ /// The valid control: an explicit No (with coefficients, which EnergyPlus ignores under No) changes nothing.
+ [Test]
+ public void AnExplicitNoPlenumSetting_IsTheUnchangedControl()
+ {
+ OsReport report = Mutated(boxRun, "ctl-lighting-plenum-no", out string run);
+ Assert.That(report.Inputs["mutation"], Does.StartWith("ctl-lighting-plenum-no"));
+ Assert.That(report.ExitCode, Is.EqualTo(box.ExitCode));
+ Assert.That(report.Unsupported, Is.Empty, string.Join(" | ", report.Unsupported));
+ Assert.That(report.CountByOutcome, Is.EqualTo(box.CountByOutcome));
+ Assert.That(Unexplained(report), Is.Empty);
+
+ Dictionary baseline = LightingResults(boxRun, TestSupport.Box, TestSupport.BoxSpace);
+ Dictionary rows = LightingResults(run, TestSupport.Box, TestSupport.BoxSpace);
+ foreach (string row in LightingRows)
+ {
+ Assert.That(rows[row].Outcome, Is.EqualTo(baseline[row].Outcome), row);
+ }
+ }
+
+ ///
+ /// S07 §5.2 (amendment U1): with the unused-face optics OUT-OF-ENGINE-SCOPE, every gated row of both fixtures is in
+ /// the PASS allow-list, so both PASS (exit 0) with complete coverage. This is scoped native-input conformance, not
+ /// simulation equivalence and not Phase B.
+ ///
+ [TestCase(TestSupport.Box, 319)]
+ [TestCase(TestSupport.Two, 592)]
+ public void BothFixtures_HaveCompleteCoverage_AndPassWithTheUnusedFaceRowsInformational(string fixtureId, int required)
+ {
+ OsReport report = fixtureId == TestSupport.Box ? box : two;
+ Assert.That(report.Schema, Is.EqualTo("s09-readback-report/3"));
+ Assert.That(report.Coverage, Is.EqualTo("Complete"), report.FixtureId);
+ Assert.That(report.Required, Is.EqualTo(required));
+ Assert.That(report.Results, Is.EqualTo(required));
+ Assert.That(report.MissingComparisons, Is.Empty, report.FixtureId);
+ Assert.That(report.Consistency, Is.Empty, report.FixtureId + ": " + string.Join(" | ", report.Consistency));
+ Assert.That(report.FixtureCriteria, Is.Empty, report.FixtureId);
+ Assert.That(report.Unsupported, Is.Empty, report.FixtureId);
+ Assert.That(Unexplained(report), Is.Empty, report.FixtureId + ": " + string.Join(" | ", Unexplained(report).Select(x => x.Key + " " + x.Outcome + " " + x.Reason)));
+ Assert.That(report.GatedNonPass, Is.EqualTo(0));
+ Assert.That(report.Status, Is.EqualTo(GateStatus.Pass.ToString()), report.FixtureId);
+ Assert.That(report.GateStatus, Is.EqualTo(GateStatus.Pass.ToString()));
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.Pass));
+ Assert.That(report.CountByOutcome.Keys, Has.No.Member("MappingDifference"), "the S09-only OS-MAT-SINGLE-SIDED listing is replaced by S07 §5.2");
+ Assert.That(report.CountByOutcome.Values.Sum(), Is.EqualTo(report.Results), "every result is counted once");
+ Assert.That(report.CountByOutcome[OsComparison.OutOfEngineScopeOutcome], Is.EqualTo(report.OutOfEngineScope.Count));
+ Assert.That(report.Supporting.Single(x => x.StartsWith(OsComparison.OpaqueFaceScope + ":", StringComparison.Ordinal)), Does.Contain("OUT-OF-ENGINE-SCOPE (informational, not gated)").And.Contain("Every used face"));
+ Assert.That(report.Series.All(x => x.Count == 8760), Is.True, "every hourly series covers the year");
+ }
+
+ // ---- opaque face optics (revision 8) -------------------------------------------------------------------------
+
+ private static readonly string[] Optics = { "SolarReflectance", "LightReflectance", "Emissivity" };
+
+ ///
+ /// Each panel side's zone-face rows, derived here from the frozen fixture alone: SAM lists a construction inside →
+ /// outside from its first space in GetSpaces() order, so a side's innermost layer is SAM's first layer on the
+ /// first space's side (its internal face) and SAM's last layer on the other side (its external face).
+ ///
+ private static List ZoneFaceKeys(string fixtureId)
+ {
+ AnalyticalModel model = TestSupport.Load(fixtureId);
+ AdjacencyCluster cluster = model.AdjacencyCluster;
+ List spaces = cluster.GetSpaces();
+ List result = new List();
+ foreach (Panel panel in cluster.GetPanels())
+ {
+ List related = cluster.GetRelatedObjects(panel).OrderBy(x => spaces.FindIndex(s => s.Guid == x.Guid)).ToList();
+ int count = panel.Construction.ConstructionLayers.Count;
+ for (int i = 0; i < related.Count; i++)
+ {
+ string face = i == 0 ? "internal" : "external";
+ result.AddRange(Optics.Select(x => "panel:" + panel.Guid.ToString("D") + "@space:" + related[i].Guid.ToString("D") + "#layer" + count + "." + face + x));
+ }
+ }
+
+ return result;
+ }
+
+ /// Every 3a result of a run, recomputed in this process from the run's frozen model and the fresh-process snapshot.
+ private static Dictionary LayerResults(string run, string fixtureId)
+ {
+ AnalyticalModel model = Core.Convert.ToSAM(Path.Combine(run, "frozen-model.json")).Single();
+ NativeSnapshot snapshot = NativeSnapshot.Load(Path.Combine(run, "native.json"));
+ OsComparison comparison = OsComparison.Run(model, snapshot, Path.Combine(run, "weather.epw"), fixtureId, GateRuleSet.Approved);
+ return comparison.Results.Where(x => x.Definition?.Id == "3a").ToDictionary(x => x.Input.ObjectKey, x => x);
+ }
+
+ ///
+ /// Revision 8: every face whose optics EnergyPlus uses is now the authored one. Box: 6 zone faces; two-zone: 12 (zone
+ /// A sees SAM's internal faces, zone B the partition's external Timber face). The rows the writer could not write
+ /// before (18 / 33) all pass, and every remaining non-PASS row is on a face EnergyPlus does not use.
+ ///
+ [TestCase(TestSupport.Box, 18)]
+ [TestCase(TestSupport.Two, 36)]
+ public void EveryUsedZoneFace_HoldsTheAuthoredFace(string fixtureId, int rows)
+ {
+ List keys = ZoneFaceKeys(fixtureId);
+ Assert.That(keys.Count, Is.EqualTo(rows));
+ Dictionary results = LayerResults(fixtureId == TestSupport.Box ? boxRun : twoRun, fixtureId);
+ foreach (string key in keys)
+ {
+ Assert.That(results.ContainsKey(key), Is.True, key);
+ Assert.That(results[key].IsPass, Is.True, key + ": " + results[key].Outcome + " " + results[key].Reason);
+ Assert.That(results[key].Input.Listing, Is.EqualTo(ContractListing.None), key + ": a used face is never a listed difference");
+ Assert.That(results[key].GatesFixture, Is.True, key + ": a used face is never informational");
+ }
+
+ OsReport report = fixtureId == TestSupport.Box ? box : two;
+ Assert.That(report.NonPass.Select(x => x.Key).Intersect(keys), Is.Empty);
+ }
+
+ ///
+ /// Review of 901e592a: Plasterboard's internal light reflectance unstated, absent or NaN. The writer writes the
+ /// external value on the zone face and reports it (SAM-OS-MAT-004); S09 keeps S-UNSPEC on exactly those zone-face
+ /// rows, never a match. S07 lists no default for an internal face, so a native value where SAM states none is an
+ /// EXPORT-DEFECT (FAIL) on exactly those rows: the inheritance is declared by the writer, not accepted by S07.
+ ///
+ [TestCase("absent")]
+ [TestCase("nan")]
+ public void AnUnstatedZoneFaceValue_KeepsItsUnspecifiedProvenance(string what)
+ {
+ AnalyticalModel model = TestSupport.Load(TestSupport.Box);
+ Core.MaterialLibrary materialLibrary = model.MaterialLibrary;
+ Core.OpaqueMaterial plasterboard = materialLibrary.GetMaterials().OfType().Single(x => x.Name == "FIX_Plasterboard");
+ if (what == "nan")
+ {
+ plasterboard.SetValue(OpaqueMaterialParameter.InternalLightReflectance, double.NaN);
+ }
+ else
+ {
+ plasterboard.RemoveValue(OpaqueMaterialParameter.InternalLightReflectance);
+ }
+
+ materialLibrary.Add(plasterboard);
+ model = new AnalyticalModel(model, model.AdjacencyCluster, materialLibrary, model.ProfileLibrary);
+ string label = "box-unstated-light-" + what;
+ OsReport report = TestSupport.Pipeline(TestSupport.Write(model, label), TestSupport.Box, label, out string run);
+
+ Assert.That(report.Inputs["writerDiagnostics"], Does.Contain("Information SAM-OS-MAT-004 Zone face: InternalLightReflectance not stated"));
+ Dictionary results = LayerResults(run, TestSupport.Box);
+ List zoneLight = ZoneFaceKeys(TestSupport.Box).Where(x => x.EndsWith(".internalLightReflectance", StringComparison.Ordinal)).Select(x => results[x]).ToList();
+ List unstated = zoneLight.Where(x => x.Input.Expected.State == SamInputState.Unspecified).ToList();
+ Assert.That(unstated.Count, Is.EqualTo(5), "the four walls and the roof have Plasterboard on the zone face; the floor has Screed");
+ Assert.That(unstated.All(x => !x.IsPass && x.Outcome == EquivalenceOutcome.ExportDefect), Is.True, string.Join(" | ", unstated.Select(x => x.Input.ObjectKey + " " + x.Outcome)));
+ Assert.That(zoneLight.Except(unstated).All(x => x.IsPass), Is.True, "the Screed zone face is unaffected");
+
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.Fail), report.Status);
+ List failed = report.NonPass.Where(x => x.Status == GateStatus.Fail.ToString()).ToList();
+ Assert.That(failed.Select(x => x.Key).OrderBy(x => x), Is.EqualTo(unstated.Select(x => x.Input.ObjectKey).OrderBy(x => x)), "exactly the unstated zone-face rows fail");
+ Assert.That(failed.All(x => x.Reason.Contains("S-UNSPEC")), Is.True);
+ }
+
+ /// A copy of a fixture run whose IDF is changed by , read and compared fresh.
+ private static OsReport MutatedIdf(string source, string fixtureId, string label, Func mutate)
+ {
+ string run = TestSupport.NewDirectory(label);
+ Directory.CreateDirectory(run);
+ foreach (string file in new[] { "frozen-model.json", "weather.epw", "model.osm", "in.idf", "export.json" })
+ {
+ File.Copy(Path.Combine(source, file), Path.Combine(run, file));
+ }
+
+ string idfPath = Path.Combine(run, "in.idf");
+ string idf = File.ReadAllText(idfPath);
+ string mutated = mutate(idf, TestSupport.Load(fixtureId));
+ Assert.That(mutated, Is.Not.EqualTo(idf), "the mutation applied");
+ File.WriteAllText(idfPath, mutated);
+ ExportManifest manifest = ExportManifest.Load(Path.Combine(run, "export.json"));
+ manifest.Mutation = "test: " + label;
+ manifest.IdfSha256 = Hashing.Sha256(idfPath);
+ manifest.Save(Path.Combine(run, "export.json"));
+ return TestSupport.ReadAndCompare(run, fixtureId);
+ }
+
+ ///
+ /// Swapped-face regression: each zone-face variant given its material's external-face optics, which is what the
+ /// writer wrote before revision 8 (and what a selector choosing the wrong face would write). The external values
+ /// come from the frozen model, never from the writer. Exactly the zone-face rows SAM needs internal FAIL.
+ ///
+ [TestCase(TestSupport.Box, 18)]
+ [TestCase(TestSupport.Two, 33)]
+ public void ASwappedZoneFace_Fails_OnExactlyTheZoneFaceRows(string fixtureId, int failingRows)
+ {
+ OsReport report = MutatedIdf(fixtureId == TestSupport.Box ? boxRun : twoRun, fixtureId, "swapped-zone-face-" + fixtureId, (idf, model) =>
+ {
+ foreach (IdfObject variant in IdfText.Parse(idf).Where(x => x.Type == "Material" && x.Name.Contains("_Internal")))
+ {
+ Core.OpaqueMaterial material = model.MaterialLibrary.GetMaterials().OfType().Single(x => variant.Name.Contains("_" + x.Name + "_"));
+ material.TryGetValue(OpaqueMaterialParameter.ExternalEmissivity, out double emissivity);
+ material.TryGetValue(OpaqueMaterialParameter.ExternalSolarReflectance, out double solar);
+ material.TryGetValue(OpaqueMaterialParameter.ExternalLightReflectance, out double light);
+ idf = Cli.Negative.SetField(idf, "Material", variant.Name, "Thermal Absorptance", emissivity.ToString("R", System.Globalization.CultureInfo.InvariantCulture));
+ idf = Cli.Negative.SetField(idf, "Material", variant.Name, "Solar Absorptance", (1 - solar).ToString("R", System.Globalization.CultureInfo.InvariantCulture));
+ idf = Cli.Negative.SetField(idf, "Material", variant.Name, "Visible Absorptance", (1 - light).ToString("R", System.Globalization.CultureInfo.InvariantCulture));
+ }
+
+ return idf;
+ });
+
+ Assert.That(report.ExitCode, Is.EqualTo(ReadBackExit.Fail), report.Status);
+ List failed = report.NonPass.Where(x => x.Status == GateStatus.Fail.ToString()).ToList();
+ List zoneFaces = ZoneFaceKeys(fixtureId);
+ Assert.That(failed.Count, Is.EqualTo(failingRows), string.Join(" | ", failed.Select(x => x.Key)));
+ Assert.That(failed.All(x => x.Quantity == "3a" && zoneFaces.Contains(x.Key) && x.Key.Contains(".internal")), Is.True, string.Join(" | ", failed.Select(x => x.Key + " " + x.Reason)));
+ Assert.That(report.Consistency, Is.Empty);
+ }
+
+ ///