From 13e79b045ee83a25e4ef7a60cc9ad6d060f57834 Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Sat, 24 Oct 2026 11:03:12 +0200 Subject: [PATCH 1/6] fix(F-S11-1): associate OpenStudio zone results by name identity, not Guid suffix NormalizeKey matched ThermalZone/Space report keys to Ideal Loads keys by the last 8 characters and took the first hit, so rooms sharing a Guid prefix (s08-fix-two FixA/FixB, 5080c000) lost or overwrote unmet hours, gains, solar, infiltration and T/RH series. Match on _ after the SAM__ prefix; absent/ambiguous identities and colliding report keys pass through unmapped, never guessed or overwritten. Offline synthetic-SQL tests. Generated by Michal Dengusiak and CodeClaude Co-Authored-By: Claude Opus 5.5 --- .../Classes/OpenStudioSimulationRunner.cs | 110 ++++++-- .../C5/ZoneResultIdentityTests.cs | 244 ++++++++++++++++++ 2 files changed, 327 insertions(+), 27 deletions(-) create mode 100644 tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs index bc86829..1b3137f 100644 --- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs +++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs @@ -949,9 +949,9 @@ 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); return new OpenStudioSimulationResultSet( @@ -992,61 +992,117 @@ 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 Dictionary NormalizeKeys(Dictionary source, List zoneKeys) + 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 whose identity matches no zone key, or more than one, 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. + /// + /// + /// 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) { 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) { - result[NormalizeKey(keyValuePair.Key, zoneKeys)] = keyValuePair.Value; + string target = NormalizeKey(key, zoneKeys); + targets[key] = target; + targetCounts[target] = (targetCounts.TryGetValue(target, out int count) ? count : 0) + 1; + } + + Dictionary result = new Dictionary(); + foreach (KeyValuePair keyValuePair in source) + { + 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) { - if (source == null) + if (key == null || zoneKeys == null || zoneKeys.Count == 0) { - return null; + return key; } - Dictionary result = new Dictionary(); - foreach (KeyValuePair keyValuePair in source) + string identity = ZoneIdentity(key); + if (identity == null) { - result[NormalizeKey(keyValuePair.Key, zoneKeys)] = keyValuePair.Value; + return key; } - return result; + string result = null; + foreach (string zoneKey in zoneKeys) + { + if (!string.Equals(ZoneIdentity(zoneKey), identity, System.StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (result != null) + { + // Ambiguous: more than one zone carries this identity. + return key; + } + + result = zoneKey; + } + + return result ?? key; } - private static string NormalizeKey(string key, List zoneKeys) + /// + /// 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 (OpenStudio's de-duplication of a + /// repeated name; generated names never contain spaces) is ignored, so two objects with the + /// same generated name share one identity and are treated as ambiguous. + /// + private static string ZoneIdentity(string name) { - if (key == null || zoneKeys == null || zoneKeys.Count == 0 || key.Length < 8) + if (name != null) { - return key; + 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); + } } - string suffix = key.Substring(key.Length - 8); - foreach (string zoneKey in zoneKeys) + 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/tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs b/tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs new file mode 100644 index 0000000..95c1192 --- /dev/null +++ b/tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs @@ -0,0 +1,244 @@ +// 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(); + } + + /// Writes one 24-hour annual run period; each (key, variable) series is constant at its value. + private static string CreateSql(string fileName, 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)"); + 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) + { + OpenStudioLoadSummary loadSummary = OpenStudioSimulationRunner.ExtractLoads(sqlPath, out string failureDetail); + Assert.That(loadSummary, Is.Not.Null, failureDetail); + + OpenStudioSimulationResultSet result = OpenStudioSimulationRunner.ExtractResultSet(sqlPath, loadSummary, true, 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"); + 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", 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", 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", 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", 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 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 TwoReportKeysOnOneRoom_NeitherOverwritesTheOther() + { + string idealLoads = Name("IdealLoads", "FixA", fixA); + OpenStudioSimulationResultSet results = Extract(CreateSql("zone_identity_collision.sql", 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)); + } + } +} From a25b4f5aafe71f4b2b844b358231abeb89fd1220 Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Sat, 24 Oct 2026 11:03:17 +0200 Subject: [PATCH 2/6] docs(F-S11-1): PR record at tested content 13e79b04 Generated by Michal Dengusiak and CodeClaude Co-Authored-By: Claude Opus 5.5 --- .../F-S11-1-Zone-Result-Identity-PR.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 documentation/F-S11-1-Zone-Result-Identity-PR.md 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..029b9bc --- /dev/null +++ b/documentation/F-S11-1-Zone-Result-Identity-PR.md @@ -0,0 +1,93 @@ +# 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:** `13e79b045ee83a25e4ef7a60cc9ad6d060f57834` (code + tests). Later commits on this branch are record-only. + +## 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). + +- 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. +- A key is remapped only when exactly one zone key has its identity. No match, more than one match, no known prefix or + no Guid suffix: the key passes through under its own name (established unmatched behaviour); there is no suffix-only + fallback. +- If two report keys of one variable resolve to the same zone key, neither is remapped, so no dictionary entry is + overwritten. +- OpenStudio de-duplicates a repeated name by appending ` `; generated names never contain spaces, so that ending + is ignored when computing the identity. Two rooms with the same sanitized name AND the same Guid prefix therefore + share one identity and are left unmapped (ambiguous), instead of being paired by order. +- The two identical `NormalizeKeys` overloads became one generic method; call sites are unchanged. +- Key formats verified: real EnergyPlus 25.1 output (S11 review run, parent-local + `.review-s11-r1-correction-20261024/extra-frequency-two/work/run/eplusout.eso`) reports `SAM_IDEALLOADS_FIXA_5080C000` + and `SAM_THERMALZONE_FIXA_5080C000`; a 25.1 ESO in `_ext_validation/edl-ost-pilot` confirms enclosure variables key + on the Space name (`ZONE ONE SPACE`), which for SAM is `SAM_Space__`. + +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 + +New `tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs` (synthetic EnergyPlus-schema SQL, offline, +through `ExtractLoads` + `ExtractResultSet` and, for the fixture case, `ToSAM_SpaceSimulationResults`): + +| 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 | +| `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` | Same name + prefix (OpenStudio ` 1` de-duplication): neither room is assigned, both values kept | +| `TwoReportKeysOnOneRoom_NeitherOverwritesTheOther` | Two report keys resolving to one zone: no overwrite | + +Against the original runner (temporarily restored, rebuilt) 5 of the 6 fail; the unique-prefix test passes on both, as +intended. + +## Validation (local, this machine) + +Release x64, `dotnet build tests/SAM.Analytical.OpenStudio.Tests/SAM.Analytical.OpenStudio.Tests.csproj -c Release +-p:Platform=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 to the +existing sibling builds (SAM `sow/2026-Q4` `b7c2c36a`). + +- Build: 0 errors. +- `dotnet test … --no-build --filter "FullyQualifiedName~ZoneResultIdentityTests"`: 6 passed / 0 failed / 0 skipped. +- `dotnet test … --no-build --filter "TestCategory!=Simulation"` (all offline suites): 477 passed / 0 failed / 2 skipped (the two real-SQL reproduction fixtures are not committed; 9 m 18 s). +- 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 + +- Models whose 8-character prefixes are unique map exactly as before. Colliding models change from silently wrong to + correct (distinct names) or to visibly unmapped (same name + same prefix). +- Residual, not fixed here: rooms with identical sanitized name AND Guid prefix still produce identical generated + names, so the exact-name SAM mapping and the design-day/surface suffix lookups in `Modify/AddResults.cs` + (`LookupSpace`, Guid-suffix first) cannot tell them apart. Removing that needs full-Guid OpenStudio names, which change + IDF names and S09 evidence: separate owner decision. +- 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 this PR (another model/session, High, read-only). After acceptance and merge, reconcile S11 +#34's pinned `Finding_SharedGuidPrefix_…` test with this fix as a separately reviewed step; F-S11-2 follows under its +own compatibility review. From 486688204c850e23330e3626473f4c05d7736007 Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Sat, 24 Oct 2026 14:37:34 +0200 Subject: [PATCH 3/6] fix(F-S11-1 R1): numbered names mark a collision, never establish a match Revision 1 stripped OpenStudio's " " de-duplication ending and matched, so a numbered ThermalZone/Space key (possibly an unconditioned room with the same generated name) moved onto the sole unnumbered Ideal Loads key. Collect the identities of all numbered keys read by ExtractResultSet and leave numbered, colliding or numbered-target associations under their own keys. Adds scalar and series regressions: numbered source only, numbered target only, and a colliding unconditioned room whose quantity is the only source entry. Generated by Michal Dengusiak and CodeClaude Co-Authored-By: Claude Opus 5.5 --- .../Classes/OpenStudioSimulationRunner.cs | 105 ++++++++++++++---- .../C5/ZoneResultIdentityTests.cs | 69 ++++++++++++ 2 files changed, 150 insertions(+), 24 deletions(-) diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs index 1b3137f..383dc56 100644 --- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs +++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs @@ -954,6 +954,29 @@ internal static OpenStudioSimulationResultSet ExtractResultSet(string sqlPath, O // 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"); + + // A numbered name anywhere in the report marks its identity as shared by more than + // one room; such identities are never remapped (see CollidingZoneIdentities). + HashSet collidingIdentities = CollidingZoneIdentities( + 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 +988,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, collidingIdentities), + NormalizeKeys(unmetCoolingHours, zoneKeys, collidingIdentities), + NormalizeKeys(peopleGains, zoneKeys, collidingIdentities), + NormalizeKeys(lightingGains, zoneKeys, collidingIdentities), + NormalizeKeys(equipmentGains, zoneKeys, collidingIdentities), + NormalizeKeys(windowSolarGains, zoneKeys, collidingIdentities), + NormalizeKeys(infiltrationGains, zoneKeys, collidingIdentities), 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, collidingIdentities), + NormalizeKeys(operativeTemperatureSeries, zoneKeys, collidingIdentities), + NormalizeKeys(relativeHumiditySeries, zoneKeys, collidingIdentities), runtimeSeconds, warningCount, severeCount, @@ -1001,7 +1022,8 @@ internal static OpenStudioSimulationResultSet ExtractResultSet(string sqlPath, O /// /// 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 whose identity matches no zone key, or more than one, passes through + /// identity. A key is remapped only onto exactly one zone key; otherwise, or when its + /// identity is colliding (), 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. /// @@ -1010,7 +1032,7 @@ internal static OpenStudioSimulationResultSet ExtractResultSet(string sqlPath, O /// 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) + private static Dictionary NormalizeKeys(Dictionary source, List zoneKeys, HashSet collidingIdentities) { if (source == null) { @@ -1021,7 +1043,7 @@ private static Dictionary NormalizeKeys(Dictionary sour Dictionary targetCounts = new Dictionary(System.StringComparer.OrdinalIgnoreCase); foreach (string key in source.Keys) { - string target = NormalizeKey(key, zoneKeys); + string target = NormalizeKey(key, zoneKeys, collidingIdentities); targets[key] = target; targetCounts[target] = (targetCounts.TryGetValue(target, out int count) ? count : 0) + 1; } @@ -1036,15 +1058,15 @@ private static Dictionary NormalizeKeys(Dictionary sour return result; } - private static string NormalizeKey(string key, List zoneKeys) + private static string NormalizeKey(string key, List zoneKeys, HashSet collidingIdentities) { if (key == null || zoneKeys == null || zoneKeys.Count == 0) { return key; } - string identity = ZoneIdentity(key); - if (identity == null) + string identity = ZoneIdentity(key, out bool numbered); + if (identity == null || numbered || (collidingIdentities != null && collidingIdentities.Contains(identity))) { return key; } @@ -1052,12 +1074,13 @@ private static string NormalizeKey(string key, List zoneKeys) string result = null; foreach (string zoneKey in zoneKeys) { - if (!string.Equals(ZoneIdentity(zoneKey), identity, System.StringComparison.OrdinalIgnoreCase)) + string zoneIdentity = ZoneIdentity(zoneKey, out bool zoneNumbered); + if (!string.Equals(zoneIdentity, identity, System.StringComparison.OrdinalIgnoreCase)) { continue; } - if (result != null) + if (result != null || zoneNumbered) { // Ambiguous: more than one zone carries this identity. return key; @@ -1069,23 +1092,57 @@ private static string NormalizeKey(string key, List zoneKeys) return result ?? key; } + /// + /// Identities that more than one room may carry: those of any numbered name + /// (see ) among the given keys. 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). The numbered name is therefore evidence of the collision only; it never + /// establishes which room an unnumbered or numbered name belongs to. + /// + private static HashSet CollidingZoneIdentities(params IEnumerable[] keyCollections) + { + HashSet result = new HashSet(System.StringComparer.OrdinalIgnoreCase); + 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) + { + result.Add(identity); + } + } + } + + return result; + } + /// /// 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 (OpenStudio's de-duplication of a - /// repeated name; generated names never contain spaces) is ignored, so two objects with the - /// same generated name share one identity and are treated as ambiguous. + /// 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) + 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; } } diff --git a/tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs b/tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs index 95c1192..1f26a58 100644 --- a/tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs +++ b/tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs @@ -225,6 +225,75 @@ public void AmbiguousIdentity_LeavesKeysUnmapped_NeverOverwrites() 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. + 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 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 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 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)); + } + [Test] public void TwoReportKeysOnOneRoom_NeitherOverwritesTheOther() { From 77dd095b46a1f0531fc69c79777edb9263f00b6b Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Sat, 24 Oct 2026 14:37:34 +0200 Subject: [PATCH 4/6] docs(F-S11-1): PR record revision 2 at tested content 48668820; R2 evidence correction Generated by Michal Dengusiak and CodeClaude Co-Authored-By: Claude Opus 5.5 --- .../F-S11-1-Zone-Result-Identity-PR.md | 91 +++++++++++++------ 1 file changed, 62 insertions(+), 29 deletions(-) diff --git a/documentation/F-S11-1-Zone-Result-Identity-PR.md b/documentation/F-S11-1-Zone-Result-Identity-PR.md index 029b9bc..0700812 100644 --- a/documentation/F-S11-1-Zone-Result-Identity-PR.md +++ b/documentation/F-S11-1-Zone-Result-Identity-PR.md @@ -5,7 +5,12 @@ Production fix for finding F-S11-1 of S11 (SAM_OpenStudio#34, Draft, review head (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:** `13e79b045ee83a25e4ef7a60cc9ad6d060f57834` (code + tests). Later commits on this branch are record-only. +- **Tested content (revision 2, review correction R1/R2):** `486688204c850e23330e3626473f4c05d7736007` (code + tests). Later commits on this branch + are record-only. +- **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 are corrected below. ## Defect @@ -31,14 +36,26 @@ window solar, net infiltration, air/operative temperature and RH series). fallback. - If two report keys of one variable resolve to the same zone key, neither is remapped, so no dictionary entry is overwritten. -- OpenStudio de-duplicates a repeated name by appending ` `; generated names never contain spaces, so that ending - is ignored when computing the identity. Two rooms with the same sanitized name AND the same Guid prefix therefore - share one identity and are left unmapped (ambiguous), instead of being paired by order. -- The two identical `NormalizeKeys` overloads became one generic method; call sites are unchanged. -- Key formats verified: real EnergyPlus 25.1 output (S11 review run, parent-local - `.review-s11-r1-correction-20261024/extra-frequency-two/work/run/eplusout.eso`) reports `SAM_IDEALLOADS_FIXA_5080C000` - and `SAM_THERMALZONE_FIXA_5080C000`; a 25.1 ESO in `_ext_validation/edl-ost-pilot` confirms enclosure variables key - on the Space name (`ZONE ONE SPACE`), which for SAM is `SAM_Space__`. +- **Numbered names (revision 2, R1).** OpenStudio de-duplicates a repeated name per object type by appending ` `; + generated names never contain spaces. A numbered name is used only as **evidence of a collision**, never to establish + a match: `ExtractResultSet` collects the identities of every numbered key it read (Ideal Loads heating/cooling keys + and all normalised dictionaries) into `collidingIdentities`, and `NormalizeKey` leaves a key under its own name when it + is numbered, when its identity is colliding, or when its only matching zone key is numbered. Cross-variable evidence + is needed because numbering is per type: 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, so a quantity reported + only for the unconditioned room would otherwise look like a unique match. Revision 1 stripped the ending and matched, + which moved for example `SAM_THERMALZONE_FIXA_5080C000 1 = 216` onto the sole `SAM_IDEALLOADS_FIXA_5080C000`. +- The two identical `NormalizeKeys` overloads became one generic method; it now also takes the colliding 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`. + **Correction (R2):** revision 1 cited `.review-s11-r1-correction-20261024/extra-frequency-two/work/run/eplusout.eso` + as a real EnergyPlus/S11 run. It is synthetic test output from a review mutation probe; 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) @@ -46,7 +63,7 @@ leaves that room's value unset (for example no `UnmetHours`) rather than another ## Tests -New `tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs` (synthetic EnergyPlus-schema SQL, offline, +`tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs` (synthetic EnergyPlus-schema SQL, offline, through `ExtractLoads` + `ExtractResultSet` and, for the fixture case, `ToSAM_SpaceSimulationResults`): | Test | Proves | @@ -55,39 +72,55 @@ through `ExtractLoads` + `ExtractResultSet` and, for the fixture case, `ToSAM_Sp | `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` | Same name + prefix (OpenStudio ` 1` de-duplication): neither room is assigned, both values kept | +| `AmbiguousIdentity_LeavesKeysUnmapped_NeverOverwrites` | Both colliding rooms present (numbered and unnumbered on both sides): neither assigned, both values kept | +| `NumberedSource_OnlyUnnumberedTarget_StaysUnmapped` (rev. 2) | Numbered ThermalZone scalar, numbered Space scalar and numbered ThermalZone series stay under their own keys | +| `NumberedTarget_OnlyUnnumberedSource_StaysUnmapped` (rev. 2) | Unnumbered ThermalZone scalar and series are not moved onto a numbered Ideal Loads key | +| `CollidingUnconditionedRoom_OnlySourceEntry_IsNotAssignedToTheConditionedRoom` (rev. 2) | Window solar reported only for the unconditioned colliding room is not assigned to the conditioned room; unmet hours and series of both rooms stay unmapped | | `TwoReportKeysOnOneRoom_NeitherOverwritesTheOther` | Two report keys resolving to one zone: no overwrite | -Against the original runner (temporarily restored, rebuilt) 5 of the 6 fail; the unique-prefix test passes on both, as -intended. +Fail-before evidence: +- Revision 1: against the original Q4 runner (temporarily restored, rebuilt) 5 of the first 6 failed; the unique-prefix + test passes on both, as intended. +- Revision 2: the three new tests were added first and run against the reviewed code `13e79b04` (rebuilt): 3 failed / + 6 passed (each failed on `Expected: False But was: True`, i.e. the numbered or colliding value was moved onto the + Ideal Loads key). After the correction: 9 passed. ## Validation (local, this machine) -Release x64, `dotnet build tests/SAM.Analytical.OpenStudio.Tests/SAM.Analytical.OpenStudio.Tests.csproj -c Release --p:Platform=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 to the -existing sibling builds (SAM `sow/2026-Q4` `b7c2c36a`). +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`). -- Build: 0 errors. -- `dotnet test … --no-build --filter "FullyQualifiedName~ZoneResultIdentityTests"`: 6 passed / 0 failed / 0 skipped. -- `dotnet test … --no-build --filter "TestCategory!=Simulation"` (all offline suites): 477 passed / 0 failed / 2 skipped (the two real-SQL reproduction fixtures are not committed; 9 m 18 s). +- 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. +- `dotnet test … --no-build --filter "FullyQualifiedName~ZoneResultIdentityTests"`: 9 passed / 0 failed / 0 skipped. +- `dotnet test … --no-build --filter "TestCategory!=Simulation"` (all offline suites): 480 passed / 0 failed / 2 skipped (the two real-SQL reproduction fixtures are not committed; 6 m 33 s). +- No benchmark project calls `ExtractLoads`/`ExtractResultSet`/`NormalizeKeys`; not rebuilt. - 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 -- Models whose 8-character prefixes are unique map exactly as before. Colliding models change from silently wrong to - correct (distinct names) or to visibly unmapped (same name + same prefix). -- Residual, not fixed here: rooms with identical sanitized name AND Guid prefix still produce identical generated - names, so the exact-name SAM mapping and the design-day/surface suffix lookups in `Modify/AddResults.cs` - (`LookupSpace`, Guid-suffix first) cannot tell them apart. Removing that needs full-Guid OpenStudio names, which change - IDF names and S09 evidence: separate owner decision. +- Models whose 8-character prefixes are unique, and colliding prefixes with distinct names, map as in revision 1 + (unique prefixes exactly as on Q4 before this PR). Same name + same prefix is left unmapped for these normalised + quantities whenever a numbered name of that identity was read, whichever side is numbered. +- Residual (normalisation): the collision evidence is the numbered names the extractor read. If a colliding room's + numbered name appears in none of the read variables, the collision cannot be seen. Not-met time is reported for + every zone, so this needs a run that lacks it. +- 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 (full-Guid names would change IDF names + and S09 evidence). - 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 this PR (another model/session, High, read-only). After acceptance and merge, reconcile S11 -#34's pinned `Finding_SharedGuidPrefix_…` test with this fix as a separately reviewed step; F-S11-2 follows under its -own compatibility review. +Independent review of revision 2 (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. From b8b38e6ed4a622ffacd914a22b027ad245d3af7b Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Sat, 24 Oct 2026 15:37:32 +0200 Subject: [PATCH 5/6] fix(F-S11-1 r3): prove zone identity uniqueness from the SQL Zones inventory Revision 2 detected collisions only from numbered keys of the result variables it read, so a colliding room without such output was invisible and its value could be assigned to the conditioned room. Read Zones.ZoneName (one row per EnergyPlus zone) and remap a key only when its identity is carried by exactly one unnumbered zone and by no numbered key read; with no usable inventory, nothing is remapped. Tests: hidden inventory-only collision (series on/off), distinct names without unmet output (series on/off), missing/empty/NULL/other inventory; existing tests now carry a native Zones inventory. Generated by Michal Dengusiak and CodeClaude Co-Authored-By: Claude Opus 5.5 --- .../Classes/OpenStudioSimulationRunner.cs | 134 +++++++++++++---- .../C5/ZoneResultIdentityTests.cs | 138 ++++++++++++++++-- 2 files changed, 228 insertions(+), 44 deletions(-) diff --git a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs index 383dc56..c085338 100644 --- a/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs +++ b/SAM_OpenStudio/SAM.Analytical.OpenStudio/Classes/OpenStudioSimulationRunner.cs @@ -961,9 +961,10 @@ internal static OpenStudioSimulationResultSet ExtractResultSet(string sqlPath, O // ("Zone Windows ..." was renamed). Dictionary windowSolarGains = ReadAnnualEnergy(sqlPath, "Enclosure Windows Total Transmitted Solar Radiation Energy"); - // A numbered name anywhere in the report marks its identity as shared by more than - // one room; such identities are never remapped (see CollidingZoneIdentities). - HashSet collidingIdentities = CollidingZoneIdentities( + // 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, @@ -988,18 +989,18 @@ internal static OpenStudioSimulationResultSet ExtractResultSet(string sqlPath, O peakHeatingHourTotal, peakCoolingLoadTotal, peakCoolingHourTotal, - NormalizeKeys(unmetHeatingHours, zoneKeys, collidingIdentities), - NormalizeKeys(unmetCoolingHours, zoneKeys, collidingIdentities), - NormalizeKeys(peopleGains, zoneKeys, collidingIdentities), - NormalizeKeys(lightingGains, zoneKeys, collidingIdentities), - NormalizeKeys(equipmentGains, zoneKeys, collidingIdentities), - NormalizeKeys(windowSolarGains, zoneKeys, collidingIdentities), - NormalizeKeys(infiltrationGains, zoneKeys, collidingIdentities), + 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, collidingIdentities), - NormalizeKeys(operativeTemperatureSeries, zoneKeys, collidingIdentities), - NormalizeKeys(relativeHumiditySeries, zoneKeys, collidingIdentities), + NormalizeKeys(temperatureSeries, zoneKeys, uniqueIdentities), + NormalizeKeys(operativeTemperatureSeries, zoneKeys, uniqueIdentities), + NormalizeKeys(relativeHumiditySeries, zoneKeys, uniqueIdentities), runtimeSeconds, warningCount, severeCount, @@ -1022,17 +1023,17 @@ internal static OpenStudioSimulationResultSet ExtractResultSet(string sqlPath, O /// /// 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 onto exactly one zone key; otherwise, or when its - /// identity is colliding (), 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. + /// 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. /// /// /// 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 collidingIdentities) + private static Dictionary NormalizeKeys(Dictionary source, List zoneKeys, HashSet uniqueIdentities) { if (source == null) { @@ -1043,7 +1044,7 @@ private static Dictionary NormalizeKeys(Dictionary sour Dictionary targetCounts = new Dictionary(System.StringComparer.OrdinalIgnoreCase); foreach (string key in source.Keys) { - string target = NormalizeKey(key, zoneKeys, collidingIdentities); + string target = NormalizeKey(key, zoneKeys, uniqueIdentities); targets[key] = target; targetCounts[target] = (targetCounts.TryGetValue(target, out int count) ? count : 0) + 1; } @@ -1058,15 +1059,15 @@ private static Dictionary NormalizeKeys(Dictionary sour return result; } - private static string NormalizeKey(string key, List zoneKeys, HashSet collidingIdentities) + private static string NormalizeKey(string key, List zoneKeys, HashSet uniqueIdentities) { - if (key == null || zoneKeys == null || zoneKeys.Count == 0) + if (key == null || zoneKeys == null || zoneKeys.Count == 0 || uniqueIdentities == null) { return key; } string identity = ZoneIdentity(key, out bool numbered); - if (identity == null || numbered || (collidingIdentities != null && collidingIdentities.Contains(identity))) + if (identity == null || numbered || !uniqueIdentities.Contains(identity)) { return key; } @@ -1093,16 +1094,39 @@ private static string NormalizeKey(string key, List zoneKeys, HashSet - /// Identities that more than one room may carry: those of any numbered name - /// (see ) among the given keys. 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). The numbered name is therefore evidence of the collision only; it never - /// establishes which room an unnumbered or numbered name belongs to. + /// 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 CollidingZoneIdentities(params IEnumerable[] keyCollections) + 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) @@ -1115,14 +1139,64 @@ private static HashSet CollidingZoneIdentities(params IEnumerable + /// 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 (string.IsNullOrWhiteSpace(sqlPath) || !File.Exists(sqlPath)) + { + return null; + } + + 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 diff --git a/tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs b/tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs index 1f26a58..76f7807 100644 --- a/tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs +++ b/tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs @@ -43,8 +43,17 @@ private static string Name(string type, string name, Guid guid) return Core.OpenStudio.Query.OpenStudioName(type, name, guid).ToUpperInvariant(); } - /// Writes one 24-hour annual run period; each (key, variable) series is constant at its value. - private static string CreateSql(string fileName, IEnumerable<(string Key, string Variable, double Value)> series) + /// 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)) @@ -68,6 +77,17 @@ void Exec(string sql) 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)); @@ -113,12 +133,12 @@ void Exec(string sql) yield return (thermalZone, RelativeHumidity, 40 + scale); } - private static OpenStudioSimulationResultSet Extract(string sqlPath) + 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, true, 0, 0, 0, 0); + OpenStudioSimulationResultSet result = OpenStudioSimulationRunner.ExtractResultSet(sqlPath, loadSummary, extractTimeSeries, 0, 0, 0, 0); Assert.That(result, Is.Not.Null); return result; } @@ -135,6 +155,12 @@ private static void AssertRoom(OpenStudioSimulationResultSet results, string nam 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"); @@ -148,7 +174,7 @@ private static Space Space(string name, Guid guid) [Test] public void SharedGuidPrefix_FixAFixB_EachRoomKeepsItsOwnValues() { - string sqlPath = CreateSql("zone_identity_shared_prefix.sql", Room("FixA", fixA, 1).Concat(Room("FixB", fixB, 3))); + 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)); @@ -170,7 +196,7 @@ public void SharedGuidPrefix_FixAFixB_EachRoomKeepsItsOwnValues() [Test] public void UniquePrefixes_AndDuplicateDisplayNames_MapAsBefore() { - string sqlPath = CreateSql("zone_identity_unique_prefix.sql", Room("Office", fixA, 1).Concat(Room("Office", other, 2))); + 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); @@ -181,7 +207,7 @@ public void UniquePrefixes_AndDuplicateDisplayNames_MapAsBefore() 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", Room("Office 04", fixA, 1).Concat(Room("Office_04_A", fixB, 2)).Concat(Room(string.Empty, Guid.Parse("5080c000-0000-0000-0000-0000000000e1"), 4))); + 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")); @@ -198,7 +224,7 @@ public void SuffixOnlyMatch_IsNotGuessedOntoAnotherRoom() 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", series)); + 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"); @@ -211,7 +237,7 @@ 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 List<(string, string, double)> + OpenStudioSimulationResultSet results = Extract(CreateSql("zone_identity_ambiguous.sql", new[] { thermalZone, thermalZone + " 1" }, new List<(string, string, double)> { (idealLoads, HeatingEnergy, 1000), (idealLoads + " 1", HeatingEnergy, 3000), @@ -228,11 +254,12 @@ public void AmbiguousIdentity_LeavesKeysUnmapped_NeverOverwrites() [Test] public void NumberedSource_OnlyUnnumberedTarget_StaysUnmapped() { - // The numbered report keys may belong to another, unconditioned room with the same generated name. + // 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 List<(string, string, double)> + 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), @@ -253,7 +280,7 @@ 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 List<(string, string, double)> + OpenStudioSimulationResultSet results = Extract(CreateSql("zone_identity_numbered_target.sql", new[] { thermalZone, thermalZone + " 1" }, new List<(string, string, double)> { (idealLoads, HeatingEnergy, 1000), (thermalZone, HeatingNotMet, 9), @@ -275,7 +302,7 @@ public void CollidingUnconditionedRoom_OnlySourceEntry_IsNotAssignedToTheConditi 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 List<(string, string, double)> + OpenStudioSimulationResultSet results = Extract(CreateSql("zone_identity_unconditioned_collision.sql", new[] { thermalZone, thermalZone + " 1" }, new List<(string, string, double)> { (idealLoads, HeatingEnergy, 1000), (thermalZone, HeatingNotMet, 5), @@ -294,11 +321,94 @@ public void CollidingUnconditionedRoom_OnlySourceEntry_IsNotAssignedToTheConditi 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", new List<(string, string, double)> + 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), From 54026386b34a8e9d6e317d16b5c2087dedd08ec6 Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Sat, 24 Oct 2026 15:37:32 +0200 Subject: [PATCH 6/6] docs(F-S11-1): PR record revision 3 at tested content b8b38e6e Generated by Michal Dengusiak and CodeClaude Co-Authored-By: Claude Opus 5.5 --- .../F-S11-1-Zone-Result-Identity-PR.md | 111 ++++++++++-------- 1 file changed, 60 insertions(+), 51 deletions(-) diff --git a/documentation/F-S11-1-Zone-Result-Identity-PR.md b/documentation/F-S11-1-Zone-Result-Identity-PR.md index 0700812..1687fa5 100644 --- a/documentation/F-S11-1-Zone-Result-Identity-PR.md +++ b/documentation/F-S11-1-Zone-Result-Identity-PR.md @@ -5,12 +5,17 @@ Production fix for finding F-S11-1 of S11 (SAM_OpenStudio#34, Draft, review head (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 2, review correction R1/R2):** `486688204c850e23330e3626473f4c05d7736007` (code + tests). Later commits on this branch - are record-only. +- **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 are corrected below. + wording. Both corrected in revision 2. ## Defect @@ -26,36 +31,37 @@ got none. every `NormalizeKeys` call in `ExtractResultSet`: unmet heating/cooling hours, people/lights/equipment gains, enclosure window solar, net infiltration, air/operative temperature and RH series). -- 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. -- A key is remapped only when exactly one zone key has its identity. No match, more than one match, no known prefix or - no Guid suffix: the key passes through under its own name (established unmatched behaviour); there is no suffix-only - fallback. -- If two report keys of one variable resolve to the same zone key, neither is remapped, so no dictionary entry is - overwritten. -- **Numbered names (revision 2, R1).** OpenStudio de-duplicates a repeated name per object type by appending ` `; - generated names never contain spaces. A numbered name is used only as **evidence of a collision**, never to establish - a match: `ExtractResultSet` collects the identities of every numbered key it read (Ideal Loads heating/cooling keys - and all normalised dictionaries) into `collidingIdentities`, and `NormalizeKey` leaves a key under its own name when it - is numbered, when its identity is colliding, or when its only matching zone key is numbered. Cross-variable evidence - is needed because numbering is per type: 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, so a quantity reported - only for the unconditioned room would otherwise look like a unique match. Revision 1 stripped the ending and matched, - which moved for example `SAM_THERMALZONE_FIXA_5080C000 1 = 216` onto the sole `SAM_IDEALLOADS_FIXA_5080C000`. -- The two identical `NormalizeKeys` overloads became one generic method; it now also takes the colliding identities. - No public API, schema, writer name or Guid format changed. +- **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`. - **Correction (R2):** revision 1 cited `.review-s11-r1-correction-20261024/extra-frequency-two/work/run/eplusout.eso` - as a real EnergyPlus/S11 run. It is synthetic test output from a review mutation probe; no S11 engine run exists. + `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) @@ -63,27 +69,32 @@ leaves that room's value unset (for example no `UnmetHours`) rather than another ## Tests -`tests/SAM.Analytical.OpenStudio.Tests/C5/ZoneResultIdentityTests.cs` (synthetic EnergyPlus-schema SQL, offline, -through `ExtractLoads` + `ExtractResultSet` and, for the fixture case, `ToSAM_SpaceSimulationResults`): +`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 (numbered and unnumbered on both sides): neither assigned, both values kept | -| `NumberedSource_OnlyUnnumberedTarget_StaysUnmapped` (rev. 2) | Numbered ThermalZone scalar, numbered Space scalar and numbered ThermalZone series stay under their own keys | -| `NumberedTarget_OnlyUnnumberedSource_StaysUnmapped` (rev. 2) | Unnumbered ThermalZone scalar and series are not moved onto a numbered Ideal Loads key | -| `CollidingUnconditionedRoom_OnlySourceEntry_IsNotAssignedToTheConditionedRoom` (rev. 2) | Window solar reported only for the unconditioned colliding room is not assigned to the conditioned room; unmet hours and series of both rooms stay unmapped | +| `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 (temporarily restored, rebuilt) 5 of the first 6 failed; the unique-prefix - test passes on both, as intended. -- Revision 2: the three new tests were added first and run against the reviewed code `13e79b04` (rebuilt): 3 failed / - 6 passed (each failed on `Expected: False But was: True`, i.e. the numbered or colliding value was moved onto the - Ideal Loads key). After the correction: 9 passed. +- 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) @@ -93,34 +104,32 @@ Release x64 with `APPDATA` redirected to a temp folder; no Grasshopper project b `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. -- `dotnet test … --no-build --filter "FullyQualifiedName~ZoneResultIdentityTests"`: 9 passed / 0 failed / 0 skipped. -- `dotnet test … --no-build --filter "TestCategory!=Simulation"` (all offline suites): 480 passed / 0 failed / 2 skipped (the two real-SQL reproduction fixtures are not committed; 6 m 33 s). -- No benchmark project calls `ExtractLoads`/`ExtractResultSet`/`NormalizeKeys`; not rebuilt. + -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 -- Models whose 8-character prefixes are unique, and colliding prefixes with distinct names, map as in revision 1 - (unique prefixes exactly as on Q4 before this PR). Same name + same prefix is left unmapped for these normalised - quantities whenever a numbered name of that identity was read, whichever side is numbered. -- Residual (normalisation): the collision evidence is the numbered names the extractor read. If a colliding room's - numbered name appears in none of the read variables, the collision cannot be seen. Not-met time is reported for - every zone, so this needs a run that lacks it. +- 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 (full-Guid names would change IDF names - and S09 evidence). + 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 2 (another model/session, High, new session, Plan mode, read-only). After acceptance and +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.