diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs index bc86829..c085338 100644 --- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs +++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs @@ -949,11 +949,35 @@ internal static OpenStudioSimulationResultSet ExtractResultSet(string sqlPath, O // Zone identity normalisation: Ideal Loads variables key on the system name, // zone-level variables on the ThermalZone name, enclosure variables on the - // Space name — all end with the same deterministic SAM Guid suffix. Every - // per-zone dictionary is remapped onto the energy key so the result set has - // ONE zone identity. + // Space name — all share the same deterministic "_" + // identity after their SAM__ prefix. Every per-zone dictionary is remapped + // onto the energy key so the result set has ONE zone identity. List zoneKeys = new List(loadSummary.ZoneHeating.Keys); + Dictionary peopleGains = ReadAnnualEnergy(sqlPath, "Zone People Total Heating Energy"); + Dictionary lightingGains = ReadAnnualEnergy(sqlPath, "Zone Lights Total Heating Energy"); + Dictionary equipmentGains = ReadAnnualEnergy(sqlPath, "Zone Electric Equipment Total Heating Energy"); + // Zone-window-solar variables are Enclosure-scoped in current EnergyPlus + // ("Zone Windows ..." was renamed). + Dictionary windowSolarGains = ReadAnnualEnergy(sqlPath, "Enclosure Windows Total Transmitted Solar Radiation Energy"); + + // Only identities proven unique by the native zone inventory, and never numbered in + // any key read here, are remapped (see UniqueZoneIdentities). + HashSet uniqueIdentities = UniqueZoneIdentities( + ReadZoneNames(sqlPath), + loadSummary.ZoneHeating.Keys, + loadSummary.ZoneCooling.Keys, + unmetHeatingHours?.Keys, + unmetCoolingHours?.Keys, + peopleGains?.Keys, + lightingGains?.Keys, + equipmentGains?.Keys, + windowSolarGains?.Keys, + infiltrationGains?.Keys, + temperatureSeries?.Keys, + operativeTemperatureSeries?.Keys, + relativeHumiditySeries?.Keys); + return new OpenStudioSimulationResultSet( loadSummary.ZoneHeating, loadSummary.ZoneCooling, @@ -965,20 +989,18 @@ internal static OpenStudioSimulationResultSet ExtractResultSet(string sqlPath, O peakHeatingHourTotal, peakCoolingLoadTotal, peakCoolingHourTotal, - NormalizeKeys(unmetHeatingHours, zoneKeys), - NormalizeKeys(unmetCoolingHours, zoneKeys), - NormalizeKeys(ReadAnnualEnergy(sqlPath, "Zone People Total Heating Energy"), zoneKeys), - NormalizeKeys(ReadAnnualEnergy(sqlPath, "Zone Lights Total Heating Energy"), zoneKeys), - NormalizeKeys(ReadAnnualEnergy(sqlPath, "Zone Electric Equipment Total Heating Energy"), zoneKeys), - // Zone-window-solar variables are Enclosure-scoped in current EnergyPlus - // ("Zone Windows ..." was renamed). - NormalizeKeys(ReadAnnualEnergy(sqlPath, "Enclosure Windows Total Transmitted Solar Radiation Energy"), zoneKeys), - NormalizeKeys(infiltrationGains, zoneKeys), + NormalizeKeys(unmetHeatingHours, zoneKeys, uniqueIdentities), + NormalizeKeys(unmetCoolingHours, zoneKeys, uniqueIdentities), + NormalizeKeys(peopleGains, zoneKeys, uniqueIdentities), + NormalizeKeys(lightingGains, zoneKeys, uniqueIdentities), + NormalizeKeys(equipmentGains, zoneKeys, uniqueIdentities), + NormalizeKeys(windowSolarGains, zoneKeys, uniqueIdentities), + NormalizeKeys(infiltrationGains, zoneKeys, uniqueIdentities), ReadAnnualEnergy(sqlPath, "Zone Ideal Loads Outdoor Air Sensible Heating Energy"), ReadAnnualEnergy(sqlPath, "Zone Ideal Loads Outdoor Air Sensible Cooling Energy"), - NormalizeKeys(temperatureSeries, zoneKeys), - NormalizeKeys(operativeTemperatureSeries, zoneKeys), - NormalizeKeys(relativeHumiditySeries, zoneKeys), + NormalizeKeys(temperatureSeries, zoneKeys, uniqueIdentities), + NormalizeKeys(operativeTemperatureSeries, zoneKeys, uniqueIdentities), + NormalizeKeys(relativeHumiditySeries, zoneKeys, uniqueIdentities), runtimeSeconds, warningCount, severeCount, @@ -992,61 +1014,226 @@ internal static OpenStudioSimulationResultSet ExtractResultSet(string sqlPath, O } /// - /// Remaps report keys onto the energy-dictionary zone keys by their shared deterministic - /// SAM Guid suffix (last 8 characters, case-insensitive): Ideal Loads variables key on - /// the system name, zone-level variables on the ThermalZone name, enclosure variables on - /// the Space name — the result set exposes ONE zone identity. Unmatched keys pass - /// through unchanged. + /// SAM_<Type>_ prefixes of the generated names that report per-zone results: Ideal Loads + /// variables key on the system name, zone-level variables on the ThermalZone name, + /// enclosure variables on the Space name. + /// + private static readonly string[] zoneIdentityPrefixes = new string[] { "SAM_IdealLoads_", "SAM_ThermalZone_", "SAM_Space_" }; + + /// + /// Remaps report keys onto the energy-dictionary zone keys by their shared zone identity + /// (see ; case-insensitive) — the result set exposes ONE zone + /// identity. A key is remapped only when its identity is proven unique + /// () and it matches exactly one zone key; otherwise it + /// passes through unchanged: it is never guessed onto a zone. When two report keys resolve + /// to the same zone key neither is remapped, so no value overwrites another zone's. /// - private static Dictionary NormalizeKeys(Dictionary source, List zoneKeys) + /// + /// A key left under its own name is visible in the result set and its zone's value stays + /// missing (never another zone's value): the SAM mapping looks values up by the exact + /// Ideal Loads name, so for example the space's UnmetHours is simply not set. + /// + private static Dictionary NormalizeKeys(Dictionary source, List zoneKeys, HashSet uniqueIdentities) { if (source == null) { return null; } - Dictionary result = new Dictionary(); - foreach (KeyValuePair keyValuePair in source) + Dictionary targets = new Dictionary(); + Dictionary targetCounts = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + foreach (string key in source.Keys) + { + string target = NormalizeKey(key, zoneKeys, uniqueIdentities); + targets[key] = target; + targetCounts[target] = (targetCounts.TryGetValue(target, out int count) ? count : 0) + 1; + } + + Dictionary result = new Dictionary(); + foreach (KeyValuePair keyValuePair in source) { - result[NormalizeKey(keyValuePair.Key, zoneKeys)] = keyValuePair.Value; + string target = targets[keyValuePair.Key]; + result[targetCounts[target] > 1 ? keyValuePair.Key : target] = keyValuePair.Value; } return result; } - private static Dictionary NormalizeKeys(Dictionary source, List zoneKeys) + private static string NormalizeKey(string key, List zoneKeys, HashSet uniqueIdentities) { - if (source == null) + if (key == null || zoneKeys == null || zoneKeys.Count == 0 || uniqueIdentities == null) { - return null; + return key; } - Dictionary result = new Dictionary(); - foreach (KeyValuePair keyValuePair in source) + string identity = ZoneIdentity(key, out bool numbered); + if (identity == null || numbered || !uniqueIdentities.Contains(identity)) { - result[NormalizeKey(keyValuePair.Key, zoneKeys)] = keyValuePair.Value; + return key; } + string result = null; + foreach (string zoneKey in zoneKeys) + { + string zoneIdentity = ZoneIdentity(zoneKey, out bool zoneNumbered); + if (!string.Equals(zoneIdentity, identity, System.StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (result != null || zoneNumbered) + { + // Ambiguous: more than one zone carries this identity. + return key; + } + + result = zoneKey; + } + + return result ?? key; + } + + /// + /// Zone identities (see ) proven to belong to exactly one room: + /// carried by exactly one, unnumbered, name in the native zone inventory + /// (, the SQL Zones table, which lists every room whatever + /// outputs were requested) and by no numbered name among . + /// OpenStudio numbers a repeated name per object type, so an unnumbered Ideal Loads name can + /// belong to the room whose ThermalZone/Space names are numbered (an unconditioned room with + /// the same identity has no Ideal Loads); a numbered name is evidence of a collision only and + /// never establishes which room a name belongs to. No inventory (table absent or unreadable): + /// uniqueness cannot be established and the result is empty. + /// + private static HashSet UniqueZoneIdentities(List zoneNames, params IEnumerable[] keyCollections) + { + HashSet result = new HashSet(System.StringComparer.OrdinalIgnoreCase); + if (zoneNames == null) + { + return result; + } + + HashSet colliding = new HashSet(System.StringComparer.OrdinalIgnoreCase); + foreach (string zoneName in zoneNames) + { + string identity = ZoneIdentity(zoneName, out bool numbered); + if (identity == null) + { + continue; + } + + if (numbered || !result.Add(identity)) + { + colliding.Add(identity); + } + } + + foreach (IEnumerable keys in keyCollections) + { + if (keys == null) + { + continue; + } + + foreach (string key in keys) + { + string identity = ZoneIdentity(key, out bool numbered); + if (identity != null && numbered) + { + colliding.Add(identity); + } + } + } + + result.ExceptWith(colliding); return result; } - private static string NormalizeKey(string key, List zoneKeys) + /// + /// Reads the native zone inventory (SQL Zones.ZoneName, one row per EnergyPlus zone). + /// Null when the file, the table or the column cannot be read, so callers can tell "no + /// inventory" from an empty one. + /// + private static List ReadZoneNames(string sqlPath) { - if (key == null || zoneKeys == null || zoneKeys.Count == 0 || key.Length < 8) + if (string.IsNullOrWhiteSpace(sqlPath) || !File.Exists(sqlPath)) { - return key; + return null; } - string suffix = key.Substring(key.Length - 8); - foreach (string zoneKey in zoneKeys) + try + { + using (System.Data.SQLite.SQLiteConnection connection = new System.Data.SQLite.SQLiteConnection(new System.Data.SQLite.SQLiteConnectionStringBuilder { DataSource = sqlPath, ReadOnly = true, FailIfMissing = true }.ConnectionString)) + { + connection.Open(); + + using (System.Data.SQLite.SQLiteCommand exists = connection.CreateCommand()) + { + exists.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'Zones'"; + if (System.Convert.ToInt32(exists.ExecuteScalar()) == 0) + { + return null; + } + } + + List result = new List(); + using (System.Data.SQLite.SQLiteCommand command = connection.CreateCommand()) + { + command.CommandText = "SELECT ZoneName FROM Zones ORDER BY ZoneIndex"; + using (System.Data.SQLite.SQLiteDataReader reader = command.ExecuteReader()) + { + while (reader.Read()) + { + result.Add(ReadString(reader, 0)); + } + } + } + + return result; + } + } + catch (System.Exception) + { + return null; + } + } + + /// + /// The stable identity of a generated zone-level name + /// (): the part + /// after the SAM_<Type>_ prefix, "<SanitizedName>_<GuidFirst8>" or just + /// "<GuidFirst8>" for an unnamed object. Null when the name has no known prefix or no + /// trailing Guid suffix. A " <digits>" ending is OpenStudio's de-duplication of a + /// repeated name (generated names never contain spaces): it is removed from the identity and + /// reported through , so callers can treat the identity as + /// colliding instead of matching it. + /// + private static string ZoneIdentity(string name, out bool numbered) + { + numbered = false; + if (name != null) + { + int index = name.LastIndexOf(' '); + if (index > 0 && index < name.Length - 1 && System.Linq.Enumerable.All(name.Substring(index + 1), char.IsDigit)) + { + name = name.Substring(0, index); + numbered = true; + } + } + + if (!Query.TryGetGuidSuffix(name, out _)) + { + return null; + } + + foreach (string prefix in zoneIdentityPrefixes) { - if (zoneKey != null && zoneKey.Length >= 8 && string.Equals(zoneKey.Substring(zoneKey.Length - 8), suffix, System.StringComparison.OrdinalIgnoreCase)) + if (name.Length > prefix.Length && name.StartsWith(prefix, System.StringComparison.OrdinalIgnoreCase)) { - return zoneKey; + return name.Substring(prefix.Length); } } - return key; + return null; } private static void FillPeaks(Dictionary>> series, Dictionary peakLoad, Dictionary peakHour) { diff --git a/documentation/F-S11-1-Zone-Result-Identity-PR.md b/documentation/F-S11-1-Zone-Result-Identity-PR.md new file mode 100644 index 0000000..1687fa5 --- /dev/null +++ b/documentation/F-S11-1-Zone-Result-Identity-PR.md @@ -0,0 +1,135 @@ +# F-S11-1: OpenStudio result association by zone identity, not Guid suffix + +Production fix for finding F-S11-1 of S11 (SAM_OpenStudio#34, Draft, review head `9ce146cd`; record +`documentation/S11-OpenStudio-ResultsRoundTrip-PR.md` on that branch). Owner authorised this bounded correction only +(2026-10-24, from the reviewed proposal `NEXT-ROOM-MATCHING.md`). + +Branch `fix/s11-zone-result-identity`, base `sow/2026-Q4` (`ec204cbb`). **Draft, for independent review. Not merged.** +- **Tested content (revision 3, inventory-based collision detection):** `b8b38e6ed4a622ffacd914a22b027ad245d3af7b` (code + tests). Later commits on + this branch are record-only. +- **Revision 2:** tested content `486688204c850e23330e3626473f4c05d7736007`, reviewed head + `77dd095b46a1f0531fc69c79777edb9263f00b6b` (Build 40094711400 and SPDX 40094711395 succeeded). Independent review + (parent-local `.review-s11-zone-identity-r2-20261024/REVIEW.md`): REVISE — collision detection used only the keys of + the result variables read, so a room with no such output was invisible (`probe-inventory.ps1`: the unconditioned + room's window solar was assigned to the conditioned room). Corrected below. +- **Revision 1:** tested content `13e79b045ee83a25e4ef7a60cc9ad6d060f57834`, reviewed head + `a25b4f5aafe71f4b2b844b358231abeb89fd1220` (Build 40080452181 and SPDX 40080452124 succeeded). Independent review + (parent-local `.review-s11-zone-identity-20261024/REVIEW.md`): REVISE — R1 numbered-name false match, R2 evidence + wording. Both corrected in revision 2. + +## Defect + +`OpenStudioSimulationRunner.NormalizeKey` remapped zone-level report keys (ThermalZone name) and enclosure keys (Space +name) onto the Ideal Loads energy keys by the **last 8 characters** and returned the **first** match. Rooms whose SAM +Guids share the first 8 hex digits (signed-off `s08-fix-two`: FixA and FixB, both `5080c000`) collided: one room's +not-met hours, gains, window solar, infiltration and temperature/RH series overwrote the other's, and the other room +got none. + +## Change + +`SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs` only (the one shared helper, used by +every `NormalizeKeys` call in `ExtractResultSet`: unmet heating/cooling hours, people/lights/equipment gains, enclosure +window solar, net infiltration, air/operative temperature and RH series). + +- **Identity.** Matching uses the stable identity of the generated name `SAM___`: the + part after a known `SAM_IdealLoads_` / `SAM_ThermalZone_` / `SAM_Space_` prefix, case-insensitive (EnergyPlus + uppercases keys). The name must end with a valid 8-hex Guid suffix (`Query.TryGetGuidSuffix`). Unnamed objects reduce + to the Guid prefix alone; sanitization and underscores are those of the writer (`Core.OpenStudio.Query.OpenStudioName`), + which is unchanged. +- **Uniqueness from the native zone inventory (revision 3).** `ExtractResultSet` reads the SQL `Zones.ZoneName` column + (new private `ReadZoneNames`, same reader pattern as `ReadZoneSizes`; EnergyPlus writes one row per zone whatever + outputs were requested). `UniqueZoneIdentities` keeps an identity only if exactly one zone carries it, that zone name + is unnumbered, and no key read by the extractor (Ideal Loads heating/cooling and every normalised dictionary) carries + it numbered; report keys supplement, never replace, the inventory. A key is remapped only when its identity is in that + set, the key itself is unnumbered and exactly one unnumbered zone key matches. No `Zones` table, an unreadable one, an + empty one, a NULL name or an inventory that lacks the room: uniqueness is not established and keys stay unmapped. + Revision 2 inferred uniqueness from absent optional outputs; collisions now do not depend on unmet-time or + temperature output, nor on `extractTimeSeries`. +- **Numbered names.** OpenStudio de-duplicates a repeated name per object type by appending ` `; generated names + never contain spaces. A numbered name is evidence of a collision only, never a match: an unconditioned room created + first owns the unnumbered ThermalZone/Space names while the conditioned room gets `... 1` for those but the unnumbered + Ideal Loads name. +- **No overwrite.** If two report keys of one variable resolve to the same zone key, neither is remapped. +- The two identical `NormalizeKeys` overloads became one generic method that also takes the unique identities. No + public API, schema, writer name or Guid format changed. +- Key formats: source-backed by `OpenStudioName`/`SanitizeName`, and observed in a genuine engine output inspected on + this machine (machine-local, not committed): the C6 simulation test output + `SAM_OpenStudio/tests/SAM.Analytical.OpenStudio.Tests/bin/x64/Release/net8.0/c6_collision/run/eplusout.eso` + (EnergyPlus 25.2.0-cf7368216c, 2026-10-21; `eplusout.end`: "EnergyPlus Completed Successfully-- 12 Warning; 0 Severe + Errors") reports `SAM_IDEALLOADS_SPACE_SINGLE_CCCCCCCC` for Ideal Loads variables, + `SAM_THERMALZONE_SPACE_SINGLE_CCCCCCCC` for not-met time, gains, infiltration and T/T_op/RH, and + `SAM_SPACE_SPACE_SINGLE_CCCCCCCC` for `Enclosure Windows Total Transmitted Solar Radiation Energy`; its + `eplusout.sql` has `CREATE TABLE Zones (ZoneIndex INTEGER PRIMARY KEY, ZoneName TEXT, ...)` (no Spaces table). + Revision 1 wrongly cited `.review-s11-r1-correction-20261024/extra-frequency-two/work/run/eplusout.eso` as a real + run; it is synthetic review output and no S11 engine run exists. + +How ambiguity is visible (existing conventions, no new diagnostic channel): the unmapped key stays in the result set +under its ThermalZone/Space name, and the SAM mapping (`ToSAM_SpaceSimulationResults`, exact Ideal Loads name lookup) +leaves that room's value unset (for example no `UnmetHours`) rather than another room's value. + +## Tests + +`tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs` (synthetic EnergyPlus-schema SQL with a native +`Zones` inventory, offline, through `ExtractLoads` + `ExtractResultSet` and, for the fixture case, +`ToSAM_SpaceSimulationResults`). 17 test cases: + +| Test | Proves | +| --- | --- | +| `SharedGuidPrefix_FixAFixB_EachRoomKeepsItsOwnValues` | Original fixture identities (FixA `5080c000-…c0a1`, FixB `5080c000-…c0b1`) keep distinct unmet hours, gains, solar, infiltration and T/T_op/RH series; each SAM space gets its own UnmetHours | +| `SharedGuidPrefix_DistinctNames_NoUnmetOutput_StillMaps` (rev. 3, series on/off) | Distinct names sharing a prefix still map without not-met output, with and without series | +| `UniquePrefixes_AndDuplicateDisplayNames_MapAsBefore` | Unique prefixes unchanged; same display name with distinct prefixes still maps | +| `SanitizedUnderscoreAndEmptyNames_SharedPrefix_Match` | Whitespace sanitization, underscores in names and the empty name, all sharing one prefix | +| `SuffixOnlyMatch_IsNotGuessedOntoAnotherRoom` | An unconditioned zone sharing FixA's prefix no longer overwrites FixA; non-SAM keys pass through | +| `AmbiguousIdentity_LeavesKeysUnmapped_NeverOverwrites` | Both colliding rooms present: neither assigned, both values kept | +| `NumberedSource_OnlyUnnumberedTarget_StaysUnmapped` | Numbered ThermalZone/Space scalars and series stay under their own keys, even with an inventory that omits the numbered room (report-key evidence supplements) | +| `NumberedTarget_OnlyUnnumberedSource_StaysUnmapped` | Unnumbered scalar and series are not moved onto a numbered Ideal Loads key | +| `CollidingUnconditionedRoom_OnlySourceEntry_IsNotAssignedToTheConditionedRoom` | Window solar reported only for the unconditioned colliding room is not assigned | +| `HiddenCollision_NativeInventoryOnly_IsNotAssignedToTheConditionedRoom` (rev. 3, series on/off) | One conditioned + one colliding unconditioned room, collision visible only in `Zones`; no not-met output, no numbered result key: window solar (Space), people gains (ThermalZone) and the temperature series stay unmapped | +| `MissingIdentityMetadata_NothingIsRemapped` (rev. 3: no Zones table, empty table, NULL ZoneName, other zone only) | Uniqueness cannot be established: nothing is remapped, energies still read | +| `TwoReportKeysOnOneRoom_NeitherOverwritesTheOther` | Two report keys resolving to one zone: no overwrite | + +Fail-before evidence: +- Revision 1: against the original Q4 runner 5 of the first 6 failed; the unique-prefix test passes on both. +- Revision 2: the three numbered-name tests failed 3/3 on revision-1 code `13e79b04`. +- Revision 3: with the new and inventory-carrying tests built against revision-2 code `48668820`: 6 failed / 11 passed — + both `HiddenCollision_…` cases and all four `MissingIdentityMetadata_…` cases failed; the distinct-name/no-unmet + cases passed on both, as intended. After the correction: 17 passed. +- The reviewer's `probe-inventory.ps1`, was **not rerun**: it needs PowerShell 7 (the test binaries are .NET 8) and none is installed on this machine (Windows PowerShell 5.1 cannot load them). Its scenario (Zones lists `SAM_THERMALZONE_FIXA_5080C000` and `... 1`, no numbered result key, the unconditioned room's window solar as the only solar entry) is reproduced by both `HiddenCollision_…` cases, which failed on `48668820` and pass now. + +## Validation (local, this machine) + +Release x64 with `APPDATA` redirected to a temp folder; no Grasshopper project built or deployed. The worktree's +`..\..\..\SAM\build` and `..\..\..\SAM_SQLite\build` hint paths were resolved by local directory junctions +(`.worktrees\SAM`, `.worktrees\SAM_SQLite`, machine-local) to the existing sibling builds (SAM `sow/2026-Q4` +`b7c2c36a`). + +- Fresh rebuild: `dotnet build tests/SAM.Analytical.OpenStudio.Tests/SAM.Analytical.OpenStudio.Tests.csproj -c Release + -p:Platform=x64 --no-incremental` (builds SAM.Core/Geometry/Analytical.OpenStudio and the tests): 0 errors; all output DLLs rewritten by this rebuild (15:29:55). +- `dotnet test … --no-build --filter "FullyQualifiedName~ZoneResultIdentityTests"`: 17 passed / 0 failed / 0 skipped. +- `dotnet test … --no-build --filter "TestCategory!=Simulation"` (all offline suites, including the C5 + `AddResultsBySqlTests` that run `ExtractResultSet` on synthetic SQL with a Zones table): 488 passed / 0 failed / 2 skipped (the two real-SQL reproduction fixtures are not committed; 6 m 58 s). +- No benchmark project calls `ExtractLoads`/`ExtractResultSet`/`NormalizeKeys`; not rebuilt. Hosted CI builds the + solution and runs the existing S09 tests; it is not evidence that these main-project tests ran. +- No EnergyPlus, OpenStudio CLI, Tas or other engine run. S09/S10 evidence and S11 fixtures untouched. + +GitHub checks: see the PR description for the final head. + +## Compatibility and limits + +- Genuine EnergyPlus SQL always has a `Zones` table, so models whose 8-character prefixes are unique, and colliding + prefixes with distinct names, map as before. Same name + same prefix is left unmapped. An SQL file without a readable + `Zones` table (not produced by EnergyPlus) now gets no remapping at all instead of suffix guessing. +- Residual, separate scope (not changed): `Modify/AddResults.cs` `LookupSpace`/panel lookups resolve design-day + (ZoneSizes) and surface results by the 8-character Guid suffix first and take the first SAM object with that suffix. + They can misassociate whenever prefixes collide, **even with different room or panel names** (for example FixA/FixB); + this is not limited to equal names. The annual-family "matched no SAM space" diagnostic uses the same lookup. Also, + `ToSAM_SpaceSimulationResults` looks values up by the exact generated Ideal Loads name, so two rooms with the same + name AND prefix both read the unnumbered key. Both need separately scoped work. +- Out of scope (unchanged): F-S11-2 fractional unmet hours (SAM integer `UnmetHours`), OccupiedUnmetHours, writer + names, fixtures, S11 tolerances/status, UI, CI. + +## Next step + +Independent review of revision 3 (another model/session, High, new session, Plan mode, read-only). After acceptance and +owner-approved merge, reconcile S11 #34's pinned `Finding_SharedGuidPrefix_…` test with this fix as a separately +reviewed step; F-S11-2 and the AddResults suffix lookups follow under their own scope. diff --git a/tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs b/tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs new file mode 100644 index 0000000..76f7807 --- /dev/null +++ b/tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs @@ -0,0 +1,423 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors + +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace SAM.Analytical.OpenStudio.Tests +{ + /// + /// F-S11-1: zone-level and enclosure report keys (ThermalZone / Space names) are remapped onto + /// the Ideal Loads energy keys by the identity of the generated name ("<SanitizedName>_<GuidFirst8>" + /// after SAM_<Type>_), not by the 8-character Guid suffix alone. Two rooms whose Guids share the + /// first 8 hex digits (the signed-off s08-fix-two FixA/FixB, 5080c000) must keep their own values; + /// an absent or ambiguous identity is never guessed onto a room and never overwrites one. + /// Synthetic EnergyPlus-schema SQL, offline. + /// + [TestFixture] + public class ZoneResultIdentityTests + { + private static readonly Guid fixA = Guid.Parse("5080c000-0000-0000-0000-00000000c0a1"); + private static readonly Guid fixB = Guid.Parse("5080c000-0000-0000-0000-00000000c0b1"); + private static readonly Guid other = Guid.Parse("6181d111-0000-0000-0000-00000000c0b1"); + + private const string HeatingEnergy = "Zone Ideal Loads Supply Air Total Heating Energy"; + private const string CoolingEnergy = "Zone Ideal Loads Supply Air Total Cooling Energy"; + private const string HeatingNotMet = "Zone Heating Setpoint Not Met Time"; + private const string CoolingNotMet = "Zone Cooling Setpoint Not Met Time"; + private const string People = "Zone People Total Heating Energy"; + private const string Lights = "Zone Lights Total Heating Energy"; + private const string Equipment = "Zone Electric Equipment Total Heating Energy"; + private const string WindowSolar = "Enclosure Windows Total Transmitted Solar Radiation Energy"; + private const string Infiltration = "Zone Infiltration Sensible Heat Gain Energy"; + private const string AirTemperature = "Zone Mean Air Temperature"; + private const string OperativeTemperature = "Zone Operative Temperature"; + private const string RelativeHumidity = "Zone Air Relative Humidity"; + + private static string Name(string type, string name, Guid guid) + { + // EnergyPlus reports keys uppercased. + return Core.OpenStudio.Query.OpenStudioName(type, name, guid).ToUpperInvariant(); + } + + /// EnergyPlus zone names (Zones.ZoneName) of generated rooms: the ThermalZone names. + private static string[] Zones(params (string Name, Guid Guid)[] rooms) + { + return rooms.Select(x => Name("ThermalZone", x.Name, x.Guid)).ToArray(); + } + + /// + /// Writes one 24-hour annual run period; each (key, variable) series is constant at its value. The native zone + /// inventory (Zones table, as EnergyPlus writes it) lists ; null omits the table. + /// + private static string CreateSql(string fileName, IEnumerable zoneNames, IEnumerable<(string Key, string Variable, double Value)> series) + { + string sqlPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, fileName); + if (File.Exists(sqlPath)) + { + File.Delete(sqlPath); + } + + using (System.Data.SQLite.SQLiteConnection connection = new System.Data.SQLite.SQLiteConnection("Data Source=" + sqlPath)) + { + connection.Open(); + using (System.Data.SQLite.SQLiteCommand command = connection.CreateCommand()) + { + void Exec(string sql) + { + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + Exec("CREATE TABLE EnvironmentPeriods (EnvironmentPeriodIndex INTEGER PRIMARY KEY, EnvironmentName TEXT, EnvironmentType INTEGER)"); + Exec("CREATE TABLE Time (TimeIndex INTEGER PRIMARY KEY, Year INTEGER, Month INTEGER, Day INTEGER, Hour INTEGER, Minute INTEGER, Dst INTEGER, EnvironmentPeriodIndex INTEGER)"); + Exec("CREATE TABLE ReportDataDictionary (ReportDataDictionaryIndex INTEGER PRIMARY KEY, KeyValue TEXT, Name TEXT, Units TEXT)"); + Exec("CREATE TABLE ReportData (ReportDataIndex INTEGER PRIMARY KEY, ReportDataDictionaryIndex INTEGER, TimeIndex INTEGER, Value REAL)"); + Exec("INSERT INTO EnvironmentPeriods VALUES (1, 'SAM_RUNPERIOD_ANNUAL', 3)"); + if (zoneNames != null) + { + Exec("CREATE TABLE Zones (ZoneIndex INTEGER PRIMARY KEY, ZoneName TEXT, FloorArea REAL, Volume REAL)"); + int zoneIndex = 0; + foreach (string zoneName in zoneNames) + { + zoneIndex++; + Exec(string.Format(System.Globalization.CultureInfo.InvariantCulture, "INSERT INTO Zones VALUES ({0}, {1}, 20, 60)", zoneIndex, zoneName == null ? "NULL" : "'" + zoneName + "'")); + } + } + + for (int hour = 1; hour <= 24; hour++) + { + Exec(string.Format(System.Globalization.CultureInfo.InvariantCulture, "INSERT INTO Time VALUES ({0}, 2006, 1, 1, {0}, 0, 0, 1)", hour)); + } + + int dictionaryIndex = 0; + int reportDataIndex = 0; + foreach ((string key, string variable, double value) in series) + { + dictionaryIndex++; + Exec(string.Format(System.Globalization.CultureInfo.InvariantCulture, "INSERT INTO ReportDataDictionary VALUES ({0}, '{1}', '{2}', '')", dictionaryIndex, key, variable)); + for (int hour = 1; hour <= 24; hour++) + { + reportDataIndex++; + Exec(string.Format(System.Globalization.CultureInfo.InvariantCulture, "INSERT INTO ReportData VALUES ({0}, {1}, {2}, {3})", reportDataIndex, dictionaryIndex, hour, value)); + } + } + } + } + + return sqlPath; + } + + /// Every per-zone variable the result set normalises, for one generated room; values scale with . + private static IEnumerable<(string, string, double)> Room(string name, Guid guid, double scale) + { + string idealLoads = Name("IdealLoads", name, guid); + string thermalZone = Name("ThermalZone", name, guid); + string space = Name("Space", name, guid); + + yield return (idealLoads, HeatingEnergy, 1000 * scale); + yield return (idealLoads, CoolingEnergy, 500 * scale); + // Whole hours: a not-met hour in 1 of every 24 timesteps of value 'scale' gives 24 * scale hours per day. + yield return (thermalZone, HeatingNotMet, scale); + yield return (thermalZone, CoolingNotMet, 2 * scale); + yield return (thermalZone, People, 10 * scale); + yield return (thermalZone, Lights, 20 * scale); + yield return (thermalZone, Equipment, 30 * scale); + yield return (space, WindowSolar, 40 * scale); + yield return (thermalZone, Infiltration, 50 * scale); + yield return (thermalZone, AirTemperature, 18 + scale); + yield return (thermalZone, OperativeTemperature, 19 + scale); + yield return (thermalZone, RelativeHumidity, 40 + scale); + } + + private static OpenStudioSimulationResultSet Extract(string sqlPath, bool extractTimeSeries = true) + { + OpenStudioLoadSummary loadSummary = OpenStudioSimulationRunner.ExtractLoads(sqlPath, out string failureDetail); + Assert.That(loadSummary, Is.Not.Null, failureDetail); + + OpenStudioSimulationResultSet result = OpenStudioSimulationRunner.ExtractResultSet(sqlPath, loadSummary, extractTimeSeries, 0, 0, 0, 0); + Assert.That(result, Is.Not.Null); + return result; + } + + private static void AssertRoom(OpenStudioSimulationResultSet results, string name, Guid guid, double scale) + { + string key = Name("IdealLoads", name, guid); + string label = name + " (" + guid.ToString("N").Substring(0, 8) + ")"; + + Assert.That(results.UnmetHeatingHours[key], Is.EqualTo(24 * scale), label + " heating not-met"); + Assert.That(results.UnmetCoolingHours[key], Is.EqualTo(48 * scale), label + " cooling not-met"); + Assert.That(results.PeopleGains[key], Is.EqualTo(Core.OpenStudio.Query.JoulesToKilowattHours(24 * 10 * scale)).Within(1e-12), label + " people"); + Assert.That(results.LightingGains[key], Is.EqualTo(Core.OpenStudio.Query.JoulesToKilowattHours(24 * 20 * scale)).Within(1e-12), label + " lights"); + Assert.That(results.EquipmentGains[key], Is.EqualTo(Core.OpenStudio.Query.JoulesToKilowattHours(24 * 30 * scale)).Within(1e-12), label + " equipment"); + Assert.That(results.WindowSolarGains[key], Is.EqualTo(Core.OpenStudio.Query.JoulesToKilowattHours(24 * 40 * scale)).Within(1e-12), label + " window solar (Space key)"); + Assert.That(results.InfiltrationGains[key], Is.EqualTo(Core.OpenStudio.Query.JoulesToKilowattHours(24 * 50 * scale)).Within(1e-12), label + " infiltration"); + if (results.TemperatureSeries == null) + { + // Series extraction off. + return; + } + + Assert.That(results.TemperatureSeries[key], Is.All.EqualTo(18 + scale), label + " air temperature"); + Assert.That(results.OperativeTemperatureSeries[key], Is.All.EqualTo(19 + scale), label + " operative temperature"); + Assert.That(results.RelativeHumiditySeries[key], Is.All.EqualTo(40 + scale), label + " relative humidity"); + } + + private static Space Space(string name, Guid guid) + { + return new Space(guid, name, new Geometry.Spatial.Point3D(0, 0, 1)); + } + + [Test] + public void SharedGuidPrefix_FixAFixB_EachRoomKeepsItsOwnValues() + { + string sqlPath = CreateSql("zone_identity_shared_prefix.sql", Zones(("FixA", fixA), ("FixB", fixB)), Room("FixA", fixA, 1).Concat(Room("FixB", fixB, 3))); + OpenStudioSimulationResultSet results = Extract(sqlPath); + + Assert.That(results.AnnualHeatingEnergy.Count, Is.EqualTo(2)); + AssertRoom(results, "FixA", fixA, 1); + AssertRoom(results, "FixB", fixB, 3); + Assert.That(results.UnmetHeatingHours.Count, Is.EqualTo(2), "no report key left behind or duplicated"); + + // Through the SAM mapping: each space carries its own not-met hours. + List spaceResults = results.ToSAM_SpaceSimulationResults(new List { Space("FixA", fixA), Space("FixB", fixB) }); + Assert.That(spaceResults.Count, Is.EqualTo(4)); + foreach ((Guid guid, double scale) in new[] { (fixA, 1.0), (fixB, 3.0) }) + { + SpaceSimulationResult heating = spaceResults.Single(x => x.Reference == guid.ToString("N") && x.LoadType() == LoadType.Heating); + Assert.That(heating.TryGetValue(Analytical.SpaceSimulationResultParameter.UnmetHours, out double unmet), Is.True); + Assert.That(unmet, Is.EqualTo(24 * scale)); + } + } + + [Test] + public void UniquePrefixes_AndDuplicateDisplayNames_MapAsBefore() + { + string sqlPath = CreateSql("zone_identity_unique_prefix.sql", Zones(("Office", fixA), ("Office", other)), Room("Office", fixA, 1).Concat(Room("Office", other, 2))); + OpenStudioSimulationResultSet results = Extract(sqlPath); + + AssertRoom(results, "Office", fixA, 1); + AssertRoom(results, "Office", other, 2); + } + + [Test] + public void SanitizedUnderscoreAndEmptyNames_SharedPrefix_Match() + { + // "Office 04" sanitizes to Office_04; "Office_04_A" keeps its underscores; an empty name is just the Guid prefix. + string sqlPath = CreateSql("zone_identity_sanitized.sql", Zones(("Office 04", fixA), ("Office_04_A", fixB), (string.Empty, Guid.Parse("5080c000-0000-0000-0000-0000000000e1"))), Room("Office 04", fixA, 1).Concat(Room("Office_04_A", fixB, 2)).Concat(Room(string.Empty, Guid.Parse("5080c000-0000-0000-0000-0000000000e1"), 4))); + OpenStudioSimulationResultSet results = Extract(sqlPath); + + Assert.That(Name("IdealLoads", string.Empty, fixA), Is.EqualTo("SAM_IDEALLOADS_5080C000")); + AssertRoom(results, "Office 04", fixA, 1); + AssertRoom(results, "Office_04_A", fixB, 2); + AssertRoom(results, string.Empty, Guid.Parse("5080c000-0000-0000-0000-0000000000e1"), 4); + } + + [Test] + public void SuffixOnlyMatch_IsNotGuessedOntoAnotherRoom() + { + // A zone with no Ideal Loads (unconditioned) shares FixA's Guid prefix; the old suffix match + // overwrote FixA's not-met hours with it. + List<(string, string, double)> series = Room("FixA", fixA, 1).ToList(); + series.Add((Name("ThermalZone", "Store", fixB), HeatingNotMet, 9)); + series.Add(("RADIANT ENCLOSURE 1", WindowSolar, 9)); + OpenStudioSimulationResultSet results = Extract(CreateSql("zone_identity_unmatched.sql", Zones(("FixA", fixA), ("Store", fixB)), series)); + + AssertRoom(results, "FixA", fixA, 1); + Assert.That(results.UnmetHeatingHours[Name("ThermalZone", "Store", fixB)], Is.EqualTo(24 * 9), "an unmatched key passes through unchanged"); + Assert.That(results.WindowSolarGains.ContainsKey("RADIANT ENCLOSURE 1"), Is.True, "a non-SAM key passes through unchanged"); + } + + [Test] + public void AmbiguousIdentity_LeavesKeysUnmapped_NeverOverwrites() + { + // Same sanitized name AND same Guid prefix: OpenStudio de-duplicates the second name with " 1". + string idealLoads = Name("IdealLoads", "FixA", fixA); + string thermalZone = Name("ThermalZone", "FixA", fixA); + OpenStudioSimulationResultSet results = Extract(CreateSql("zone_identity_ambiguous.sql", new[] { thermalZone, thermalZone + " 1" }, new List<(string, string, double)> + { + (idealLoads, HeatingEnergy, 1000), + (idealLoads + " 1", HeatingEnergy, 3000), + (thermalZone, HeatingNotMet, 1), + (thermalZone + " 1", HeatingNotMet, 3), + })); + + Assert.That(results.UnmetHeatingHours.ContainsKey(idealLoads), Is.False, "an ambiguous identity is never assigned to a room"); + Assert.That(results.UnmetHeatingHours.ContainsKey(idealLoads + " 1"), Is.False); + Assert.That(results.UnmetHeatingHours[thermalZone], Is.EqualTo(24)); + Assert.That(results.UnmetHeatingHours[thermalZone + " 1"], Is.EqualTo(72)); + } + + [Test] + public void NumberedSource_OnlyUnnumberedTarget_StaysUnmapped() + { + // The numbered report keys may belong to another, unconditioned room with the same generated name. The + // inventory deliberately omits that room: the numbered report keys alone must still refuse the match. + string idealLoads = Name("IdealLoads", "FixA", fixA); + string thermalZone = Name("ThermalZone", "FixA", fixA) + " 1"; + string space = Name("Space", "FixA", fixA) + " 1"; + OpenStudioSimulationResultSet results = Extract(CreateSql("zone_identity_numbered_source.sql", new[] { Name("ThermalZone", "FixA", fixA) }, new List<(string, string, double)> + { + (idealLoads, HeatingEnergy, 1000), + (thermalZone, HeatingNotMet, 9), + (space, WindowSolar, 40), + (thermalZone, AirTemperature, 21), + })); + + Assert.That(results.UnmetHeatingHours.ContainsKey(idealLoads), Is.False, "scalar"); + Assert.That(results.UnmetHeatingHours[thermalZone], Is.EqualTo(24 * 9)); + Assert.That(results.WindowSolarGains.ContainsKey(idealLoads), Is.False, "scalar, Space key"); + Assert.That(results.WindowSolarGains.ContainsKey(space), Is.True); + Assert.That(results.TemperatureSeries.ContainsKey(idealLoads), Is.False, "series"); + Assert.That(results.TemperatureSeries[thermalZone], Is.All.EqualTo(21)); + } + + [Test] + public void NumberedTarget_OnlyUnnumberedSource_StaysUnmapped() + { + string idealLoads = Name("IdealLoads", "FixA", fixA) + " 1"; + string thermalZone = Name("ThermalZone", "FixA", fixA); + OpenStudioSimulationResultSet results = Extract(CreateSql("zone_identity_numbered_target.sql", new[] { thermalZone, thermalZone + " 1" }, new List<(string, string, double)> + { + (idealLoads, HeatingEnergy, 1000), + (thermalZone, HeatingNotMet, 9), + (thermalZone, AirTemperature, 21), + })); + + Assert.That(results.UnmetHeatingHours.ContainsKey(idealLoads), Is.False, "scalar"); + Assert.That(results.UnmetHeatingHours[thermalZone], Is.EqualTo(24 * 9)); + Assert.That(results.TemperatureSeries.ContainsKey(idealLoads), Is.False, "series"); + Assert.That(results.TemperatureSeries[thermalZone], Is.All.EqualTo(21)); + } + + [Test] + public void CollidingUnconditionedRoom_OnlySourceEntry_IsNotAssignedToTheConditionedRoom() + { + // Room 1 (unconditioned, created first) owns the unnumbered ThermalZone/Space names; room 2 (conditioned) + // got "... 1" for those but the unnumbered Ideal Loads name, because only it has Ideal Loads. Window solar + // is reported for room 1 only; the numbered names seen in other variables are the evidence of the collision. + string idealLoads = Name("IdealLoads", "FixA", fixA); + string thermalZone = Name("ThermalZone", "FixA", fixA); + string space = Name("Space", "FixA", fixA); + OpenStudioSimulationResultSet results = Extract(CreateSql("zone_identity_unconditioned_collision.sql", new[] { thermalZone, thermalZone + " 1" }, new List<(string, string, double)> + { + (idealLoads, HeatingEnergy, 1000), + (thermalZone, HeatingNotMet, 5), + (thermalZone + " 1", HeatingNotMet, 7), + (space, WindowSolar, 40), + (thermalZone, AirTemperature, 15), + (thermalZone + " 1", AirTemperature, 21), + })); + + Assert.That(results.WindowSolarGains.ContainsKey(idealLoads), Is.False, "the only solar entry is the unconditioned room's"); + Assert.That(results.WindowSolarGains.ContainsKey(space), Is.True); + Assert.That(results.UnmetHeatingHours.ContainsKey(idealLoads), Is.False); + Assert.That(results.UnmetHeatingHours[thermalZone], Is.EqualTo(24 * 5)); + Assert.That(results.UnmetHeatingHours[thermalZone + " 1"], Is.EqualTo(24 * 7)); + Assert.That(results.TemperatureSeries.ContainsKey(idealLoads), Is.False); + Assert.That(results.TemperatureSeries[thermalZone], Is.All.EqualTo(15)); + } + + [TestCase(true)] + [TestCase(false)] + public void HiddenCollision_NativeInventoryOnly_IsNotAssignedToTheConditionedRoom(bool extractTimeSeries) + { + // The collision is visible only in the native zone inventory: no not-met-time output and no numbered + // result key. Room 1 (unconditioned) owns the unnumbered ThermalZone/Space names; room 2 (conditioned) + // is "... 1" in Zones but owns the unnumbered Ideal Loads name. + string idealLoads = Name("IdealLoads", "FixA", fixA); + string thermalZone = Name("ThermalZone", "FixA", fixA); + string space = Name("Space", "FixA", fixA); + OpenStudioSimulationResultSet results = Extract(CreateSql("zone_identity_hidden_collision_" + extractTimeSeries + ".sql", new[] { thermalZone, thermalZone + " 1" }, new List<(string, string, double)> + { + (idealLoads, HeatingEnergy, 1000), + (space, WindowSolar, 40), + (thermalZone, People, 10), + (thermalZone, AirTemperature, 15), + }), extractTimeSeries); + + Assert.That(results.UnmetHeatingHours, Is.Empty, "no not-met-time output in this run"); + Assert.That(results.WindowSolarGains.ContainsKey(idealLoads), Is.False, "scalar, Space key"); + Assert.That(results.WindowSolarGains[space], Is.EqualTo(Core.OpenStudio.Query.JoulesToKilowattHours(24 * 40)).Within(1e-12)); + Assert.That(results.PeopleGains.ContainsKey(idealLoads), Is.False, "scalar, ThermalZone key"); + Assert.That(results.PeopleGains.ContainsKey(thermalZone), Is.True); + if (extractTimeSeries) + { + Assert.That(results.TemperatureSeries.ContainsKey(idealLoads), Is.False, "series"); + Assert.That(results.TemperatureSeries[thermalZone], Is.All.EqualTo(15)); + } + else + { + Assert.That(results.TemperatureSeries, Is.Null); + } + } + + [TestCase(true)] + [TestCase(false)] + public void SharedGuidPrefix_DistinctNames_NoUnmetOutput_StillMaps(bool extractTimeSeries) + { + List<(string Key, string Variable, double Value)> series = Room("FixA", fixA, 1).Concat(Room("FixB", fixB, 3)).Where(x => x.Item2 != HeatingNotMet && x.Item2 != CoolingNotMet).ToList(); + OpenStudioSimulationResultSet results = Extract(CreateSql("zone_identity_shared_prefix_no_unmet_" + extractTimeSeries + ".sql", Zones(("FixA", fixA), ("FixB", fixB)), series), extractTimeSeries); + + foreach ((string name, Guid guid, double scale) in new[] { ("FixA", fixA, 1.0), ("FixB", fixB, 3.0) }) + { + string key = Name("IdealLoads", name, guid); + Assert.That(results.WindowSolarGains[key], Is.EqualTo(Core.OpenStudio.Query.JoulesToKilowattHours(24 * 40 * scale)).Within(1e-12), name); + Assert.That(results.InfiltrationGains[key], Is.EqualTo(Core.OpenStudio.Query.JoulesToKilowattHours(24 * 50 * scale)).Within(1e-12), name); + if (extractTimeSeries) + { + Assert.That(results.RelativeHumiditySeries[key], Is.All.EqualTo(40 + scale), name); + } + } + } + + [TestCase("no Zones table")] + [TestCase("empty Zones table")] + [TestCase("null ZoneName")] + [TestCase("other zone only")] + public void MissingIdentityMetadata_NothingIsRemapped(string inventory) + { + string[] zoneNames = null; + switch (inventory) + { + case "empty Zones table": + zoneNames = new string[0]; + break; + case "null ZoneName": + zoneNames = new string[] { null }; + break; + case "other zone only": + zoneNames = Zones(("FixB", fixB)); + break; + } + + OpenStudioSimulationResultSet results = Extract(CreateSql("zone_identity_missing_inventory_" + inventory.Replace(' ', '_') + ".sql", zoneNames, Room("FixA", fixA, 1))); + + string idealLoads = Name("IdealLoads", "FixA", fixA); + Assert.That(results.AnnualHeatingEnergy.ContainsKey(idealLoads), Is.True, "energy still read"); + Assert.That(results.UnmetHeatingHours.ContainsKey(idealLoads), Is.False, "uniqueness cannot be established: " + inventory); + Assert.That(results.UnmetHeatingHours[Name("ThermalZone", "FixA", fixA)], Is.EqualTo(24)); + Assert.That(results.WindowSolarGains.ContainsKey(Name("Space", "FixA", fixA)), Is.True); + Assert.That(results.TemperatureSeries.ContainsKey(idealLoads), Is.False); + } + + [Test] + public void TwoReportKeysOnOneRoom_NeitherOverwritesTheOther() + { + string idealLoads = Name("IdealLoads", "FixA", fixA); + OpenStudioSimulationResultSet results = Extract(CreateSql("zone_identity_collision.sql", Zones(("FixA", fixA)), new List<(string, string, double)> + { + (idealLoads, HeatingEnergy, 1000), + (Name("ThermalZone", "FixA", fixA), HeatingNotMet, 1), + (Name("Space", "FixA", fixA), HeatingNotMet, 2), + })); + + Assert.That(results.UnmetHeatingHours.ContainsKey(idealLoads), Is.False); + Assert.That(results.UnmetHeatingHours[Name("ThermalZone", "FixA", fixA)], Is.EqualTo(24)); + Assert.That(results.UnmetHeatingHours[Name("Space", "FixA", fixA)], Is.EqualTo(48)); + } + } +}