From 1b5868d8627a7f17057536c56c5cdb8123cae9a1 Mon Sep 17 00:00:00 2001 From: Franciszek Latala Date: Thu, 24 Sep 2026 18:10:47 +0200 Subject: [PATCH 1/7] support for graph surrogate --- adsg_core/graph/__init__.py | 1 + adsg_core/graph/labeling.py | 52 +++++ adsg_core/optimization/__init__.py | 1 + adsg_core/optimization/graph_decoder.py | 190 ++++++++++++++++++ adsg_core/tests/test_graph_labeling.py | 21 ++ .../tests/test_optimization_graph_decoder.py | 130 ++++++++++++ 6 files changed, 395 insertions(+) create mode 100644 adsg_core/graph/labeling.py create mode 100644 adsg_core/optimization/graph_decoder.py create mode 100644 adsg_core/tests/test_graph_labeling.py create mode 100644 adsg_core/tests/test_optimization_graph_decoder.py diff --git a/adsg_core/graph/__init__.py b/adsg_core/graph/__init__.py index 2c8232f..d2fd52f 100644 --- a/adsg_core/graph/__init__.py +++ b/adsg_core/graph/__init__.py @@ -2,3 +2,4 @@ from .adsg_basic import * from .adsg_nodes import * from .graph_edges import * +from .labeling import * diff --git a/adsg_core/graph/labeling.py b/adsg_core/graph/labeling.py new file mode 100644 index 0000000..5e5e9fd --- /dev/null +++ b/adsg_core/graph/labeling.py @@ -0,0 +1,52 @@ +"""Generic node labelings for design-space graphs.""" + +from adsg_core.graph.adsg_nodes import ( + ChoiceNode, + CollectorNode, + ConnectionChoiceNode, + ConnectorDegreeGroupingNode, + ConnectorNode, + DesignVariableNode, + MetricNode, + NamedNode, + NonSelectionNode, + SelectionChoiceNode, +) + + +__all__ = ["constant_label", "dsg_type_label", "node_type_label"] + + +def constant_label(_node): + return "node" + + +def dsg_type_label(node): + """Label a node by its most specific generic DSG role.""" + if isinstance(node, SelectionChoiceNode): + return "selection_choice" + if isinstance(node, ConnectionChoiceNode): + return "connection_choice" + if isinstance(node, ChoiceNode): + return "choice" + if isinstance(node, DesignVariableNode): + return "design_variable" + if isinstance(node, MetricNode): + return "metric" + if isinstance(node, ConnectorDegreeGroupingNode): + return "connector_group" + if isinstance(node, ConnectorNode): + return "connector" + if isinstance(node, CollectorNode): + return "collector" + if isinstance(node, NonSelectionNode): + return "non_selection" + if isinstance(node, NamedNode): + return "named_node" + return "node" + + +def node_type_label(node): + """Label a node by its concrete class without exporting the class object.""" + node_type = type(node) + return node_type.__module__, node_type.__qualname__ diff --git a/adsg_core/optimization/__init__.py b/adsg_core/optimization/__init__.py index 2ada60c..d786273 100644 --- a/adsg_core/optimization/__init__.py +++ b/adsg_core/optimization/__init__.py @@ -2,3 +2,4 @@ from .evaluator import * from .dv_output_defs import * from .problem import * +from .graph_decoder import * diff --git a/adsg_core/optimization/graph_decoder.py b/adsg_core/optimization/graph_decoder.py new file mode 100644 index 0000000..b0c5068 --- /dev/null +++ b/adsg_core/optimization/graph_decoder.py @@ -0,0 +1,190 @@ +"""Optional SBArchOpt graph decoder for DSG optimization problems.""" + +from __future__ import annotations + +from typing import Dict, Protocol, Sequence, Tuple + +import networkx as nx +import numpy as np + +from adsg_core.graph.adsg import DSGType +from adsg_core.graph.adsg_nodes import DesignVariableNode +from adsg_core.graph.graph_edges import get_edge_type +from adsg_core.graph.labeling import constant_label, dsg_type_label, node_type_label +from adsg_core.optimization.graph_processor import GraphProcessor + +try: + from sb_arch_opt.algo.arch_sbo.graph import ( + GraphRepresentation, + NodeLabeling, + SizingFeature, + ) + + HAS_SB_ARCH_OPT_GRAPH = True +except ImportError: + HAS_SB_ARCH_OPT_GRAPH = False + + +__all__ = [ + "HAS_SB_ARCH_OPT_GRAPH", + "DSGSizingEncoder", + "DesignVariableSizingEncoder", + "DSGGraphDecoder", + "get_default_dsg_labelings", +] + + +def _check_dependency(): + if not HAS_SB_ARCH_OPT_GRAPH: + raise ImportError("SBArchOpt graph support is not installed") + + +def get_default_dsg_labelings(): + _check_dependency() + return ( + NodeLabeling("adsg_core.constant", constant_label), + NodeLabeling("adsg_core.dsg", dsg_type_label), + NodeLabeling("adsg_core.type", node_type_label), + ) + + +class DSGSizingEncoder(Protocol): + + @property + def features(self) -> Sequence["SizingFeature"]: + ... + + def encode(self, graph: DSGType, x_imputed: Sequence[float]) -> np.ndarray: + ... + + +class DesignVariableSizingEncoder: + """Export additional DesignVariableNode values as normalized sizing features.""" + + def __init__(self, processor: GraphProcessor): + self._variables = tuple( + (index, variable) + for index, variable in enumerate(processor.des_vars) + if isinstance(variable.node, DesignVariableNode) + ) + _check_dependency() + self._features = tuple( + SizingFeature( + variable.name, + "numeric" if not variable.is_discrete or variable.is_ordinal else "categorical", + ) + for _, variable in self._variables + ) + + @property + def features(self): + return self._features + + def encode(self, graph, x_imputed): + del graph + return np.asarray( + [self._normalize(x_imputed[index], variable) for index, variable in self._variables], + dtype=float, + ) + + @staticmethod + def _normalize(value, variable): + if not variable.is_discrete: + lower, upper = variable.bounds + return (float(value) - lower) / (upper - lower) + if variable.is_ordinal: + return 0.0 if variable.n_opts == 1 else float(value) / (variable.n_opts - 1) + return float(value) + + +class DSGGraphDecoder: + """Decode design vectors into neutral SBArchOpt graph representations.""" + + def __init__( + self, + processor: GraphProcessor, + labelings=None, + sizing_encoders: Sequence[DSGSizingEncoder] = None, + ): + _check_dependency() + self.processor = processor + self._labelings = tuple( + labelings if labelings is not None else get_default_dsg_labelings() + ) + self.sizing_encoders = tuple( + sizing_encoders + if sizing_encoders is not None + else (DesignVariableSizingEncoder(processor),) + ) + self._raw_to_corrected: Dict[Tuple[float, ...], Tuple[float, ...]] = {} + self._representations: Dict[Tuple[float, ...], "GraphRepresentation"] = {} + + def __deepcopy__(self, memo): + copied = self.__class__.__new__(self.__class__) + memo[id(self)] = copied + copied.__dict__ = self.__dict__.copy() + return copied + + @property + def labelings(self): + return self._labelings + + @property + def sizing_features(self): + return tuple( + feature + for encoder in self.sizing_encoders + for feature in encoder.features + ) + + def decode(self, x: np.ndarray): + representations = [] + for row in np.asarray(x): + raw_key = tuple(np.asarray(row, dtype=float)) + corrected_key = self._raw_to_corrected.get(raw_key) + graph = None + if corrected_key is None: + graph, x_imputed, _ = self.processor.get_graph(row, create=False) + corrected_key = tuple(x_imputed) + self._raw_to_corrected[raw_key] = corrected_key + + representation = self._representations.get(corrected_key) + if representation is None: + if graph is None: + graph, _, _ = self.processor.get_graph(corrected_key) + representation = self._representation(graph, corrected_key) + self._representations[corrected_key] = representation + representations.append(representation) + return representations + + def _representation(self, dsg: DSGType, x_imputed): + source_graph = dsg.graph + source_nodes = tuple(source_graph.nodes) + node_ids = {node: index for index, node in enumerate(source_nodes)} + + graph = nx.MultiDiGraph() + graph.add_nodes_from(node_ids.values()) + for source, target, key, data in source_graph.edges(keys=True, data=True): + graph.add_edge( + node_ids[source], + node_ids[target], + kind=get_edge_type((source, target, key, data)).name.lower(), + ) + + node_labels = { + labeling: { + node_ids[node]: labeling(node) + for node in source_nodes + } + for labeling in self.labelings + } + sizing_values = np.concatenate([ + encoder.encode(dsg, x_imputed) + for encoder in self.sizing_encoders + ]) if self.sizing_encoders else np.empty((0,), dtype=float) + + return GraphRepresentation( + graph=graph, + node_labels=node_labels, + sizing_values=sizing_values, + ) diff --git a/adsg_core/tests/test_graph_labeling.py b/adsg_core/tests/test_graph_labeling.py new file mode 100644 index 0000000..e125215 --- /dev/null +++ b/adsg_core/tests/test_graph_labeling.py @@ -0,0 +1,21 @@ +from adsg_core.graph.adsg_nodes import ( + ConnectorNode, + DesignVariableNode, + NamedNode, + SelectionChoiceNode, +) +from adsg_core.graph.labeling import constant_label, dsg_type_label, node_type_label + + +def test_generic_node_labelings(): + nodes_and_roles = [ + (NamedNode("node"), "named_node"), + (ConnectorNode("port"), "connector"), + (SelectionChoiceNode("choice"), "selection_choice"), + (DesignVariableNode("size", bounds=(0.0, 1.0)), "design_variable"), + ] + + for node, role in nodes_and_roles: + assert constant_label(node) == "node" + assert dsg_type_label(node) == role + assert node_type_label(node) == (type(node).__module__, type(node).__qualname__) diff --git a/adsg_core/tests/test_optimization_graph_decoder.py b/adsg_core/tests/test_optimization_graph_decoder.py new file mode 100644 index 0000000..b3ac81f --- /dev/null +++ b/adsg_core/tests/test_optimization_graph_decoder.py @@ -0,0 +1,130 @@ +import copy + +import numpy as np +import pytest + +from adsg_core.graph.adsg_basic import BasicDSG +from adsg_core.graph.adsg_nodes import ConnectorNode, DesignVariableNode, NamedNode +from adsg_core.graph.graph_edges import EdgeType, add_edge +from adsg_core.optimization.graph_decoder import HAS_SB_ARCH_OPT_GRAPH, DSGGraphDecoder +from adsg_core.optimization.graph_processor import GraphProcessor + + +pytestmark = pytest.mark.skipif( + not HAS_SB_ARCH_OPT_GRAPH, + reason="SBArchOpt graph support is not installed", +) + + +def test_dsg_graph_decoder(): + root = NamedNode("root") + source = ConnectorNode("source", repeated_allowed=True) + target = ConnectorNode("target", repeated_allowed=True) + size = DesignVariableNode("size", bounds=(0.0, 10.0)) + material = DesignVariableNode("material", options=["a", "b"]) + + dsg = BasicDSG() + dsg.add_edges([(root, source), (root, target), (root, size), (root, material)]) + add_edge(dsg.graph, source, target, edge_type=EdgeType.CONNECTS) + add_edge(dsg.graph, source, target, edge_type=EdgeType.CONNECTS) + dsg = dsg.set_start_nodes({root}) + + processor = GraphProcessor(dsg) + decoder = DSGGraphDecoder(processor) + values = { + "size": 7.5, + "material": 1, + } + x = np.asarray([[values[variable.name] for variable in processor.des_vars]]) + + representation = decoder.decode(x)[0] + + assert all(isinstance(node, int) for node in representation.graph.nodes) + assert set(decoder.labelings) == set(representation.node_labels) + assert all( + set(labels) == set(representation.graph.nodes) + for labels in representation.node_labels.values() + ) + assert [feature.name for feature in decoder.sizing_features] == [ + variable.name for variable in processor.des_vars + ] + sizing_by_name = dict(zip( + [feature.name for feature in decoder.sizing_features], + representation.sizing_values, + )) + assert sizing_by_name == {"material": 1.0, "size": 0.75} + + connects_edges = [ + edge + for edge in representation.graph.edges(data=True) + if edge[-1]["kind"] == "connects" + ] + assert len(connects_edges) == 2 + + +def test_dsg_graph_decoder_exports_imputed_sizing_values(): + root = NamedNode("root") + with_size = NamedNode("with-size") + without_size = NamedNode("without-size") + size = DesignVariableNode("size", bounds=(0.0, 10.0)) + + dsg = BasicDSG() + dsg.add_selection_choice("architecture", root, [with_size, without_size]) + dsg.add_edge(with_size, size) + dsg = dsg.set_start_nodes({root}) + + processor = GraphProcessor(dsg) + decoder = DSGGraphDecoder(processor) + + def row(option, size_value=9.0): + return [ + option if variable.name == "architecture" else size_value + for variable in processor.des_vars + ] + + with_size_representation, without_size_representation, equivalent_representation = decoder.decode( + np.asarray([row(0), row(1), row(1, size_value=1.0)]) + ) + + np.testing.assert_allclose(with_size_representation.sizing_values, [0.9]) + np.testing.assert_allclose(without_size_representation.sizing_values, [0.5]) + assert equivalent_representation is without_size_representation + + +def test_dsg_graph_decoder_composes_sizing_encoders(): + from sb_arch_opt.algo.arch_sbo.graph import GraphDecoder, SizingFeature + + class AttributeSizingEncoder: + features = (SizingFeature("attribute", "categorical"),) + + def encode(self, graph, x_imputed): + del graph, x_imputed + return np.asarray([2.0]) + + root = NamedNode("root") + dsg = BasicDSG() + dsg.add_node(root) + dsg = dsg.set_start_nodes({root}) + + decoder = DSGGraphDecoder( + GraphProcessor(dsg), + sizing_encoders=[AttributeSizingEncoder()], + ) + representation = decoder.decode(np.empty((1, 0)))[0] + + assert isinstance(decoder, GraphDecoder) + assert decoder.sizing_features == (SizingFeature("attribute", "categorical"),) + np.testing.assert_allclose(representation.sizing_values, [2.0]) + + +def test_dsg_graph_decoder_copies_share_representations(): + root = NamedNode("root") + dsg = BasicDSG() + dsg.add_node(root) + dsg = dsg.set_start_nodes({root}) + decoder = DSGGraphDecoder(GraphProcessor(dsg)) + copied = copy.deepcopy(decoder) + + representation = decoder.decode(np.empty((1, 0)))[0] + + assert copied.decode(np.empty((1, 0)))[0] is representation From eab620c5cb5b5cd9084578aa2153a1e666437275 Mon Sep 17 00:00:00 2001 From: Franciszek Latala Date: Mon, 28 Sep 2026 15:23:43 +0200 Subject: [PATCH 2/7] update caching and cleanup code --- adsg_core/optimization/graph_decoder.py | 26 ++++++++++++------- .../tests/test_optimization_graph_decoder.py | 16 ++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/adsg_core/optimization/graph_decoder.py b/adsg_core/optimization/graph_decoder.py index b0c5068..c477d26 100644 --- a/adsg_core/optimization/graph_decoder.py +++ b/adsg_core/optimization/graph_decoder.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass, field from typing import Dict, Protocol, Sequence, Tuple import networkx as nx @@ -97,6 +98,12 @@ def _normalize(value, variable): return float(value) +@dataclass +class _DSGDecoderCache: + raw_to_corrected: Dict[Tuple[float, ...], Tuple[float, ...]] = field(default_factory=dict) + representations: Dict[Tuple[float, ...], "GraphRepresentation"] = field(default_factory=dict) + + class DSGGraphDecoder: """Decode design vectors into neutral SBArchOpt graph representations.""" @@ -111,19 +118,18 @@ def __init__( self._labelings = tuple( labelings if labelings is not None else get_default_dsg_labelings() ) + if len({labeling.key for labeling in self._labelings}) != len(self._labelings): + raise ValueError("node labeling keys must be unique") self.sizing_encoders = tuple( sizing_encoders if sizing_encoders is not None else (DesignVariableSizingEncoder(processor),) ) - self._raw_to_corrected: Dict[Tuple[float, ...], Tuple[float, ...]] = {} - self._representations: Dict[Tuple[float, ...], "GraphRepresentation"] = {} + self._cache = _DSGDecoderCache() def __deepcopy__(self, memo): - copied = self.__class__.__new__(self.__class__) - memo[id(self)] = copied - copied.__dict__ = self.__dict__.copy() - return copied + memo[id(self)] = self + return self @property def labelings(self): @@ -141,19 +147,19 @@ def decode(self, x: np.ndarray): representations = [] for row in np.asarray(x): raw_key = tuple(np.asarray(row, dtype=float)) - corrected_key = self._raw_to_corrected.get(raw_key) + corrected_key = self._cache.raw_to_corrected.get(raw_key) graph = None if corrected_key is None: graph, x_imputed, _ = self.processor.get_graph(row, create=False) corrected_key = tuple(x_imputed) - self._raw_to_corrected[raw_key] = corrected_key + self._cache.raw_to_corrected[raw_key] = corrected_key - representation = self._representations.get(corrected_key) + representation = self._cache.representations.get(corrected_key) if representation is None: if graph is None: graph, _, _ = self.processor.get_graph(corrected_key) representation = self._representation(graph, corrected_key) - self._representations[corrected_key] = representation + self._cache.representations[corrected_key] = representation representations.append(representation) return representations diff --git a/adsg_core/tests/test_optimization_graph_decoder.py b/adsg_core/tests/test_optimization_graph_decoder.py index b3ac81f..1a8371f 100644 --- a/adsg_core/tests/test_optimization_graph_decoder.py +++ b/adsg_core/tests/test_optimization_graph_decoder.py @@ -125,6 +125,22 @@ def test_dsg_graph_decoder_copies_share_representations(): decoder = DSGGraphDecoder(GraphProcessor(dsg)) copied = copy.deepcopy(decoder) + assert copied is decoder representation = decoder.decode(np.empty((1, 0)))[0] assert copied.decode(np.empty((1, 0)))[0] is representation + + +def test_dsg_graph_decoder_rejects_duplicate_labeling_keys(): + from sb_arch_opt.algo.arch_sbo.graph import NodeLabeling + + root = NamedNode("root") + dsg = BasicDSG() + dsg.add_node(root) + dsg = dsg.set_start_nodes({root}) + + with pytest.raises(ValueError, match="labeling keys must be unique"): + DSGGraphDecoder( + GraphProcessor(dsg), + labelings=[NodeLabeling("same", str), NodeLabeling("same", repr)], + ) From ec0aeecf9b591bab8f43b70e9a29fd18f72e1628 Mon Sep 17 00:00:00 2001 From: Franciszek Latala Date: Mon, 28 Sep 2026 16:00:06 +0200 Subject: [PATCH 3/7] add comments --- adsg_core/graph/labeling.py | 33 +++++++++++++++++++------ adsg_core/optimization/graph_decoder.py | 28 ++++++++++++++++++--- adsg_core/tests/test_graph_labeling.py | 3 +-- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/adsg_core/graph/labeling.py b/adsg_core/graph/labeling.py index 5e5e9fd..8f47aa1 100644 --- a/adsg_core/graph/labeling.py +++ b/adsg_core/graph/labeling.py @@ -1,4 +1,27 @@ -"""Generic node labelings for design-space graphs.""" +""" +MIT License + +Copyright: (c) 2026, Deutsches Zentrum fuer Luft- und Raumfahrt e.V. +Contact: jasper.bussemaker@dlr.de + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" from adsg_core.graph.adsg_nodes import ( ChoiceNode, @@ -14,7 +37,7 @@ ) -__all__ = ["constant_label", "dsg_type_label", "node_type_label"] +__all__ = ["constant_label", "dsg_type_label"] def constant_label(_node): @@ -44,9 +67,3 @@ def dsg_type_label(node): if isinstance(node, NamedNode): return "named_node" return "node" - - -def node_type_label(node): - """Label a node by its concrete class without exporting the class object.""" - node_type = type(node) - return node_type.__module__, node_type.__qualname__ diff --git a/adsg_core/optimization/graph_decoder.py b/adsg_core/optimization/graph_decoder.py index c477d26..3fa9966 100644 --- a/adsg_core/optimization/graph_decoder.py +++ b/adsg_core/optimization/graph_decoder.py @@ -1,4 +1,27 @@ -"""Optional SBArchOpt graph decoder for DSG optimization problems.""" +""" +MIT License + +Copyright: (c) 2026, Deutsches Zentrum fuer Luft- und Raumfahrt e.V. +Contact: jasper.bussemaker@dlr.de + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" from __future__ import annotations @@ -11,7 +34,7 @@ from adsg_core.graph.adsg import DSGType from adsg_core.graph.adsg_nodes import DesignVariableNode from adsg_core.graph.graph_edges import get_edge_type -from adsg_core.graph.labeling import constant_label, dsg_type_label, node_type_label +from adsg_core.graph.labeling import constant_label, dsg_type_label from adsg_core.optimization.graph_processor import GraphProcessor try: @@ -45,7 +68,6 @@ def get_default_dsg_labelings(): return ( NodeLabeling("adsg_core.constant", constant_label), NodeLabeling("adsg_core.dsg", dsg_type_label), - NodeLabeling("adsg_core.type", node_type_label), ) diff --git a/adsg_core/tests/test_graph_labeling.py b/adsg_core/tests/test_graph_labeling.py index e125215..cf4284f 100644 --- a/adsg_core/tests/test_graph_labeling.py +++ b/adsg_core/tests/test_graph_labeling.py @@ -4,7 +4,7 @@ NamedNode, SelectionChoiceNode, ) -from adsg_core.graph.labeling import constant_label, dsg_type_label, node_type_label +from adsg_core.graph.labeling import constant_label, dsg_type_label def test_generic_node_labelings(): @@ -18,4 +18,3 @@ def test_generic_node_labelings(): for node, role in nodes_and_roles: assert constant_label(node) == "node" assert dsg_type_label(node) == role - assert node_type_label(node) == (type(node).__module__, type(node).__qualname__) From 624462cfae53251ed1cc92268796c47a494bc67c Mon Sep 17 00:00:00 2001 From: Franciszek Latala Date: Mon, 28 Sep 2026 16:29:42 +0200 Subject: [PATCH 4/7] update comments --- adsg_core/optimization/graph_decoder.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/adsg_core/optimization/graph_decoder.py b/adsg_core/optimization/graph_decoder.py index 3fa9966..b249126 100644 --- a/adsg_core/optimization/graph_decoder.py +++ b/adsg_core/optimization/graph_decoder.py @@ -72,17 +72,30 @@ def get_default_dsg_labelings(): class DSGSizingEncoder(Protocol): + """Provide extra sizing values for each decoded DSG. + + Declare one feature for each value returned by ``encode``. The decoder + combines the values from its sizing encoders in the order they are given. + """ @property def features(self) -> Sequence["SizingFeature"]: + """Sizing features in the same order as the values from ``encode``.""" ... def encode(self, graph: DSGType, x_imputed: Sequence[float]) -> np.ndarray: + """Extract sizing values for one design. + + :param graph: The DSG generated for this design. + :param x_imputed: Its corrected design vector, including imputed values + for inactive variables. + :return: One-dimensional array with one value per declared feature. + """ ... class DesignVariableSizingEncoder: - """Export additional DesignVariableNode values as normalized sizing features.""" + """Encode DSG design-variable node values as sizing features.""" def __init__(self, processor: GraphProcessor): self._variables = tuple( From dbe6230819d0879888ad18a64a3a4e3d281ed007 Mon Sep 17 00:00:00 2001 From: Franciszek Latala Date: Tue, 29 Sep 2026 15:08:33 +0200 Subject: [PATCH 5/7] Cleanup code --- adsg_core/graph/__init__.py | 1 - adsg_core/graph/adsg_nodes.py | 34 +++++++++ adsg_core/graph/labeling.py | 69 ------------------- adsg_core/optimization/graph_decoder.py | 40 ++++++----- adsg_core/tests/test_graph_labeling.py | 4 +- .../tests/test_optimization_graph_decoder.py | 31 ++++++--- 6 files changed, 80 insertions(+), 99 deletions(-) delete mode 100644 adsg_core/graph/labeling.py diff --git a/adsg_core/graph/__init__.py b/adsg_core/graph/__init__.py index d2fd52f..2c8232f 100644 --- a/adsg_core/graph/__init__.py +++ b/adsg_core/graph/__init__.py @@ -2,4 +2,3 @@ from .adsg_basic import * from .adsg_nodes import * from .graph_edges import * -from .labeling import * diff --git a/adsg_core/graph/adsg_nodes.py b/adsg_core/graph/adsg_nodes.py index 2f5ed0c..dd4560a 100644 --- a/adsg_core/graph/adsg_nodes.py +++ b/adsg_core/graph/adsg_nodes.py @@ -74,6 +74,10 @@ def update_node_id(self): def get_export_shape(self) -> NodeExportShape: return NodeExportShape.CIRCLE + def get_dsg_label(self): + """Return this node's generic DSG role.""" + return 'node' + def get_export_color(self) -> str: return '#ffffff' @@ -118,6 +122,9 @@ def __init__(self, name: str, **kwargs): def get_export_title(self) -> str: return self.name + def get_dsg_label(self): + return 'named_node' + def __str__(self): return f'[{self.name}]' @@ -139,6 +146,9 @@ class ConnectorNode(DSGNode): connector node is included, before the connection choice is resolved. """ + def get_dsg_label(self): + return 'connector' + def __init__(self, name: str = None, deg_spec=None, deg_list=None, deg_min=1, deg_max=None, repeated_allowed=False, remove_if_unconnected=False, **kwargs): self.name = name @@ -279,6 +289,9 @@ class only looks at the aggregate number of connections accepted by the grouped should be called with the grouped nodes to update the connection degree specifications. """ + def get_dsg_label(self): + return 'connector_group' + def __init__(self, name: str = None): super(ConnectorDegreeGroupingNode, self).__init__(name) self.override_must_connect = False @@ -367,6 +380,9 @@ class DesignVariableNode(DSGNode): Set `is_ordinal=True` if there is inherent ordering between the available options. """ + def get_dsg_label(self): + return 'design_variable' + def __init__(self, name, bounds=None, options=None, idx=None, is_ordinal=False, **kwargs): if bounds is not None: if len(bounds) != 2: @@ -463,6 +479,9 @@ class MetricNode(DSGNode): - `CONSTRAINT`: metric is a constraint (provide a direction and a reference value) """ + def get_dsg_label(self): + return 'metric' + def __init__(self, name, direction: int = None, ref: float = None, idx=None, type_=None): self.name = name self.idx = idx @@ -510,6 +529,9 @@ class ChoiceNode(DSGNode): (categorical variable). """ + def get_dsg_label(self): + return 'choice' + def __init__(self, decision_id=None, decision_sort_key=None, is_ordinal=False): self.decision_sort_key = decision_sort_key self.is_ordinal = is_ordinal @@ -533,6 +555,9 @@ class SelectionChoiceNode(ChoiceNode): Set `is_ordinal=True` if there is inherent ordering between the available options. """ + def get_dsg_label(self): + return 'selection_choice' + def __str__(self): ordinal_str = f' (Ord)' if self.is_ordinal else '' return f'D[Sel{ordinal_str}: {self.decision_id or "_"}]' @@ -547,6 +572,9 @@ class CollectorNode(DSGNode): Node collecting multiple derivation edges into one. """ + def get_dsg_label(self): + return 'collector' + def __str__(self): return 'Col' @@ -559,6 +587,9 @@ class NonSelectionNode(DSGNode): Node representing an option for not selecting any of the other option nodes for a selection choice. """ + def get_dsg_label(self): + return 'non_selection' + def __str__(self): return 'NonSel' @@ -572,6 +603,9 @@ class ConnectionChoiceNode(ChoiceNode): target nodes. Sources and targets should be `ConnectorNode` types. """ + def get_dsg_label(self): + return 'connection_choice' + def __init__(self, decision_id=None, decision_sort_key=None): super(ConnectionChoiceNode, self).__init__(decision_id=decision_id, decision_sort_key=decision_sort_key) diff --git a/adsg_core/graph/labeling.py b/adsg_core/graph/labeling.py deleted file mode 100644 index 8f47aa1..0000000 --- a/adsg_core/graph/labeling.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -MIT License - -Copyright: (c) 2026, Deutsches Zentrum fuer Luft- und Raumfahrt e.V. -Contact: jasper.bussemaker@dlr.de - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -""" - -from adsg_core.graph.adsg_nodes import ( - ChoiceNode, - CollectorNode, - ConnectionChoiceNode, - ConnectorDegreeGroupingNode, - ConnectorNode, - DesignVariableNode, - MetricNode, - NamedNode, - NonSelectionNode, - SelectionChoiceNode, -) - - -__all__ = ["constant_label", "dsg_type_label"] - - -def constant_label(_node): - return "node" - - -def dsg_type_label(node): - """Label a node by its most specific generic DSG role.""" - if isinstance(node, SelectionChoiceNode): - return "selection_choice" - if isinstance(node, ConnectionChoiceNode): - return "connection_choice" - if isinstance(node, ChoiceNode): - return "choice" - if isinstance(node, DesignVariableNode): - return "design_variable" - if isinstance(node, MetricNode): - return "metric" - if isinstance(node, ConnectorDegreeGroupingNode): - return "connector_group" - if isinstance(node, ConnectorNode): - return "connector" - if isinstance(node, CollectorNode): - return "collector" - if isinstance(node, NonSelectionNode): - return "non_selection" - if isinstance(node, NamedNode): - return "named_node" - return "node" diff --git a/adsg_core/optimization/graph_decoder.py b/adsg_core/optimization/graph_decoder.py index b249126..c8907d2 100644 --- a/adsg_core/optimization/graph_decoder.py +++ b/adsg_core/optimization/graph_decoder.py @@ -26,6 +26,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from operator import methodcaller from typing import Dict, Protocol, Sequence, Tuple import networkx as nx @@ -34,7 +35,6 @@ from adsg_core.graph.adsg import DSGType from adsg_core.graph.adsg_nodes import DesignVariableNode from adsg_core.graph.graph_edges import get_edge_type -from adsg_core.graph.labeling import constant_label, dsg_type_label from adsg_core.optimization.graph_processor import GraphProcessor try: @@ -54,7 +54,6 @@ "DSGSizingEncoder", "DesignVariableSizingEncoder", "DSGGraphDecoder", - "get_default_dsg_labelings", ] @@ -63,14 +62,6 @@ def _check_dependency(): raise ImportError("SBArchOpt graph support is not installed") -def get_default_dsg_labelings(): - _check_dependency() - return ( - NodeLabeling("adsg_core.constant", constant_label), - NodeLabeling("adsg_core.dsg", dsg_type_label), - ) - - class DSGSizingEncoder(Protocol): """Provide extra sizing values for each decoded DSG. @@ -142,19 +133,24 @@ class _DSGDecoderCache: class DSGGraphDecoder: """Decode design vectors into neutral SBArchOpt graph representations.""" + @staticmethod + def _constant_label(_node): + return "node" + def __init__( self, processor: GraphProcessor, - labelings=None, sizing_encoders: Sequence[DSGSizingEncoder] = None, + *, + use_constant_labels: bool = True, + use_dsg_type_labels: bool = True, ): _check_dependency() self.processor = processor - self._labelings = tuple( - labelings if labelings is not None else get_default_dsg_labelings() - ) - if len({labeling.key for labeling in self._labelings}) != len(self._labelings): - raise ValueError("node labeling keys must be unique") + self.use_constant_labels = use_constant_labels + self.use_dsg_type_labels = use_dsg_type_labels + self.constant_labeling = NodeLabeling("adsg_core.constant", self._constant_label) + self.dsg_type_labeling = NodeLabeling("adsg_core.dsg", methodcaller("get_dsg_label")) self.sizing_encoders = tuple( sizing_encoders if sizing_encoders is not None @@ -168,7 +164,17 @@ def __deepcopy__(self, memo): @property def labelings(self): - return self._labelings + return self.constant_labeling, self.dsg_type_labeling + + @property + def selected_labelings(self): + """Labelings selected by the decoder's boolean options.""" + selected = [] + if self.use_constant_labels: + selected.append(self.constant_labeling) + if self.use_dsg_type_labels: + selected.append(self.dsg_type_labeling) + return tuple(selected) @property def sizing_features(self): diff --git a/adsg_core/tests/test_graph_labeling.py b/adsg_core/tests/test_graph_labeling.py index cf4284f..3e9b32f 100644 --- a/adsg_core/tests/test_graph_labeling.py +++ b/adsg_core/tests/test_graph_labeling.py @@ -4,7 +4,6 @@ NamedNode, SelectionChoiceNode, ) -from adsg_core.graph.labeling import constant_label, dsg_type_label def test_generic_node_labelings(): @@ -16,5 +15,4 @@ def test_generic_node_labelings(): ] for node, role in nodes_and_roles: - assert constant_label(node) == "node" - assert dsg_type_label(node) == role + assert node.get_dsg_label() == role diff --git a/adsg_core/tests/test_optimization_graph_decoder.py b/adsg_core/tests/test_optimization_graph_decoder.py index 1a8371f..37646ee 100644 --- a/adsg_core/tests/test_optimization_graph_decoder.py +++ b/adsg_core/tests/test_optimization_graph_decoder.py @@ -1,4 +1,5 @@ import copy +import pickle import numpy as np import pytest @@ -6,7 +7,10 @@ from adsg_core.graph.adsg_basic import BasicDSG from adsg_core.graph.adsg_nodes import ConnectorNode, DesignVariableNode, NamedNode from adsg_core.graph.graph_edges import EdgeType, add_edge -from adsg_core.optimization.graph_decoder import HAS_SB_ARCH_OPT_GRAPH, DSGGraphDecoder +from adsg_core.optimization.graph_decoder import ( + HAS_SB_ARCH_OPT_GRAPH, + DSGGraphDecoder, +) from adsg_core.optimization.graph_processor import GraphProcessor @@ -131,16 +135,25 @@ def test_dsg_graph_decoder_copies_share_representations(): assert copied.decode(np.empty((1, 0)))[0] is representation -def test_dsg_graph_decoder_rejects_duplicate_labeling_keys(): - from sb_arch_opt.algo.arch_sbo.graph import NodeLabeling - +def test_dsg_graph_decoder_labeling_flags(): root = NamedNode("root") dsg = BasicDSG() dsg.add_node(root) dsg = dsg.set_start_nodes({root}) + decoder = DSGGraphDecoder( + GraphProcessor(dsg), + use_constant_labels=False, + ) + + assert decoder.constant_labeling in decoder.labelings + assert decoder.dsg_type_labeling in decoder.labelings + constant, dsg_type = pickle.loads(pickle.dumps(decoder.labelings)) + assert constant(root) == "node" + assert dsg_type(root) == "named_node" + assert [labeling.key for labeling in decoder.selected_labelings] == ["adsg_core.dsg"] + decoder.use_constant_labels = True + decoder.use_dsg_type_labels = False + assert [labeling.key for labeling in decoder.selected_labelings] == ["adsg_core.constant"] - with pytest.raises(ValueError, match="labeling keys must be unique"): - DSGGraphDecoder( - GraphProcessor(dsg), - labelings=[NodeLabeling("same", str), NodeLabeling("same", repr)], - ) + representation = decoder.decode(np.empty((1, 0)))[0] + assert set(representation.node_labels) == set(decoder.labelings) From d0f9b8853f0e24a0bd67460f77df9465c23e0c61 Mon Sep 17 00:00:00 2001 From: Franciszek Latala Date: Wed, 14 Oct 2026 14:21:33 +0200 Subject: [PATCH 6/7] add documentation --- docs/guide_graph_based_optimization.ipynb | 311 ++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 312 insertions(+) create mode 100644 docs/guide_graph_based_optimization.ipynb diff --git a/docs/guide_graph_based_optimization.ipynb b/docs/guide_graph_based_optimization.ipynb new file mode 100644 index 0000000..a8fbfc9 --- /dev/null +++ b/docs/guide_graph_based_optimization.ipynb @@ -0,0 +1,311 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# Graph-based optimization of a DSG\n", + "\n", + "Below is shown how a design space can be built using the DSG and optimized with SBArchOpt's graph surrogate. The alternatives are one processor or two processors of a common size `s in [0, 1]`, with a sizing variable on each branch." + ] + }, + { + "cell_type": "code", + "id": "imports", + "metadata": {}, + "execution_count": 1, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "from pymoo.optimize import minimize\n", + "\n", + "from adsg_core import BasicDSG, NamedNode, DesignVariableNode, MetricNode, MetricType, DSGEvaluator\n", + "from adsg_core.optimization.graph_decoder import DSGGraphDecoder\n", + "from sb_arch_opt.algo.arch_sbo import get_sbo\n", + "from sb_arch_opt.algo.arch_sbo.graph import CompositeGraphKernel, LdWloa, SizingKernel\n", + "from sb_arch_opt.algo.arch_sbo.infill import get_default_infill\n", + "from sb_arch_opt.algo.arch_sbo.models import ModelFactory\n", + "\n", + "SEED = 42\n", + "N_INIT = 10\n", + "BATCH_SIZE = 2\n", + "MAX_EVALS = 24" + ] + }, + { + "cell_type": "markdown", + "id": "graph-text", + "metadata": {}, + "source": [ + "## Building the Design Space\n", + "\n", + "A selection choice in `BasicDSG` activates either the single-processor or two-processor branch. Each branch has its own sizing variable, so exactly one size is active in a completed design. The objective node is attached to the system root and remains present for both alternatives." + ] + }, + { + "cell_type": "code", + "id": "graph", + "metadata": {}, + "execution_count": 2, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "system = NamedNode(\"System\")\n", + "single = NamedNode(\"Single processor\")\n", + "double = NamedNode(\"Two processors\")\n", + "single_size = DesignVariableNode(\"single_size\", bounds=(0.0, 1.0))\n", + "double_size = DesignVariableNode(\"double_size\", bounds=(0.0, 1.0))\n", + "loss = MetricNode(\"loss\", direction=-1, type_=MetricType.OBJECTIVE)\n", + "\n", + "dsg = BasicDSG()\n", + "dsg.add_selection_choice(\"architecture\", system, [single, double])\n", + "dsg.add_edges([\n", + " (system, loss),\n", + " (single, single_size),\n", + " (double, double_size),\n", + " (double, NamedNode(\"Processor 1\")),\n", + " (double, NamedNode(\"Processor 2\")),\n", + "])\n", + "dsg = dsg.set_start_nodes({system})\n", + "dsg.render()" + ] + }, + { + "cell_type": "markdown", + "id": "evaluator-text", + "metadata": {}, + "source": [ + "## Defining the Evaluation Function\n", + "\n", + "The evaluation function is implemented by extending `DSGEvaluator` and overriding `_evaluate`. It receives a completed graph and the requested metric nodes. The remaining sizing node identifies the selected branch and supplies its size; the returned dictionary maps each metric node to its calculated value." + ] + }, + { + "cell_type": "code", + "id": "evaluator", + "metadata": {}, + "execution_count": 3, + "outputs": [], + "source": [ + "class ProcessorEvaluator(DSGEvaluator):\n", + " def _evaluate(self, graph, metric_nodes):\n", + " size_node = graph.des_var_nodes[0]\n", + " n_processors = 1 if size_node.name == \"single_size\" else 2\n", + " size = graph.des_var_value(size_node)\n", + " value = (n_processors * size - 1.4)**2 + 0.1 * n_processors\n", + " return {metric_nodes[0]: value}\n", + "\n", + "evaluator = ProcessorEvaluator(dsg)\n", + "problem = evaluator.get_problem(parallel_processes=False)" + ] + }, + { + "cell_type": "markdown", + "id": "surrogate-text", + "metadata": {}, + "source": [ + "## Constructing the Graph Surrogate\n", + "\n", + "`DSGGraphDecoder` uses the evaluator's graph processor to resolve design vectors and export graphs, constant and DSG-role labels, and normalized sizing values. Inactive numeric variables are imputed to their midpoint. `LdWloa` compares the exported graphs and labels, and `SizingKernel` compares the sizing values. Their composite includes both similarities and their product; `ModelFactory` creates the surrogate and input normalization." + ] + }, + { + "cell_type": "code", + "id": "surrogate", + "metadata": {}, + "execution_count": 4, + "outputs": [], + "source": [ + "# Decoder that can deconstruct a design vector into it's associated DSG graph instance\n", + "decoder = DSGGraphDecoder(evaluator)\n", + "\n", + "# Define the composite kernel consisting of a graph kernel for\n", + "# structural similarity and a sizing kernel for sizing parameters\n", + "kernel = CompositeGraphKernel(\n", + " branches=[\n", + " LdWloa(cutoff=2, labelings=decoder.selected_labelings),\n", + " SizingKernel(decoder.sizing_features),\n", + " ],\n", + " composition=\"additive_interaction\",\n", + ")\n", + "\n", + "# Define the graph kernel surrogate model\n", + "surrogate, normalization = ModelFactory(problem).get_graph_kriging_model(\n", + " decoder=decoder,\n", + " kernel=kernel,\n", + " multi=False,\n", + " seed=SEED,\n", + " n_start=1,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "run-text", + "metadata": {}, + "source": [ + "## Running the Optimization\n", + "\n", + "The run starts with ten evaluations and adds two designs per infill batch until 24 designs have been evaluated. The problem wrapper uses the evaluator to resolve and evaluate each design; the same evaluator supplies the graph processor used by the decoder." + ] + }, + { + "cell_type": "code", + "id": "run", + "metadata": {}, + "execution_count": 5, + "outputs": [], + "source": [ + "# Get the infill criterion\n", + "infill, _ = get_default_infill(problem, n_parallel=BATCH_SIZE)\n", + "\n", + "# Prepare an infill point optimisation SBO algorithm that uses the graph-kernel / sizing kernel hybrid surrogate.\n", + "algorithm = get_sbo(\n", + " surrogate_model=surrogate,\n", + " infill=infill,\n", + " init_size=N_INIT,\n", + " infill_size=BATCH_SIZE,\n", + " infill_pop_size=30,\n", + " infill_gens=10,\n", + " normalization=normalization,\n", + ")\n", + "\n", + "# Optimize designs\n", + "result = minimize(\n", + " problem, algorithm,\n", + " termination=(\"n_evals\", MAX_EVALS),\n", + " seed=SEED,\n", + " verbose=False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "result-text", + "metadata": {}, + "source": [ + "## Recovering the Selected Design\n", + "\n", + "`get_graph` converts the best design vector back into a DSG instance. The remaining sizing node identifies the selected branch and its size. The plot shows the best objective observed as evaluations accumulate; the dashed line marks the known minimum of 0.2." + ] + }, + { + "cell_type": "code", + "id": "result", + "metadata": {}, + "execution_count": 6, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Selected size variable: double_size\n", + "Size: 0.700\n", + "Best objective: 0.2000; known optimum: 0.2000\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "best_graph, _, _ = evaluator.get_graph(result.X)\n", + "best_size = best_graph.des_var_nodes[0]\n", + "print(f\"Selected size variable: {best_size.name}\")\n", + "print(f\"Size: {best_graph.des_var_value(best_size):.3f}\")\n", + "print(f\"Best objective: {result.F[0]:.4f}; known optimum: 0.2000\")\n", + "\n", + "values = result.pop.get(\"F\")[:, 0]\n", + "fig, ax = plt.subplots(figsize=(6, 4))\n", + "ax.plot(np.arange(1, len(values) + 1), np.minimum.accumulate(values))\n", + "ax.axhline(0.2, color=\"grey\", linestyle=\"--\", label=\"Known optimum\")\n", + "ax.set(xlabel=\"Evaluations\", ylabel=\"Best objective observed\")\n", + "ax.legend()\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/mkdocs.yml b/mkdocs.yml index 22a6ee2..2cade5d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,6 +15,7 @@ nav: - 'Theory': theory.md - Guide: - Guide: guide.ipynb + - 'Graph-based Optimization': guide_graph_based_optimization.ipynb - 'Cardinality Guide': guide_cardinality.ipynb - 'Example: Apollo': example_apollo.ipynb - 'Example: GNC': example_gnc.ipynb From f40f6b43a33588363fd9889caeaa6989d784d1a3 Mon Sep 17 00:00:00 2001 From: Franciszek Latala Date: Sat, 17 Oct 2026 13:29:22 +0200 Subject: [PATCH 7/7] Update dependency handling --- adsg_core/optimization/graph_decoder.py | 6 +++++- adsg_core/tests/test_optimization_graph_decoder.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/adsg_core/optimization/graph_decoder.py b/adsg_core/optimization/graph_decoder.py index c8907d2..1a14631 100644 --- a/adsg_core/optimization/graph_decoder.py +++ b/adsg_core/optimization/graph_decoder.py @@ -39,6 +39,7 @@ try: from sb_arch_opt.algo.arch_sbo.graph import ( + GraphDecoder, GraphRepresentation, NodeLabeling, SizingFeature, @@ -48,6 +49,9 @@ except ImportError: HAS_SB_ARCH_OPT_GRAPH = False + class GraphDecoder: + pass + __all__ = [ "HAS_SB_ARCH_OPT_GRAPH", @@ -130,7 +134,7 @@ class _DSGDecoderCache: representations: Dict[Tuple[float, ...], "GraphRepresentation"] = field(default_factory=dict) -class DSGGraphDecoder: +class DSGGraphDecoder(GraphDecoder): """Decode design vectors into neutral SBArchOpt graph representations.""" @staticmethod diff --git a/adsg_core/tests/test_optimization_graph_decoder.py b/adsg_core/tests/test_optimization_graph_decoder.py index 37646ee..c0bca74 100644 --- a/adsg_core/tests/test_optimization_graph_decoder.py +++ b/adsg_core/tests/test_optimization_graph_decoder.py @@ -116,6 +116,7 @@ def encode(self, graph, x_imputed): ) representation = decoder.decode(np.empty((1, 0)))[0] + assert GraphDecoder in DSGGraphDecoder.__bases__ assert isinstance(decoder, GraphDecoder) assert decoder.sizing_features == (SizingFeature("attribute", "categorical"),) np.testing.assert_allclose(representation.sizing_values, [2.0])