diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs
new file mode 100644
index 0000000..4d77d3f
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseChildrenResult.cs
@@ -0,0 +1,19 @@
+using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
+
+namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
+
+///
+/// Result of one call. Holds a
+/// materialized page of direct children for the requested parent, along with a
+/// parallel-indexed hint and the total post-filter
+/// sibling count for paging.
+///
+/// The page of direct children, sorted areas-first then by display name.
+/// Parallel array indicating whether each child has at least one matching descendant under the same filter set.
+/// Total matching direct children of the parent (post-filter).
+/// Stable signature of the filter and parent selector, used to bind page tokens.
+public sealed record GalaxyBrowseChildrenResult(
+ IReadOnlyList Children,
+ IReadOnlyList ChildHasChildren,
+ int TotalChildCount,
+ string FilterSignature);
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs
new file mode 100644
index 0000000..a9b7501
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs
@@ -0,0 +1,266 @@
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text;
+using Grpc.Core;
+using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
+
+namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
+
+///
+/// Projects one level of children of a parent object out of an immutable
+/// . Pure and side-effect free. Memoizes the
+/// filtered child list per cache-entry instance so repeated paging is an O(pageSize)
+/// slice rather than an O(siblings) filter scan per page. The memo is keyed on the
+/// immutable cache entry, so when the cache publishes a new entry the stale memo
+/// becomes unreachable and is reclaimed with it.
+///
+public static class GalaxyBrowseProjector
+{
+ private static readonly ConditionalWeakTable<
+ GalaxyHierarchyCacheEntry,
+ ConcurrentDictionary> FilteredChildrenCache = new();
+
+ /// Projects one page of direct children of the resolved parent.
+ /// The Galaxy hierarchy cache entry to query.
+ /// The browse-children request.
+ /// Optional API-key browse-subtree constraints.
+ /// Zero-based offset into the filtered child list.
+ /// Maximum number of children to return.
+ public static GalaxyBrowseChildrenResult ProjectChildren(
+ GalaxyHierarchyCacheEntry entry,
+ BrowseChildrenRequest request,
+ IReadOnlyList? browseSubtreeGlobs,
+ int offset,
+ int pageSize)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ ArgumentNullException.ThrowIfNull(request);
+ if (offset < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be greater than or equal to zero.");
+ }
+ if (pageSize <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero.");
+ }
+
+ int parentId = ResolveParentId(entry, request);
+ string filterSignature = ComputeFilterSignature(request, browseSubtreeGlobs, parentId);
+ FilteredChildren filtered = GetFilteredChildren(entry, request, browseSubtreeGlobs, parentId, filterSignature);
+
+ bool includeAttributes = IncludeAttributes(request);
+ int end = (int)Math.Min((long)offset + pageSize, filtered.Children.Count);
+ List page = new(Math.Max(0, end - offset));
+ List hasChildren = new(Math.Max(0, end - offset));
+ for (int index = offset; index < end; index++)
+ {
+ page.Add(CloneObject(filtered.Children[index].Object, includeAttributes));
+ hasChildren.Add(filtered.HasMatchingDescendant[index]);
+ }
+
+ return new GalaxyBrowseChildrenResult(page, hasChildren, filtered.Children.Count, filterSignature);
+ }
+
+ private static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
+ {
+ switch (request.ParentCase)
+ {
+ case BrowseChildrenRequest.ParentOneofCase.None:
+ return 0;
+ case BrowseChildrenRequest.ParentOneofCase.ParentGobjectId:
+ if (request.ParentGobjectId == 0)
+ {
+ return 0;
+ }
+ if (!entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId))
+ {
+ throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
+ }
+ return request.ParentGobjectId;
+ case BrowseChildrenRequest.ParentOneofCase.ParentTagName:
+ {
+ GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault(
+ view => string.Equals(view.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase));
+ if (match is null)
+ {
+ throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
+ }
+ return match.Object.GobjectId;
+ }
+ case BrowseChildrenRequest.ParentOneofCase.ParentContainedPath:
+ {
+ GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault(
+ view => string.Equals(view.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase));
+ if (match is null)
+ {
+ throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
+ }
+ return match.Object.GobjectId;
+ }
+ default:
+ return 0;
+ }
+ }
+
+ private static FilteredChildren GetFilteredChildren(
+ GalaxyHierarchyCacheEntry entry,
+ BrowseChildrenRequest request,
+ IReadOnlyList? browseSubtreeGlobs,
+ int parentId,
+ string filterSignature)
+ {
+ ConcurrentDictionary memo =
+ FilteredChildrenCache.GetValue(entry, static _ => new ConcurrentDictionary(StringComparer.Ordinal));
+
+ return memo.GetOrAdd(
+ filterSignature,
+ static (_, state) =>
+ {
+ IReadOnlyDictionary> map = state.Entry.Index.ChildrenByParent;
+ IReadOnlyList directChildren = map.TryGetValue(state.ParentId, out IReadOnlyList? list)
+ ? list
+ : Array.Empty();
+
+ List matched = [];
+ List hasMatching = [];
+ foreach (GalaxyObjectView view in directChildren)
+ {
+ if (!MatchesBrowseSubtrees(view, state.BrowseSubtreeGlobs))
+ {
+ continue;
+ }
+ if (!MatchesFilters(view.Object, state.Request))
+ {
+ // Even if the direct child itself fails the filter, a matching
+ // descendant should still surface its ancestor — but only when
+ // there is one. Mirror the dashboard browse-tree semantics: if a
+ // descendant matches, include the parent with has-children true.
+ if (HasMatchingDescendant(view, state.Entry.Index, state.Request, state.BrowseSubtreeGlobs))
+ {
+ matched.Add(view);
+ hasMatching.Add(true);
+ }
+ continue;
+ }
+ matched.Add(view);
+ hasMatching.Add(HasMatchingDescendant(view, state.Entry.Index, state.Request, state.BrowseSubtreeGlobs));
+ }
+
+ return new FilteredChildren(matched, hasMatching);
+ },
+ (Entry: entry, ParentId: parentId, Request: request, BrowseSubtreeGlobs: browseSubtreeGlobs));
+ }
+
+ private static bool HasMatchingDescendant(
+ GalaxyObjectView parent,
+ GalaxyHierarchyIndex index,
+ BrowseChildrenRequest request,
+ IReadOnlyList? browseSubtreeGlobs)
+ {
+ if (!index.ChildrenByParent.TryGetValue(parent.Object.GobjectId, out IReadOnlyList? children))
+ {
+ return false;
+ }
+
+ Stack stack = new();
+ foreach (GalaxyObjectView child in children)
+ {
+ stack.Push(child);
+ }
+ while (stack.Count > 0)
+ {
+ GalaxyObjectView candidate = stack.Pop();
+ if (MatchesBrowseSubtrees(candidate, browseSubtreeGlobs)
+ && MatchesFilters(candidate.Object, request))
+ {
+ return true;
+ }
+ if (index.ChildrenByParent.TryGetValue(candidate.Object.GobjectId, out IReadOnlyList? grandchildren))
+ {
+ foreach (GalaxyObjectView grandchild in grandchildren)
+ {
+ stack.Push(grandchild);
+ }
+ }
+ }
+ return false;
+ }
+
+ private static bool MatchesBrowseSubtrees(GalaxyObjectView view, IReadOnlyList? browseSubtreeGlobs)
+ {
+ return browseSubtreeGlobs is null
+ || browseSubtreeGlobs.Count == 0
+ || browseSubtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(view.ContainedPath, glob));
+ }
+
+ private static bool MatchesFilters(GalaxyObject obj, BrowseChildrenRequest request)
+ {
+ if (request.CategoryIds.Count > 0 && !request.CategoryIds.Contains(obj.CategoryId))
+ {
+ return false;
+ }
+ foreach (string templateFilter in request.TemplateChainContains)
+ {
+ if (!obj.TemplateChain.Any(template => template.Contains(templateFilter, StringComparison.OrdinalIgnoreCase)))
+ {
+ return false;
+ }
+ }
+ if (!string.IsNullOrWhiteSpace(request.TagNameGlob)
+ && !GalaxyGlobMatcher.IsMatch(obj.TagName, request.TagNameGlob))
+ {
+ return false;
+ }
+ if (request.AlarmBearingOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm))
+ {
+ return false;
+ }
+ if (request.HistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized))
+ {
+ return false;
+ }
+ return true;
+ }
+
+ private static bool IncludeAttributes(BrowseChildrenRequest request)
+ {
+ return !request.HasIncludeAttributes || request.IncludeAttributes;
+ }
+
+ private static GalaxyObject CloneObject(GalaxyObject source, bool includeAttributes)
+ {
+ GalaxyObject clone = source.Clone();
+ if (!includeAttributes)
+ {
+ clone.Attributes.Clear();
+ }
+ return clone;
+ }
+
+ /// Computes a stable filter signature for memoization purposes.
+ /// The browse-children request.
+ /// Optional API-key browse-subtree constraints.
+ /// Resolved parent gobject id (0 for roots).
+ public static string ComputeFilterSignature(
+ BrowseChildrenRequest request,
+ IReadOnlyList? browseSubtreeGlobs,
+ int parentId)
+ {
+ StringBuilder builder = new();
+ builder.Append("parent=").Append(parentId.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ builder.Append("|cat=").AppendJoin(',', request.CategoryIds.Order());
+ builder.Append("|tpl=").AppendJoin(',', request.TemplateChainContains.Order(StringComparer.OrdinalIgnoreCase));
+ builder.Append("|glob=").Append(request.TagNameGlob);
+ builder.Append("|attrs=").Append(request.HasIncludeAttributes ? request.IncludeAttributes.ToString() : "unset");
+ builder.Append("|alarm=").Append(request.AlarmBearingOnly);
+ builder.Append("|hist=").Append(request.HistorizedOnly);
+ builder.Append("|browse=").AppendJoin(',', (browseSubtreeGlobs ?? Array.Empty()).Order(StringComparer.OrdinalIgnoreCase));
+ byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
+ return Convert.ToHexString(hash, 0, 12);
+ }
+
+ private sealed record FilteredChildren(
+ IReadOnlyList Children,
+ IReadOnlyList HasMatchingDescendant);
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs
new file mode 100644
index 0000000..98fee57
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyBrowseProjectorTests.cs
@@ -0,0 +1,311 @@
+using Grpc.Core;
+using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
+using ZB.MOM.WW.MxGateway.Server.Dashboard;
+using ZB.MOM.WW.MxGateway.Server.Galaxy;
+
+namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
+
+///
+/// Direct coverage for . Validates parent
+/// resolution (gobject id / tag name / contained path), paging across siblings,
+/// filter parity with , the
+/// child_has_children hint, browse-subtree constraints, and the
+/// attribute-skeleton mode.
+///
+public sealed class GalaxyBrowseProjectorTests
+{
+ /// Verifies that an empty parent oneof returns the root area.
+ [Fact]
+ public void Project_NoParent_ReturnsRootArea()
+ {
+ GalaxyHierarchyCacheEntry entry = CreateEntry();
+
+ GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
+ entry,
+ new BrowseChildrenRequest(),
+ browseSubtreeGlobs: null,
+ offset: 0,
+ pageSize: 10);
+
+ Assert.Single(result.Children);
+ Assert.Equal("Plant", result.Children[0].TagName);
+ Assert.True(result.ChildHasChildren[0]);
+ }
+
+ /// Verifies that resolving the parent by gobject id returns sorted direct children.
+ [Fact]
+ public void Project_ByParentGobjectId_ReturnsDirectChildren()
+ {
+ GalaxyHierarchyCacheEntry entry = CreateEntry();
+
+ GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
+ entry,
+ new BrowseChildrenRequest { ParentGobjectId = 1 },
+ browseSubtreeGlobs: null,
+ offset: 0,
+ pageSize: 10);
+
+ string[] names = result.Children.Select(child => child.TagName).ToArray();
+ Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
+ Assert.Equal(new[] { true, false, false, false }, result.ChildHasChildren.ToArray());
+ Assert.Equal(4, result.TotalChildCount);
+ }
+
+ /// Verifies that resolving the parent by tag name returns the same direct children.
+ [Fact]
+ public void Project_ByParentTagName_ResolvesParent()
+ {
+ GalaxyHierarchyCacheEntry entry = CreateEntry();
+
+ GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
+ entry,
+ new BrowseChildrenRequest { ParentTagName = "Plant" },
+ browseSubtreeGlobs: null,
+ offset: 0,
+ pageSize: 10);
+
+ string[] names = result.Children.Select(child => child.TagName).ToArray();
+ Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
+ }
+
+ /// Verifies that resolving the parent by contained path returns the same direct children.
+ [Fact]
+ public void Project_ByParentContainedPath_ResolvesParent()
+ {
+ GalaxyHierarchyCacheEntry entry = CreateEntry();
+
+ GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
+ entry,
+ new BrowseChildrenRequest { ParentContainedPath = "Plant" },
+ browseSubtreeGlobs: null,
+ offset: 0,
+ pageSize: 10);
+
+ string[] names = result.Children.Select(child => child.TagName).ToArray();
+ Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
+ }
+
+ /// Verifies that an unknown parent gobject id throws an RpcException with StatusCode.NotFound.
+ [Fact]
+ public void Project_UnknownParent_ThrowsNotFound()
+ {
+ GalaxyHierarchyCacheEntry entry = CreateEntry();
+
+ RpcException exception = Assert.Throws(() => GalaxyBrowseProjector.ProjectChildren(
+ entry,
+ new BrowseChildrenRequest { ParentGobjectId = 999 },
+ browseSubtreeGlobs: null,
+ offset: 0,
+ pageSize: 10));
+
+ Assert.Equal(StatusCode.NotFound, exception.StatusCode);
+ }
+
+ /// Verifies that paging across siblings returns every sibling exactly once.
+ [Fact]
+ public void Project_PagedAcrossSiblings_ReturnsEverySiblingOnce()
+ {
+ GalaxyHierarchyCacheEntry entry = CreateEntry();
+
+ GalaxyBrowseChildrenResult first = GalaxyBrowseProjector.ProjectChildren(
+ entry,
+ new BrowseChildrenRequest { ParentGobjectId = 1 },
+ browseSubtreeGlobs: null,
+ offset: 0,
+ pageSize: 2);
+ GalaxyBrowseChildrenResult second = GalaxyBrowseProjector.ProjectChildren(
+ entry,
+ new BrowseChildrenRequest { ParentGobjectId = 1 },
+ browseSubtreeGlobs: null,
+ offset: 2,
+ pageSize: 2);
+
+ List collected = first.Children
+ .Concat(second.Children)
+ .Select(child => child.TagName)
+ .ToList();
+ Assert.Equal(4, collected.Count);
+ Assert.Equal(collected.Count, collected.Distinct(StringComparer.Ordinal).Count());
+ Assert.Equal(
+ new HashSet(StringComparer.Ordinal)
+ {
+ "Plant.Line_A",
+ "Plant.Mixer_001",
+ "Plant.Mixer_002",
+ "Plant.Pump_001",
+ },
+ new HashSet(collected, StringComparer.Ordinal));
+ }
+
+ /// Verifies that a tag-name glob filters direct children and clears the has-children hint.
+ [Fact]
+ public void Project_TagNameGlobFiltersChildren_AndUpdatesHasChildren()
+ {
+ GalaxyHierarchyCacheEntry entry = CreateEntry();
+
+ GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
+ entry,
+ new BrowseChildrenRequest
+ {
+ ParentGobjectId = 1,
+ TagNameGlob = "*Mixer*",
+ },
+ browseSubtreeGlobs: null,
+ offset: 0,
+ pageSize: 10);
+
+ string[] names = result.Children.Select(child => child.TagName).ToArray();
+ Assert.Equal(new[] { "Plant.Mixer_001", "Plant.Mixer_002" }, names);
+ Assert.Equal(new[] { false, false }, result.ChildHasChildren.ToArray());
+ }
+
+ /// Verifies that historized-only filtering also drives the has-children hint via descendants.
+ [Fact]
+ public void Project_HistorizedOnlyFiltersDescendants_HasChildrenReflectsFilter()
+ {
+ GalaxyHierarchyCacheEntry entry = CreateEntry();
+
+ GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
+ entry,
+ new BrowseChildrenRequest
+ {
+ ParentGobjectId = 1,
+ HistorizedOnly = true,
+ },
+ browseSubtreeGlobs: null,
+ offset: 0,
+ pageSize: 10);
+
+ // Line_A itself has no historized attributes, but its descendant Sensor_A1 does,
+ // so the subtree match keeps Line_A in the result with has-children = true.
+ // Mixer_001/Mixer_002/Pump_001 have no historized attributes themselves and
+ // no historized descendants -> filtered out entirely.
+ Assert.Single(result.Children);
+ Assert.Equal("Plant.Line_A", result.Children[0].TagName);
+ Assert.Equal(new[] { true }, result.ChildHasChildren.ToArray());
+ }
+
+ /// Verifies that IncludeAttributes=false returns object skeletons.
+ [Fact]
+ public void Project_IncludeAttributesFalse_ReturnsSkeletons()
+ {
+ GalaxyHierarchyCacheEntry entry = CreateEntry();
+
+ GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
+ entry,
+ new BrowseChildrenRequest
+ {
+ ParentGobjectId = 1,
+ IncludeAttributes = false,
+ },
+ browseSubtreeGlobs: null,
+ offset: 0,
+ pageSize: 10);
+
+ GalaxyObject mixer = result.Children.Single(child => child.TagName == "Plant.Mixer_001");
+ Assert.Empty(mixer.Attributes);
+ }
+
+ /// Verifies that browse-subtree globs constrain the returned children.
+ [Fact]
+ public void Project_BrowseSubtrees_ExcludesChildrenOutsideAllowedGlobs()
+ {
+ GalaxyHierarchyCacheEntry entry = CreateEntry();
+
+ GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
+ entry,
+ new BrowseChildrenRequest { ParentGobjectId = 1 },
+ browseSubtreeGlobs: new[] { "Plant/Line_*" },
+ offset: 0,
+ pageSize: 10);
+
+ Assert.Single(result.Children);
+ Assert.Equal("Plant.Line_A", result.Children[0].TagName);
+ }
+
+ private static GalaxyHierarchyCacheEntry CreateEntry()
+ {
+ IReadOnlyList objects = CreateObjects();
+ return GalaxyHierarchyCacheEntry.Empty with
+ {
+ Status = GalaxyCacheStatus.Healthy,
+ Sequence = 1,
+ LastSuccessAt = DateTimeOffset.UtcNow,
+ Objects = objects,
+ Index = GalaxyHierarchyIndex.Build(objects),
+ DashboardSummary = DashboardGalaxySummary.Unknown with
+ {
+ Status = DashboardGalaxyStatus.Healthy,
+ ObjectCount = objects.Count,
+ },
+ ObjectCount = objects.Count,
+ };
+ }
+
+ private static IReadOnlyList CreateObjects()
+ {
+ GalaxyObject plant = new()
+ {
+ GobjectId = 1,
+ ParentGobjectId = 0,
+ IsArea = true,
+ ContainedName = "Plant",
+ BrowseName = "Plant",
+ TagName = "Plant",
+ };
+ GalaxyObject mixer001 = new()
+ {
+ GobjectId = 2,
+ ParentGobjectId = 1,
+ ContainedName = "Mixer_001",
+ BrowseName = "Mixer_001",
+ TagName = "Plant.Mixer_001",
+ };
+ mixer001.Attributes.Add(new GalaxyAttribute
+ {
+ AttributeName = "Speed",
+ FullTagReference = "Plant.Mixer_001.Speed",
+ });
+ GalaxyObject mixer002 = new()
+ {
+ GobjectId = 3,
+ ParentGobjectId = 1,
+ ContainedName = "Mixer_002",
+ BrowseName = "Mixer_002",
+ TagName = "Plant.Mixer_002",
+ };
+ GalaxyObject lineA = new()
+ {
+ GobjectId = 4,
+ ParentGobjectId = 1,
+ IsArea = true,
+ ContainedName = "Line_A",
+ BrowseName = "Line_A",
+ TagName = "Plant.Line_A",
+ };
+ GalaxyObject sensorA1 = new()
+ {
+ GobjectId = 5,
+ ParentGobjectId = 4,
+ ContainedName = "Sensor_A1",
+ BrowseName = "Sensor_A1",
+ TagName = "Plant.Line_A.Sensor_A1",
+ };
+ sensorA1.Attributes.Add(new GalaxyAttribute
+ {
+ AttributeName = "Value",
+ FullTagReference = "Plant.Line_A.Sensor_A1.Value",
+ IsHistorized = true,
+ });
+ GalaxyObject pump001 = new()
+ {
+ GobjectId = 6,
+ ParentGobjectId = 1,
+ ContainedName = "Pump_001",
+ BrowseName = "Pump_001",
+ TagName = "Plant.Pump_001",
+ };
+
+ return new[] { plant, mixer001, mixer002, lineA, sensorA1, pump001 };
+ }
+}