From bec11637aa116d17fdf48564e0e23b1c5215553f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Gallard?= Date: Wed, 9 Sep 2026 21:56:30 +0200 Subject: [PATCH 01/21] Add GEMSEO interface, examples and tests Add philote_mdo.gemseo, providing PhiloteDiscipline (a GEMSEO Discipline that calls a remote Philote-MDO server) and GEMSEOtoPhiloteDiscipline (the opposite direction, serving a GEMSEO discipline over gRPC). Add matching examples (Paraboloid, OpenAeroStruct, and the Sellar problem driven from OpenMDAO) and integration tests under tests/, adapted to the current OpenMdaoSubProblem() + add_group() construction pattern. Also add the gemseo extra to pyproject.toml, add gemseo to the CI test dependencies, and fix proto compilation on Windows. --- .github/workflows/tests.yaml | 2 +- CHANGELOG.md | 6 + examples/openaerostruct_to_gemseo.py | 150 +++++++++++++++++++++ examples/paraboloid_gemseo.py | 123 +++++++++++++++++ examples/sellar_gemseo_to_openmdao.py | 161 +++++++++++++++++++++++ philote_mdo/gemseo/__init__.py | 31 +++++ philote_mdo/gemseo/gemseo_to_philote.py | 155 ++++++++++++++++++++++ philote_mdo/gemseo/philote_to_gemseo.py | 165 +++++++++++++++++++++++ pyproject.toml | 1 + tests/test_gemseo_to_philote.py | 103 +++++++++++++++ tests/test_openmdao_to_gemseo.py | 96 ++++++++++++++ tests/test_philote_to_gemseo.py | 77 +++++++++++ tests/test_sellar_gemseo_to_openmdao.py | 167 ++++++++++++++++++++++++ utils/compile_proto.py | 46 ++++--- 14 files changed, 1266 insertions(+), 17 deletions(-) create mode 100644 examples/openaerostruct_to_gemseo.py create mode 100644 examples/paraboloid_gemseo.py create mode 100644 examples/sellar_gemseo_to_openmdao.py create mode 100644 philote_mdo/gemseo/__init__.py create mode 100644 philote_mdo/gemseo/gemseo_to_philote.py create mode 100644 philote_mdo/gemseo/philote_to_gemseo.py create mode 100644 tests/test_gemseo_to_philote.py create mode 100644 tests/test_openmdao_to_gemseo.py create mode 100644 tests/test_philote_to_gemseo.py create mode 100644 tests/test_sellar_gemseo_to_openmdao.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 5f6c9d9..60e67b9 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -34,7 +34,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 coverage[toml] grpcio grpcio-tools protoletariat numpy scipy openmdao + pip install flake8 coverage[toml] grpcio grpcio-tools protoletariat numpy scipy openmdao gemseo if [ -f requirements.txt ]; then pip install -r requirements.txt; fi sudo apt install -y protobuf-compiler - name: Install Package diff --git a/CHANGELOG.md b/CHANGELOG.md index e94db42..484dd98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 twice for an implicit output, which is quadratic in the number of dynamic variables. Both now index the metadata by type and name once. Applying shapes to 1,000 dynamic variables drops from 922 ms to 120 ms. +- Added a GEMSEO interface (`philote_mdo.gemseo`) allowing GEMSEO + disciplines to be called from Philote-MDO, and Philote-MDO disciplines + (local or remote, e.g. served from OpenMDAO) to be called from GEMSEO. +- Added examples and tutorials demonstrating interoperability between + GEMSEO, OpenMDAO and OpenAeroStruct through Philote-MDO. ### Bug Fixes @@ -121,6 +126,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 removes redundant work inside `compute_totals` on every gradient call. Results were already correct, since the totals are indexed by the `(of, wrt)` pair rather than by position (#80). +- Fixed the compilation of the proto files on Windows. ### Documentation & Infrastructure diff --git a/examples/openaerostruct_to_gemseo.py b/examples/openaerostruct_to_gemseo.py new file mode 100644 index 0000000..187d5c0 --- /dev/null +++ b/examples/openaerostruct_to_gemseo.py @@ -0,0 +1,150 @@ +# Philote-Python +# +# Copyright 2026 IRT Saint Exupery +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# originally authored by Francois Gallard, IRT Saint Exupery. +"""Trim a remote OpenAeroStruct aerostructural discipline with GEMSEO. + +This example starts a gRPC server exposing ``philote_examples``' +``OasAerostructDiscipline`` -- a coupled OpenAeroStruct aerostructural +analysis of a single wing (VLM aerodynamics + FEM structure, run through +OpenMDAO) wrapped as a Philote-MDO explicit discipline -- connects a +:class:`~philote_mdo.gemseo.philote_to_gemseo.PhiloteDiscipline` client to +it, and uses this discipline in a GEMSEO +:class:`~gemseo.scenarios.mdo_scenario.MDOScenario`: find the angle of +attack ``alpha`` that minimizes the drag coefficient ``CD`` while trimming +the wing at a target lift coefficient ``CL = 0.5``, using the +gradient-based SLSQP algorithm. + +The remote discipline only exposes the partial derivatives of ``CL`` and +``CD`` with respect to ``alpha`` (see the two +``declare_subproblem_partial`` calls in +``OasAerostructDiscipline._build_discipline``), so ``alpha`` is the only +usable design variable, and ``CL``/``CD`` the only usable objective and +constraint, for a gradient-based scenario. The other flight-condition and +mission parameters are kept fixed at their default values. + +This requires GEMSEO, OpenMDAO, OpenAeroStruct and ``philote-examples`` to +be installed (``pip install gemseo openaerostruct philote-examples``). +""" + +from __future__ import annotations + +from concurrent import futures + +import grpc +import numpy as np +import philote_mdo.general as pmdo +from gemseo import create_design_space +from gemseo import create_scenario +from gemseo import execute_post +from gemseo.settings.formulations import DisciplinaryOpt_Settings +from gemseo.settings.opt import SLSQP_Settings +from gemseo.settings.post import OptHistoryView_Settings +from numpy import array +from openaerostruct.utils.constants import grav_constant +from philote_examples import OasAerostructDiscipline + +from philote_mdo.gemseo import PhiloteDiscipline + +HOST = "localhost" +"""The host of the Philote discipline server.""" + +PORT = 50052 +"""The port of the Philote discipline server.""" + +FIXED_INPUTS = { + "v": array([248.136]), + "Mach_number": array([0.84]), + "re": array([1e6]), + "rho": array([0.38]), + "CT": array([grav_constant * 17.0e-6]), + "R": array([11.165e6]), + "W0": array([0.4 * 3e5]), + "speed_of_sound": array([295.4]), + "load_factor": array([1.0]), + "empty_cg": np.zeros(3), +} +"""The flight-condition and mission inputs that are not design variables. + +These are set as default input values of the discipline, so that the +scenario only has to provide a value for the "alpha" design variable. +""" + + +def create_discipline(server: grpc.Server) -> PhiloteDiscipline: + """Serve the OAS aerostructural discipline and connect a GEMSEO client to it. + + Args: + server: The started gRPC server to attach the OAS aerostructural + Philote discipline to. + + Returns: + A GEMSEO discipline connected to the remote OAS discipline, with + its non-design inputs (flight conditions and mission parameters) + set to their default values. + """ + discipline = pmdo.ExplicitServer(discipline=OasAerostructDiscipline()) + discipline.attach_to_server(server) + oas_discipline = PhiloteDiscipline(channel=grpc.insecure_channel(f"{HOST}:{PORT}")) + oas_discipline.default_input_data.update(FIXED_INPUTS) + return oas_discipline + + +def create_server() -> grpc.Server: + """Create and start the gRPC server used to serve Philote disciplines. + + Returns: + The started gRPC server. + """ + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + server.add_insecure_port(f"[::]:{PORT}") + server.start() + return server + + +if __name__ == "__main__": + server = create_server() + + try: + oas_disc = create_discipline(server) + + design_space = create_design_space() + # alpha is in degrees, as declared by OasAerostructDiscipline. + design_space.add_variable( + "alpha", lower_bound=-10.0, upper_bound=15.0, value=5.0 + ) + + scenario = create_scenario( + [oas_disc], + objective_name="CD", + design_space=design_space, + formulation_settings_model=DisciplinaryOpt_Settings(), + ) + # Trim the wing: CL = 0.5 + scenario.add_constraint("CL", constraint_type="eq", value=0.5) + scenario.execute(algo_settings_model=SLSQP_Settings(max_iter=20)) + + out = oas_disc.local_data + print( + "Trimmed solution:", + {name: out[name] for name in ("alpha", "CL", "CD", "failure")}, + ) + execute_post(scenario, OptHistoryView_Settings(save=True, show=False)) + + finally: + # Stop the server explicitly: + # its worker threads would otherwise keep the process alive. + server.stop(0) diff --git a/examples/paraboloid_gemseo.py b/examples/paraboloid_gemseo.py new file mode 100644 index 0000000..265bec0 --- /dev/null +++ b/examples/paraboloid_gemseo.py @@ -0,0 +1,123 @@ +# Philote-Python +# +# Copyright 2026 IRT Saint Exupery +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# originally authored by Francois Gallard, IRT Saint Exupery. +"""Optimize a remote Philote discipline with GEMSEO. + +This example starts a gRPC server exposing the +``philote_mdo.examples.Paraboloid`` explicit discipline through the +Philote-MDO protocol, connects a ``philote_mdo.gemseo.PhiloteDiscipline`` +client to it, and uses this discipline like any other GEMSEO discipline in +a mono-disciplinary optimization scenario: minimize ``f_xy(x, y)`` under +the constraint ``0 <= x + y <= 10``. + +This requires GEMSEO to be installed (``pip install gemseo``). +""" + +from __future__ import annotations + +from concurrent import futures + +import grpc +from gemseo import create_design_space +from gemseo import create_scenario +from gemseo.disciplines.auto_py import AutoPyDiscipline +from gemseo.settings.formulations import DisciplinaryOpt_Settings +from gemseo.settings.opt import COBYQA_Settings +from numpy import ndarray + +from philote_mdo.examples import Paraboloid +from philote_mdo.gemseo import PhiloteDiscipline +from philote_mdo.general import ExplicitServer + +HOST = "localhost" +"""The host of the Philote discipline server.""" + +PORT = 50051 +"""The port of the Philote discipline server.""" + + +def create_discipline(server: grpc.Server) -> PhiloteDiscipline: + """Serve the Paraboloid discipline and connect a GEMSEO client to it. + + Args: + server: The started gRPC server to attach the Paraboloid Philote + discipline to. + + Returns: + A GEMSEO discipline connected to the remote Paraboloid discipline. + """ + discipline = ExplicitServer(discipline=Paraboloid()) + discipline.attach_to_server(server) + return PhiloteDiscipline(channel=grpc.insecure_channel(f"{HOST}:{PORT}")) + + +def create_server() -> grpc.Server: + """Create and start the gRPC server used to serve Philote disciplines. + + Returns: + The started gRPC server. + """ + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + server.add_insecure_port(f"[::]:{PORT}") + server.start() + return server + + +def compute_constraint(x: ndarray, y: ndarray) -> ndarray: + """Compute the constraint function ``g``. + + Args: + x: The first paraboloid variable. + y: The second paraboloid variable. + + Returns: + The constraint value ``g``, bounded between 0 and 10 in the scenario. + """ + # AutoPyDiscipline infers the output name from this assignment, so the + # variable must be named "g" and returned as-is (not e.g. "return x + y"). + g = x + y + return g + + +if __name__ == "__main__": + server = create_server() + + try: + paraboloid_disc = create_discipline(server) + design_space = create_design_space() + design_space.add_variable("x", lower_bound=-50, upper_bound=50, value=3.0) + design_space.add_variable("y", lower_bound=-50, upper_bound=50, value=-4.0) + + scenario = create_scenario( + [paraboloid_disc, AutoPyDiscipline(py_func=compute_constraint)], + objective_name="f_xy", + design_space=design_space, + formulation_settings_model=DisciplinaryOpt_Settings(), + ) + + # 0 <= g <= 10 + scenario.add_constraint("g", constraint_type="ineq", positive=True) + scenario.add_constraint("g", constraint_type="ineq", value=10.0) + scenario.execute(algo_settings_model=COBYQA_Settings(max_iter=100)) + + print( + "Optimal design found:", design_space.get_current_value(as_dict=True) + ) + finally: + # Stop the server explicitly: + # its worker threads would otherwise keep the process alive. + server.stop(0) diff --git a/examples/sellar_gemseo_to_openmdao.py b/examples/sellar_gemseo_to_openmdao.py new file mode 100644 index 0000000..98b3265 --- /dev/null +++ b/examples/sellar_gemseo_to_openmdao.py @@ -0,0 +1,161 @@ +# Philote-Python +# +# Copyright 2026 IRT Saint Exupery +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# originally authored by Francois Gallard, IRT Saint Exupery +"""Optimize the GEMSEO Sellar problem from OpenMDAO through Philote-MDO. + +This example serves each discipline of GEMSEO's customizable Sellar MDO +problem (``Sellar1``, ``Sellar2``, ``SellarSystem``) as an independent +Philote-MDO gRPC server, using ``philote_mdo.gemseo.GEMSEOtoPhiloteDiscipline``, +and builds an OpenMDAO model that calls them through +``philote_mdo.openmdao.RemoteExplicitComponent``. The algebraic coupling +between ``Sellar1`` and ``Sellar2`` (through ``y_1``, ``y_2``) is resolved +with a Gauss-Seidel solver, and OpenMDAO's SLSQP driver optimizes the +coupled system using the analytic gradients computed by the GEMSEO +disciplines and transferred over gRPC. + +This demonstrates the "OpenMDAO calls a remote GEMSEO discipline" direction +of the ``philote_mdo.gemseo`` integration, complementing ``PhiloteDiscipline`` +(used for the opposite direction: GEMSEO calling a remote discipline, e.g. +served from OpenMDAO -- see ``examples/paraboloid_gemseo.py``). + +The optimal solution of this classic problem is well known: +``x_1 = 0``, ``x_shared = [1.9776, 0]``, ``obj = 3.1834``. + +This requires GEMSEO to be installed (``pip install gemseo``). +""" + +from __future__ import annotations + +from concurrent import futures +from typing import TYPE_CHECKING + +import grpc +import openmdao.api as om +from gemseo.problems.mdo.sellar.sellar_1 import Sellar1 +from gemseo.problems.mdo.sellar.sellar_2 import Sellar2 +from gemseo.problems.mdo.sellar.sellar_system import SellarSystem + +from philote_mdo.gemseo import GEMSEOtoPhiloteDiscipline +from philote_mdo.general import ExplicitServer +from philote_mdo.openmdao import RemoteExplicitComponent + +if TYPE_CHECKING: + from gemseo.core.discipline.discipline import Discipline + +HOST = "localhost" +"""The host of the Philote discipline servers.""" + +PORT_D1 = 50061 +"""The port serving the Sellar1 discipline (computes y_1).""" + +PORT_D2 = 50062 +"""The port serving the Sellar2 discipline (computes y_2).""" + +PORT_SYSTEM = 50063 +"""The port serving the SellarSystem discipline (computes obj, c_1, c_2).""" + + +def create_discipline_server(discipline: Discipline, port: int) -> grpc.Server: + """Serve a GEMSEO discipline as a Philote-MDO explicit discipline. + + Args: + discipline: The GEMSEO discipline to serve. + port: The port to serve it on. + + Returns: + The started gRPC server. + """ + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + philote_discipline = GEMSEOtoPhiloteDiscipline(discipline) + ExplicitServer(discipline=philote_discipline).attach_to_server(server) + server.add_insecure_port(f"[::]:{port}") + server.start() + return server + + +def create_remote_component(port: int) -> RemoteExplicitComponent: + """Create an OpenMDAO component connected to a remote Philote discipline. + + Args: + port: The port of the Philote discipline server to connect to. + + Returns: + An OpenMDAO explicit component calling the remote discipline. + """ + return RemoteExplicitComponent(channel=grpc.insecure_channel(f"{HOST}:{port}")) + + +if __name__ == "__main__": + servers = [ + create_discipline_server(Sellar1(), PORT_D1), + create_discipline_server(Sellar2(), PORT_D2), + create_discipline_server(SellarSystem(), PORT_SYSTEM), + ] + + try: + prob = om.Problem() + model = prob.model + + # Sellar1 and Sellar2 are coupled through y_1 and y_2: group them so + # that this algebraic loop can be resolved by a dedicated solver. + cycle = model.add_subsystem("cycle", om.Group(), promotes=["*"]) + cycle.add_subsystem("d1", create_remote_component(PORT_D1), promotes=["*"]) + cycle.add_subsystem("d2", create_remote_component(PORT_D2), promotes=["*"]) + cycle.nonlinear_solver = om.NonlinearBlockGS() + # Compute the exact total derivatives across the coupling loop. + cycle.linear_solver = om.DirectSolver() + + model.add_subsystem( + "system", create_remote_component(PORT_SYSTEM), promotes=["*"] + ) + + model.add_design_var("x_1", lower=0.0, upper=10.0) + model.add_design_var("x_2", lower=0.0, upper=10.0) + model.add_design_var("x_shared", lower=[-10.0, 0.0], upper=[10.0, 10.0]) + model.add_objective("obj") + model.add_constraint("c_1", upper=0.0) + model.add_constraint("c_2", upper=0.0) + + prob.driver = om.ScipyOptimizeDriver(optimizer="SLSQP", tol=1e-8) + + prob.setup() + + # The Philote-MDO protocol only transfers variable names, shapes and + # units, not GEMSEO's default values, so the non-design parameters + # and the design variables' starting point must be set explicitly + # here, matching gemseo's SellarDesignSpace and get_initial_data. + prob.set_val("x_1", 1.0) + prob.set_val("x_2", 1.0) + prob.set_val("x_shared", [4.0, 3.0]) + prob.set_val("alpha", 3.16) + prob.set_val("beta", 24.0) + prob.set_val("gamma", 0.2) + + prob.run_driver() + + print( + "Optimal solution:", + { + name: prob.get_val(name) + for name in ("x_1", "x_2", "x_shared", "obj", "c_1", "c_2") + }, + ) + finally: + # Stop the servers explicitly: + # their worker threads would otherwise keep the process alive. + for server in servers: + server.stop(0) diff --git a/philote_mdo/gemseo/__init__.py b/philote_mdo/gemseo/__init__.py new file mode 100644 index 0000000..5f14781 --- /dev/null +++ b/philote_mdo/gemseo/__init__.py @@ -0,0 +1,31 @@ +# Philote-Python +# +# Copyright 2022-2024 Christopher A. Lupp +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# +# This work has been cleared for public release, distribution unlimited, case +# number: AFRL-2023-5713. +# +# The views expressed are those of the authors and do not reflect the +# official guidance or position of the United States Government, the +# Department of Defense or of the United States Air Force. +# +# Statement from DoD: The Appearance of external hyperlinks does not +# constitute endorsement by the United States Department of Defense (DoD) of +# the linked websites, of the information, products, or services contained +# therein. The DoD does not exercise any editorial, security, or other +# control over the information you may find at these locations. +from .gemseo_to_philote import GEMSEOtoPhiloteDiscipline +from .philote_to_gemseo import PhiloteDiscipline diff --git a/philote_mdo/gemseo/gemseo_to_philote.py b/philote_mdo/gemseo/gemseo_to_philote.py new file mode 100644 index 0000000..63c3d32 --- /dev/null +++ b/philote_mdo/gemseo/gemseo_to_philote.py @@ -0,0 +1,155 @@ +# Philote-Python +# +# Copyright 2026 IRT Saint Exupery +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# originally authored by Francois Gallard, IRT Saint Exupery. +"""A Philote explicit discipline wrapping a GEMSEO discipline.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from gemseo.core.serializable import GoogleDocstringInheritanceMeta +from numpy import ndarray +from scipy.sparse import issparse + +from philote_mdo.general.explicit_discipline import ExplicitDiscipline + +if TYPE_CHECKING: + from gemseo.core.discipline.discipline import Discipline + + +class GEMSEOtoPhiloteDiscipline( + ExplicitDiscipline, metaclass=GoogleDocstringInheritanceMeta +): + """A Philote-MDO explicit discipline wrapping a GEMSEO discipline. + + This adapts a GEMSEO :class:`~gemseo.core.discipline.discipline.Discipline` + to the :class:`~philote_mdo.general.explicit_discipline.ExplicitDiscipline` + interface, so that it can be exposed to remote Philote clients (e.g. an + OpenMDAO component or a :class:`~philote_mdo.gemseo.philote_to_gemseo.PhiloteDiscipline`) + through a :class:`~philote_mdo.general.explicit_server.ExplicitServer`, + for example:: + + server = ExplicitServer( + discipline=GEMSEOtoPhiloteDiscipline(my_discipline) + ) + server.attach_to_server(grpc_server) + + All the input and output variables of the wrapped discipline are + exposed as flat (1D) continuous Philote variables, and the Jacobian of + every output with respect to every input is made available, computed + on demand using GEMSEO's own differentiation capabilities + (see :meth:`~gemseo.core.discipline.discipline.Discipline.linearize`). + """ + + gemseo_discipine: Discipline + """The GEMSEO discipline to be wrapped.""" + + default_data_size: int + """The default size used to declare a variable that has no default value.""" + + def __init__(self, gemseo_discipine: Discipline, default_data_size: int = 1): + """Initialize the GEMSEO discipline. + + Args: + gemseo_discipine: The GEMSEO discipline to be wrapped. + default_data_size: The default data size to be declared for an + input or output variable when the wrapped GEMSEO discipline + does not define a default value (as a + :class:`~numpy.ndarray`) for it in its grammars. + """ + super().__init__() + self.gemseo_discipine = gemseo_discipine + self.default_data_size = default_data_size + + def setup(self): + """Declare the Philote inputs and outputs from the GEMSEO grammars. + + Every name of the wrapped GEMSEO discipline's input and output + grammars is declared as a flat (1D) continuous Philote variable. + Its size is taken from the corresponding default value in + :attr:`~gemseo.core.discipline.discipline.Discipline.default_input_data` + or + :attr:`~gemseo.core.discipline.discipline.Discipline.default_output_data` + when it is a :class:`~numpy.ndarray`, and from + :attr:`.default_data_size` otherwise. + """ + disc = self.gemseo_discipine + for input_name in disc.input_grammar.names: + data = disc.default_input_data.get(input_name) + size = self.default_data_size + if isinstance(data, ndarray): + size = data.size + self.add_input(input_name, shape=(size,)) + for output_name in disc.output_grammar.names: + data = disc.default_output_data.get(output_name) + size = self.default_data_size + if isinstance(data, ndarray): + size = data.size + self.add_output(output_name, shape=(size,)) + + def setup_partials(self): + """Declare the Jacobian of every output with respect to every input. + + No sparsity pattern is assumed: all the input-output pairs of the + wrapped GEMSEO discipline are declared as partial derivatives to + be computed by :meth:`.compute_partials`. + """ + for output_name in self.gemseo_discipine.output_grammar.names: + for input_name in self.gemseo_discipine.input_grammar.names: + self.declare_partials(output_name, input_name) + + def compute(self, inputs, outputs): + """Execute the wrapped GEMSEO discipline. + + Args: + inputs: The input values, indexed by input name. + outputs: The dictionary to be filled with the output values + computed by the wrapped GEMSEO discipline, indexed by + output name. + """ + out = self.gemseo_discipine.execute(inputs) + outputs.update({k: out[k] for k in self.gemseo_discipine.output_grammar.names}) + + def compute_partials(self, inputs, partials): + """Linearize the wrapped GEMSEO discipline. + + Args: + inputs: The input values, indexed by input name. + partials: The dictionary to be filled with the Jacobian matrices + computed by the wrapped GEMSEO discipline, indexed by + ``(output_name, input_name)`` pairs. + """ + jac = self.gemseo_discipine.linearize(inputs, compute_all_jacobians=True) + output_names = self.gemseo_discipine.output_grammar.names + input_names = self.gemseo_discipine.input_grammar.names + + for out_k, jac_in in jac.items(): + if out_k not in output_names: + # Some GEMSEO disciplines (e.g. the Sellar ones) populate + # their Jacobian with extra entries beyond their own + # declared outputs; only forward what setup_partials() + # actually declared to the Philote-MDO client. + continue + for in_k, jac_loc in jac_in.items(): + if in_k not in input_names: + continue + # The Philote-MDO wire protocol only transfers dense arrays, + # while a GEMSEO discipline may return a sparse Jacobian + # matrix (e.g. scipy.sparse.dia_matrix). + if issparse(jac_loc): + jac_loc = jac_loc.toarray() + partials[out_k, in_k] = jac_loc diff --git a/philote_mdo/gemseo/philote_to_gemseo.py b/philote_mdo/gemseo/philote_to_gemseo.py new file mode 100644 index 0000000..2768ec0 --- /dev/null +++ b/philote_mdo/gemseo/philote_to_gemseo.py @@ -0,0 +1,165 @@ +# Philote-Python +# +# Copyright 2026 IRT Saint Exupery +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# originally authored by Francois Gallard, IRT Saint Exupery. +"""A GEMSEO discipline wrapping a remote Philote explicit discipline server.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import ClassVar + +from gemseo.core.discipline.discipline import Discipline +from gemseo.core.grammars.factory import GrammarType +from numpy import prod + +import philote_mdo.general as pm +import philote_mdo.generated.data_pb2 as data + +if TYPE_CHECKING: + from collections.abc import Iterable + + +class PhiloteDiscipline(Discipline): + """A GEMSEO discipline connected to a remote Philote explicit discipline. + + This discipline acts as a Philote-MDO client: at construction, it + connects to a Philote explicit discipline server through a gRPC + channel, triggers the remote ``Setup`` and requests the input/output + and partials metadata. This metadata is used to build the GEMSEO + input and output grammars, so that the resulting discipline can be + used like any other GEMSEO discipline, e.g. in an + :class:`~gemseo.mda.base_mda.BaseMDA`. + + Executing the discipline (:meth:`.execute`) and linearizing it + (:meth:`.linearize`) transparently call the remote server to compute + the outputs and the Jacobian, respectively. + """ + + default_grammar_type: ClassVar[GrammarType] = GrammarType.SIMPLE + """The default type of grammar.""" + + def __init__(self, channel, name="", **options): + """Initialize the discipline and connect its Philote client. + + Args: + channel: The gRPC channel to the Philote discipline server, + e.g. created with ``grpc.insecure_channel("localhost:50051")``. + name: The name of the discipline. + If empty, use the name of the remote discipline class. + **options: The discipline options to send to the server, + if any. + + Raises: + ValueError: When ``channel`` is empty or ``None``. + """ + if not channel: + msg = "No channel provided, the Philote client will not be able to connect." + raise ValueError(msg) + # The shape of each input and output variable, indexed by name; + # filled in by _initialize_grammars() and used by _compute_jacobian() + # to reshape the flattened partial derivatives sent by the server. + self._shapes = {} + # generic Philote client + self._client = pm.ExplicitClient(channel=channel) + + # call the init function of the explicit component + super().__init__(name=name) + + self._client.send_stream_options() + if options: + self._client.send_options(options) + + # run setup + self._client.run_setup() + self._client.get_variable_definitions() + self._client.get_partials_definitions() + self._initialize_grammars() + + def _run(self, input_data: dict) -> dict: + """Compute the function evaluation. + + This sends the input values to the remote Philote discipline server + through the ``ComputeFunction`` RPC and returns the resulting output + values. + + Args: + input_data: The input data, without namespace prefixes. + + Returns: + The output data computed by the remote discipline. + """ + return self._client.run_compute(input_data) + + def _compute_jacobian( + self, + input_names: Iterable[str] = (), + output_names: Iterable[str] = (), + ) -> None: + """Compute one Jacobian matrix per input-output pair. + + This sends the current input values to the remote Philote discipline + server through the ``ComputeGradient`` RPC, reshapes the resulting + flattened partial derivatives according to the input and output + shapes obtained by :meth:`._initialize_grammars`, and stores them + in :attr:`.jac` as a dictionary + ``{output_name: {input_name: jacobian_matrix}}``. + + Note: + The remote discipline always returns the Jacobian of every + declared output with respect to every declared input, so + ``input_names`` and ``output_names`` are currently not used to + restrict the RPC request: the full Jacobian is always computed + and stored. + + Args: + input_names: The names of the inputs against which to differentiate the + outputs. If empty, use all the inputs. + output_names: The names of the outputs to be differentiated. + If empty, use all the outputs. + """ + jac_flat = self._client.run_compute_partials(self.get_input_data()) + for jac_key, jac_val in jac_flat.items(): + out_name, in_name = jac_key + out_size = int(prod(self._shapes[out_name])) + in_size = int(prod(self._shapes[in_name])) + jac_loc = jac_val.reshape(out_size, in_size) + if out_name not in self.jac: + self.jac[out_name] = {in_name: jac_loc} + else: + self.jac[out_name][in_name] = jac_loc + + def _initialize_grammars(self): + """Set up the GEMSEO discipline input and output grammars. + + This builds the input and output grammars, and populates + :attr:`._shapes`, from the variable metadata already retrieved + from the remote discipline server by + :meth:`~philote_mdo.general.discipline_client.DisciplineClient.get_variable_definitions`. + It does not perform any RPC call itself. + """ + input_names = [] + output_names = [] + for var in self._client._var_meta: + if var.type == data.kInput: + input_names.append(var.name) + self._shapes[var.name] = tuple(var.shape) + + if var.type == data.kOutput: + output_names.append(var.name) + self._shapes[var.name] = tuple(var.shape) + self.input_grammar.update_from_names(input_names) + self.output_grammar.update_from_names(output_names) diff --git a/pyproject.toml b/pyproject.toml index 0b45c86..4b2b7e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ Documentation = "https://mdo-standards.github.io/Philote-Python/" [project.optional-dependencies] openmdao = ["openmdao>=3.0"] +gemseo = ["gemseo>=6.0"] dev = [ "pytest>=7.0", "pytest-cov>=4.0", diff --git a/tests/test_gemseo_to_philote.py b/tests/test_gemseo_to_philote.py new file mode 100644 index 0000000..8fe5326 --- /dev/null +++ b/tests/test_gemseo_to_philote.py @@ -0,0 +1,103 @@ +# Philote-Python +# +# Copyright 2026 IRT Saint Exupery +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# originally authored by Francois Gallard, IRT Saint Exupery. +from concurrent import futures +import unittest +import grpc +from numpy.testing import assert_allclose +from scipy.sparse import issparse +from gemseo.problems.mdo.sellar.sellar_1 import Sellar1 +import philote_mdo.general as pmdo +from philote_mdo.gemseo import GEMSEOtoPhiloteDiscipline +from philote_mdo.gemseo import PhiloteDiscipline + +PORT = "[::]:50051" +CHANNEL = "localhost:50051" + + +class GEMSEOToPhiloteTests(unittest.TestCase): + """ + Integration tests for GEMSEOtoPhiloteDiscipline, which wraps a GEMSEO + discipline as a Philote explicit discipline server. + + A GEMSEO Sellar1 discipline is served, and a PhiloteDiscipline client is + used to check that the remote outputs and Jacobian match those of a + local (non-remote) Sellar1 instance. + """ + + def test_sellar1_compute(self): + """ + The remote GEMSEO discipline produces the same output as a local one. + """ + sellar1 = Sellar1() + + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + discipline = pmdo.ExplicitServer( + discipline=GEMSEOtoPhiloteDiscipline(sellar1) + ) + discipline.attach_to_server(server) + server.add_insecure_port(PORT) + server.start() + + remote_sellar1 = PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + out = remote_sellar1.execute(sellar1.default_input_data) + out_local = sellar1.execute() + + server.stop(0) + + assert_allclose(out["y_1"], out_local["y_1"]) + + def test_sellar1_linearize(self): + """ + The Jacobian of the remote GEMSEO discipline matches the local one. + """ + sellar1 = Sellar1() + + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + discipline = pmdo.ExplicitServer( + discipline=GEMSEOtoPhiloteDiscipline(sellar1) + ) + discipline.attach_to_server(server) + server.add_insecure_port(PORT) + server.start() + + remote_sellar1 = PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + remote_sellar1.execute(sellar1.default_input_data) + remote_sellar1.linearize( + sellar1.default_input_data, compute_all_jacobians=True + ) + jac_local = sellar1.linearize( + sellar1.default_input_data, compute_all_jacobians=True + ) + + server.stop(0) + + for input_name in ("x_1", "x_shared", "y_2", "gamma"): + jac_loc = jac_local["y_1"][input_name] + if issparse(jac_loc): + jac_loc = jac_loc.toarray() + assert_allclose( + remote_sellar1.jac["y_1"][input_name].ravel(), + jac_loc.ravel(), + atol=1e-8, + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_openmdao_to_gemseo.py b/tests/test_openmdao_to_gemseo.py new file mode 100644 index 0000000..f6d3628 --- /dev/null +++ b/tests/test_openmdao_to_gemseo.py @@ -0,0 +1,96 @@ +# Philote-Python +# +# Copyright 2026 IRT Saint Exupery +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# originally authored by Francois Gallard, IRT Saint Exupery. +from concurrent import futures +import unittest +import grpc +from numpy import array +import philote_mdo.general as pmdo +import philote_mdo.openmdao as pmdo_om +from philote_mdo.examples.sellar import SellarMDA +from philote_mdo.gemseo import PhiloteDiscipline + +PORT = "[::]:50051" +CHANNEL = "localhost:50051" + + +def _serve_sellar_mda(server): + """ + Wraps the OpenMDAO SellarMDA group as a Philote explicit discipline and + attaches it to the given (not yet started) gRPC server. + """ + subprob = pmdo_om.OpenMdaoSubProblem() + subprob.add_group(SellarMDA()) + subprob.add_mapped_input("x", "x", shape=(1,), units="") + subprob.add_mapped_input("z", "z", shape=(2,), units="") + subprob.add_mapped_output("obj", "obj", shape=(1,), units="") + subprob.add_mapped_output("con1", "con1", shape=(1,), units="") + subprob.declare_subproblem_partial("obj", "x") + subprob.declare_subproblem_partial("obj", "z") + subprob.declare_subproblem_partial("con1", "x") + subprob.declare_subproblem_partial("con1", "z") + + discipline = pmdo.ExplicitServer(discipline=subprob) + discipline.attach_to_server(server) + + +class OpenMDAOToGEMSEOTests(unittest.TestCase): + """ + Integration tests for the Sellar MDA (an OpenMDAO group wrapped through + OpenMdaoSubProblem) served to a GEMSEO PhiloteDiscipline client. + """ + + def test_sellar_mda_compute(self): + """ + Integration test for the Sellar MDA in GEMSEO. + """ + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + _serve_sellar_mda(server) + server.add_insecure_port(PORT) + server.start() + + sellar_mda = PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + out = sellar_mda.execute({"x": array([1.0]), "z": array([2.0, 3.0])}) + + server.stop(0) + + self.assertIn("obj", out) + self.assertIn("con1", out) + + def test_sellar_mda_linearize(self): + """ + Integration test for the Sellar MDA Jacobian in GEMSEO. + """ + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + _serve_sellar_mda(server) + server.add_insecure_port(PORT) + server.start() + + sellar_mda = PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + inputs = {"x": array([1.0]), "z": array([2.0, 3.0])} + sellar_mda.linearize(inputs, compute_all_jacobians=True) + ok = sellar_mda.check_jacobian(inputs, step=1e-7, threshold=1e-4) + + server.stop(0) + + self.assertTrue(ok) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_philote_to_gemseo.py b/tests/test_philote_to_gemseo.py new file mode 100644 index 0000000..45fc9f5 --- /dev/null +++ b/tests/test_philote_to_gemseo.py @@ -0,0 +1,77 @@ +# Philote-Python +# +# Copyright 2026 IRT Saint Exupery +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# originally authored by Francois Gallard, IRT Saint Exupery. +from concurrent import futures +import unittest +import grpc +from numpy import array +import philote_mdo.general as pmdo +from philote_mdo.examples import Paraboloid +from philote_mdo.gemseo import PhiloteDiscipline + +PORT = "[::]:50051" +CHANNEL = "localhost:50051" + + +class PhiloteToGEMSEOTests(unittest.TestCase): + """ + Integration tests for PhiloteDiscipline, which wraps a remote Philote + explicit discipline server as a GEMSEO discipline. + """ + + def test_paraboloid_compute(self): + """ + Integration test for the Paraboloid compute function. + """ + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + discipline = pmdo.ExplicitServer(discipline=Paraboloid()) + discipline.attach_to_server(server) + server.add_insecure_port(PORT) + server.start() + + paraboloid_disc = PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + out = paraboloid_disc.execute({"x": array([1.0]), "y": array([2.0])}) + + server.stop(0) + + self.assertEqual(out["f_xy"][0], 39.0) + + def test_paraboloid_linearize(self): + """ + Integration test for the Paraboloid linearization, checked against + the analytic Jacobian at (x, y) = (1, 2). + """ + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + discipline = pmdo.ExplicitServer(discipline=Paraboloid()) + discipline.attach_to_server(server) + server.add_insecure_port(PORT) + server.start() + + paraboloid_disc = PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + inputs = {"x": array([1.0]), "y": array([2.0])} + paraboloid_disc.linearize(inputs, compute_all_jacobians=True) + + server.stop(0) + + self.assertEqual(paraboloid_disc.jac["f_xy"]["x"][0][0], -2.0) + self.assertEqual(paraboloid_disc.jac["f_xy"]["y"][0][0], 13.0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_sellar_gemseo_to_openmdao.py b/tests/test_sellar_gemseo_to_openmdao.py new file mode 100644 index 0000000..ee8e65b --- /dev/null +++ b/tests/test_sellar_gemseo_to_openmdao.py @@ -0,0 +1,167 @@ +# Philote-Python +# +# Copyright 2026 IRT Saint Exupery +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# originally authored by Francois Gallard, IRT Saint Exupery. +from concurrent import futures +import unittest +import grpc +import openmdao.api as om +from gemseo.problems.mdo.sellar.sellar_1 import Sellar1 +from gemseo.problems.mdo.sellar.sellar_2 import Sellar2 +from gemseo.problems.mdo.sellar.sellar_system import SellarSystem +import philote_mdo.general as pmdo +import philote_mdo.openmdao as pmdo_om +from philote_mdo.gemseo import GEMSEOtoPhiloteDiscipline + +PORT_D1 = "[::]:50061" +CHANNEL_D1 = "localhost:50061" +PORT_D2 = "[::]:50062" +CHANNEL_D2 = "localhost:50062" +PORT_SYSTEM = "[::]:50063" +CHANNEL_SYSTEM = "localhost:50063" + + +def _serve(discipline, port): + """ + Serves a GEMSEO discipline as a Philote explicit discipline. + """ + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + philote_discipline = GEMSEOtoPhiloteDiscipline(discipline) + pmdo.ExplicitServer(discipline=philote_discipline).attach_to_server(server) + server.add_insecure_port(port) + server.start() + return server + + +class SellarGEMSEOToOpenMDAOTests(unittest.TestCase): + """ + Integration tests for the GEMSEO Sellar MDO problem (Sellar1, Sellar2, + SellarSystem), served as three Philote explicit discipline servers and + driven from an OpenMDAO model through RemoteExplicitComponent. + + The y_1/y_2 algebraic coupling between Sellar1 and Sellar2 is resolved + by a Gauss-Seidel solver, and both the total derivatives and the + gradient-based (SLSQP) optimum of the coupled model are checked. + """ + + def setUp(self): + self._servers = [ + _serve(Sellar1(), PORT_D1), + _serve(Sellar2(), PORT_D2), + _serve(SellarSystem(), PORT_SYSTEM), + ] + + prob = om.Problem() + model = prob.model + + # Sellar1 and Sellar2 are coupled through y_1 and y_2: group them so + # that this algebraic loop can be resolved by a dedicated solver. + cycle = model.add_subsystem("cycle", om.Group(), promotes=["*"]) + cycle.add_subsystem( + "d1", + pmdo_om.RemoteExplicitComponent( + channel=grpc.insecure_channel(CHANNEL_D1) + ), + promotes=["*"], + ) + cycle.add_subsystem( + "d2", + pmdo_om.RemoteExplicitComponent( + channel=grpc.insecure_channel(CHANNEL_D2) + ), + promotes=["*"], + ) + cycle.nonlinear_solver = om.NonlinearBlockGS(iprint=0) + # Compute the exact total derivatives across the coupling loop. + cycle.linear_solver = om.DirectSolver() + + model.add_subsystem( + "system", + pmdo_om.RemoteExplicitComponent( + channel=grpc.insecure_channel(CHANNEL_SYSTEM) + ), + promotes=["*"], + ) + + model.add_design_var("x_1", lower=0.0, upper=10.0) + model.add_design_var("x_2", lower=0.0, upper=10.0) + model.add_design_var("x_shared", lower=[-10.0, 0.0], upper=[10.0, 10.0]) + model.add_objective("obj") + model.add_constraint("c_1", upper=0.0) + model.add_constraint("c_2", upper=0.0) + + prob.setup() + + # The Philote-MDO protocol only transfers variable names, shapes and + # units, not GEMSEO's default values, so they must be set explicitly. + prob.set_val("x_1", 1.0) + prob.set_val("x_2", 1.0) + prob.set_val("x_shared", [4.0, 3.0]) + prob.set_val("alpha", 3.16) + prob.set_val("beta", 24.0) + prob.set_val("gamma", 0.2) + + self._prob = prob + + def tearDown(self): + for server in self._servers: + server.stop(0) + + def test_sellar_derivatives(self): + """ + The total derivatives through the remote GEMSEO disciplines are + correct, checked against finite differences, across the y_1/y_2 + coupling loop and the three remote disciplines. + """ + self._prob.run_model() + + check_data = self._prob.check_totals( + of=["obj", "c_1", "c_2"], + wrt=["x_1", "x_2", "x_shared"], + out_stream=None, + ) + + for (of, wrt), error in check_data.items(): + rel_error = error["rel error"] + value = rel_error.forward + if value is None: + value = rel_error.reverse + self.assertLess(value, 1e-4, "d{}/d{}: relative error {} too large.".format(of, wrt, value)) + + def test_sellar_optimum(self): + """ + SLSQP finds the well-known optimum of the Sellar problem: + obj = 3.18339, x_1 = 0, x_shared = [1.9776, 0], with c_1 active. + + Reference values from Sellar, R., Batill, S., and Renaud, J. + (1996), "Response surface based, concurrent subspace optimization + for multidisciplinary system design". + """ + self._prob.driver = om.ScipyOptimizeDriver(optimizer="SLSQP", tol=1e-8) + + self._prob.run_driver() + + self.assertAlmostEqual(self._prob.get_val("obj")[0], 3.18339, places=4) + self.assertAlmostEqual(self._prob.get_val("x_1")[0], 0.0, places=3) + self.assertAlmostEqual(self._prob.get_val("x_shared")[0], 1.9776, places=3) + self.assertAlmostEqual(self._prob.get_val("x_shared")[1], 0.0, places=3) + # The constraints shall be satisfied, c_1 being active at the optimum. + self.assertAlmostEqual(self._prob.get_val("c_1")[0], 0.0, places=4) + self.assertLess(self._prob.get_val("c_2")[0], 0.0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/utils/compile_proto.py b/utils/compile_proto.py index c7e625e..dc3a6ce 100644 --- a/utils/compile_proto.py +++ b/utils/compile_proto.py @@ -38,35 +38,49 @@ def main(): print("Compiling proto files.") proto_include = os.path.join(resources.files("grpc_tools"), "_proto") + generated_dir = "./philote_mdo/generated/" # proto files proto_files = ["data.proto", "disciplines.proto"] + # A FileDescriptorSet, generated alongside the Python code below, lets + # protoletariat rewrite the generated imports without shelling out to a + # standalone "protoc" executable, which most environments do not have on + # PATH (grpc_tools bundles its own protoc, used in-process below). + descriptor_set_path = os.path.join(generated_dir, "_descriptor.bin") + # compile the proto files for use in python - grpc_tools.protoc.main( + return_code = grpc_tools.protoc.main( [ "grpc_tools.protoc", "-I{}".format(proto_include), "-I{}".format("./proto"), - "--python_out=./philote_mdo/generated/", - "--pyi_out=./philote_mdo/generated/", - "--grpc_python_out=./philote_mdo/generated/", + "--python_out={}".format(generated_dir), + "--pyi_out={}".format(generated_dir), + "--grpc_python_out={}".format(generated_dir), + "--descriptor_set_out={}".format(descriptor_set_path), + "--include_imports", ] + proto_files ) + if return_code != 0: + msg = "Compiling the proto files failed (see the protoc output above)." + raise RuntimeError(msg) - - # call protoletariat to convert absolute imports to relative ones - protol.main( - [ - "--in-place", - "--dont-create-package", - "--python-out=./philote_mdo/generated/", - "protoc", - "--proto-path=./proto", - ] - + proto_files - ) + try: + # call protoletariat to convert absolute imports to relative ones, + # using the FileDescriptorSet generated above. + protol.main( + [ + "--python-out={}".format(generated_dir), + "--in-place", + "--dont-create-package", + "raw", + descriptor_set_path, + ] + ) + finally: + os.remove(descriptor_set_path) if __name__ == "__main__": From 1f2abfffd4bc0b9e328af812df1095b655fbc552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Gallard?= Date: Wed, 9 Sep 2026 21:56:38 +0200 Subject: [PATCH 02/21] Add GEMSEO tutorials to the Docusaurus site Add a "Working with GEMSEO" section with two tutorials, ported from the retired Jupyter Book docs: using PhiloteDiscipline to optimize a remote Paraboloid discipline, and coupling OpenAeroStruct with GEMSEO through an OpenMDAO sub-problem. Enable @docusaurus/theme-mermaid so the flowchart diagrams on these pages render. Note: docs/package-lock.json was not regenerated (no Node/npm available in this environment) -- run `npm install` in docs/ before the next `npm ci` build. --- docs/docs/gemseo/gemseo-openaerostruct.md | 208 +++++++++++++++++ docs/docs/gemseo/gemseo.md | 263 ++++++++++++++++++++++ docs/docusaurus.config.ts | 5 + docs/package.json | 1 + docs/sidebars.ts | 8 + 5 files changed, 485 insertions(+) create mode 100644 docs/docs/gemseo/gemseo-openaerostruct.md create mode 100644 docs/docs/gemseo/gemseo.md diff --git a/docs/docs/gemseo/gemseo-openaerostruct.md b/docs/docs/gemseo/gemseo-openaerostruct.md new file mode 100644 index 0000000..c9a8549 --- /dev/null +++ b/docs/docs/gemseo/gemseo-openaerostruct.md @@ -0,0 +1,208 @@ +--- +sidebar_position: 2 +title: "Coupling OpenAeroStruct and GEMSEO" +--- + +# Coupling OpenAeroStruct and GEMSEO through Philote-MDO + +This tutorial builds on [Using GEMSEO with Philote-MDO](./gemseo.md) and +walks through +[`examples/openaerostruct_to_gemseo.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/openaerostruct_to_gemseo.py), +a more realistic case: instead of a toy analytic function, the remote +discipline is an [OpenAeroStruct](https://github.com/mdolab/OpenAeroStruct) +aerostructural analysis -- a coupled VLM aerodynamic model and a +finite-element structural model, solved together with an OpenMDAO +nonlinear solver -- and it is optimized with a gradient-based algorithm. + +This example additionally requires OpenMDAO, OpenAeroStruct and the +[`philote-examples`](https://pypi.org/project/philote-examples/) package +(`pip install openaerostruct philote-examples`), which provides the +`OasAerostructDiscipline` used below. + +## Why this matters: hiding an OpenMDAO MDA behind a Philote-MDO discipline + +OpenAeroStruct is written for [OpenMDAO](https://openmdao.org): the +`philote-examples` package provides `OasAerostructDiscipline`, which +builds an OpenMDAO `Group` combining `AerostructGeometry` and +`AerostructPoint` for a wing, resolves the aero-structural coupling +internally with `NonlinearBlockGS`, and wraps the whole `om.Problem` as a +**single Philote-MDO explicit discipline**. + +This is the key interoperability point: from the Philote-MDO (and +therefore GEMSEO) side, this coupled, iterative OpenMDAO analysis is +completely invisible. `PhiloteDiscipline` only ever sees a stateless +function `inputs -> outputs` plus a Jacobian -- exactly like the +`Paraboloid` discipline of the [previous tutorial](./gemseo.md). GEMSEO +does not need OpenMDAO, OpenAeroStruct, or any of their dependencies +installed: it only talks gRPC to a server that happens to run them. + +```mermaid +flowchart LR + subgraph GEMSEO_process["GEMSEO process"] + PD["PhiloteDiscipline"] + SC["MDOScenario
(SLSQP)"] + SC --> PD + end + subgraph Server_process["Server process"] + OAS["OasAerostructDiscipline
(Philote explicit discipline)"] + subgraph OM["OpenMDAO Problem"] + VLM["VLM aerodynamics"] + FEM["FEM structure"] + VLM <-->|"NonlinearBlockGS"| FEM + end + OAS --> OM + end + PD <-- "gRPC: alpha -> CL, CD, ...
+ Jacobian" --> OAS +``` + +## A gradient-based scenario is limited to what the server differentiates + +`OasAerostructDiscipline` only declares two partial derivatives on the +server side (in its `_build_discipline` method): + +```python +self.declare_subproblem_partial("CD", "alpha") +self.declare_subproblem_partial("CL", "alpha") +``` + +Only declared partials are computed and sent to the client: a +gradient-based GEMSEO scenario can therefore only use `alpha` as a design +variable, and `CL`/`CD` as objective/constraint candidates -- using any +other input as a design variable, or any other output as an objective or +constraint, would make GEMSEO request a Jacobian entry the server never +computes. + +This shapes the scenario: **trim the wing** by finding the angle of +attack `alpha` that **minimizes drag** `CD` while holding the **lift +coefficient** `CL` at a target value, a classic aerostructural design +problem. + +## Walkthrough + +### Fixing the non-design inputs + +The Philote-MDO protocol only transfers variable names, shapes and units +over gRPC -- not GEMSEO's default values. Since `alpha` is the only +design variable, every other input of the discipline (flight conditions +and mission parameters) must be given a value explicitly, or GEMSEO would +have no default to execute the discipline with. This is done once, by +updating the connected discipline's `default_input_data`: + +```python +FIXED_INPUTS = { + "v": array([248.136]), + "Mach_number": array([0.84]), + "re": array([1e6]), + "rho": array([0.38]), + "CT": array([grav_constant * 17.0e-6]), + "R": array([11.165e6]), + "W0": array([0.4 * 3e5]), + "speed_of_sound": array([295.4]), + "load_factor": array([1.0]), + "empty_cg": np.zeros(3), +} + + +def create_discipline(server: grpc.Server) -> PhiloteDiscipline: + discipline = pmdo.ExplicitServer(discipline=OasAerostructDiscipline()) + discipline.attach_to_server(server) + oas_discipline = PhiloteDiscipline(channel=grpc.insecure_channel(f"{HOST}:{PORT}")) + oas_discipline.default_input_data.update(FIXED_INPUTS) + return oas_discipline +``` + +:::note +This is not specific to GEMSEO: any Philote-MDO client that does not set +a value for a non-design input runs into the same issue. The +[Sellar example](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/sellar_gemseo_to_openmdao.py), +which drives remote *GEMSEO* disciplines from *OpenMDAO*, sets its fixed +parameters explicitly with `prob.set_val(...)` for the exact same reason. +::: + +### Serving the discipline + +As in the previous tutorial, serving the discipline only requires +starting a gRPC server and attaching an `ExplicitServer` wrapping it: + +```python +def create_server() -> grpc.Server: + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + server.add_insecure_port(f"[::]:{PORT}") + server.start() + return server +``` + +### Building and running the scenario + +The design space has a single variable, `alpha` (in degrees); the +scenario minimizes `CD` under the equality constraint `CL = 0.5`, using +the gradient-based `SLSQP` algorithm -- made possible because the server +provides the `CL`/`CD` derivatives with respect to `alpha` analytically: + +```python +if __name__ == "__main__": + server = create_server() + + try: + oas_disc = create_discipline(server) + + design_space = create_design_space() + # alpha is in degrees, as declared by OasAerostructDiscipline. + design_space.add_variable( + "alpha", lower_bound=-10.0, upper_bound=15.0, value=5.0 + ) + + scenario = create_scenario( + [oas_disc], + objective_name="CD", + design_space=design_space, + formulation_settings_model=DisciplinaryOpt_Settings(), + ) + # Trim the wing: CL = 0.5 + scenario.add_constraint("CL", constraint_type="eq", value=0.5) + scenario.execute(algo_settings_model=SLSQP_Settings(max_iter=20)) + + out = oas_disc.local_data + print( + "Trimmed solution:", + {name: out[name] for name in ("alpha", "CL", "CD", "failure")}, + ) + execute_post(scenario, OptHistoryView_Settings(save=True, show=False)) + + finally: + # Stop the server explicitly: + # its worker threads would otherwise keep the process alive. + server.stop(0) +``` + +Running the script converges in a handful of iterations and prints: + +```text +Trimmed solution: { + 'alpha': array([4.07490213]), + 'CL': array([0.5]), + 'CD': array([0.03553854]), + 'failure': array([-0.91249284]), +} +``` + +`CL` reaches the target `0.5` exactly (the equality constraint fully +determines `alpha` here, since there is a single design variable), and +`failure` stays negative, meaning the structure remains within its +allowable stress margin at the trimmed condition. + +## The full script + +See the actual, always up-to-date source at +[`examples/openaerostruct_to_gemseo.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/openaerostruct_to_gemseo.py) +in the repository. + +## Where to go next + +[`examples/sellar_gemseo_to_openmdao.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/sellar_gemseo_to_openmdao.py) +shows the mirror image of this tutorial: instead of GEMSEO calling an +OpenMDAO/OpenAeroStruct discipline, GEMSEO's own Sellar disciplines are +served over gRPC with `GEMSEOtoPhiloteDiscipline` and optimized from an +OpenMDAO model with SLSQP -- the same coupling pattern used here (an +internal Gauss-Seidel solver resolving an algebraic loop behind the +scenes), but with the two frameworks swapped. diff --git a/docs/docs/gemseo/gemseo.md b/docs/docs/gemseo/gemseo.md new file mode 100644 index 0000000..be03dcc --- /dev/null +++ b/docs/docs/gemseo/gemseo.md @@ -0,0 +1,263 @@ +--- +sidebar_position: 1 +title: "Using GEMSEO with Philote-MDO" +--- + +# Using GEMSEO with Philote-MDO + +## What problem does this solve? + +[Philote-MDO](https://github.com/MDO-Standards/Philote-Python) is a gRPC +protocol for exchanging MDO disciplines between tools. A discipline is +served by one process (possibly on another machine, in another language, +or built with another MDO framework) and called by a client process as if +it were local: the client sends input values, and the server streams back +the output values and, if available, the Jacobian. + +`philote_mdo.gemseo` implements this protocol on the GEMSEO side, in +**both** directions: + +- `PhiloteDiscipline` is a GEMSEO `Discipline` that connects to *any* + Philote-MDO server (be it a `philote-mdo` discipline, an OpenAeroStruct + model, or an OpenMDAO component served through + `philote_mdo.openmdao.RemoteExplicitComponent`) and exposes it as a + normal discipline, usable in an `MDOScenario` or an `MDA` like any + other. +- `GEMSEOtoPhiloteDiscipline` does the opposite: it wraps a GEMSEO + `Discipline` so that it can be served over gRPC and called from any + Philote-MDO client, in particular an OpenMDAO model. + +```mermaid +flowchart LR + subgraph GEMSEO_process["GEMSEO process"] + PD["PhiloteDiscipline"] + end + subgraph Server_process["Remote server process"] + S["Philote-MDO server
(any framework)"] + end + PD <-- "gRPC: values + Jacobian" --> S + + subgraph GEMSEO_process2["GEMSEO process"] + D["A GEMSEO Discipline"] + GTP["GEMSEOtoPhiloteDiscipline"] + D --> GTP + end + subgraph Client_process["Remote client process"] + C["Philote-MDO client
(e.g. OpenMDAO)"] + end + GTP <-- "gRPC: values + Jacobian" --> C +``` + +Both directions carry the discipline's Jacobian, not just its output +values, so a remote discipline can be used in a gradient-based +optimization exactly like a local one. + +This page walks through the first, and simplest, direction -- +`PhiloteDiscipline` consuming a remote discipline -- using the complete +example [`examples/paraboloid_gemseo.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/paraboloid_gemseo.py). +The [next tutorial](./gemseo-openaerostruct.md) covers a realistic, +coupled OpenMDAO analysis and the opposite direction is demonstrated in +[`examples/sellar_gemseo_to_openmdao.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/sellar_gemseo_to_openmdao.py). + +## Installation + +```bash +pip install philote-mdo gemseo +``` + +`philote_mdo.gemseo` (which provides `PhiloteDiscipline` and +`GEMSEOtoPhiloteDiscipline`) is part of `philote-mdo` itself; GEMSEO is +not a hard dependency of the package, so it must be installed separately +(or via the `philote-mdo[gemseo]` extra). + +## Walkthrough: optimizing a remote Paraboloid discipline + +The example serves `philote_mdo.examples.Paraboloid`, a toy discipline +computing `f_xy(x, y)`, over gRPC, and uses it as the objective of a +GEMSEO optimization scenario that also has a constraint computed locally. +The full script is reproduced at the end of this page; the sections below +explain it piece by piece. + +### Serving the discipline + +Any Philote-MDO discipline (here, the built-in `Paraboloid` example) is +served by wrapping it in a `philote_mdo.general.ExplicitServer` and +attaching that server to a started gRPC server: + +```python +def create_server() -> grpc.Server: + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + server.add_insecure_port(f"[::]:{PORT}") + server.start() + return server +``` + +### Connecting a GEMSEO client to it + +`PhiloteDiscipline` is the GEMSEO-side client. Connecting it to the +channel above is enough: at construction, it queries the server for the +discipline's inputs, outputs and available partial derivatives, and +builds its GEMSEO grammars from that metadata -- no manual declaration is +needed. + +```python +def create_discipline(server: grpc.Server) -> PhiloteDiscipline: + discipline = ExplicitServer(discipline=Paraboloid()) + discipline.attach_to_server(server) + return PhiloteDiscipline(channel=grpc.insecure_channel(f"{HOST}:{PORT}")) +``` + +From this point on, `paraboloid_disc` behaves like any other GEMSEO +`Discipline`: `paraboloid_disc.execute(...)` calls the remote +`ComputeFunction` RPC, and `paraboloid_disc.linearize(...)` calls the +remote `ComputeGradient` RPC. + +### Adding a local constraint + +Not every discipline of a scenario has to be remote. Here, the +constraint `g = x + y` is computed locally through GEMSEO's +`AutoPyDiscipline`, which turns a plain Python function into a +discipline by inspecting its signature and its `return` statement: + +```python +def compute_constraint(x: ndarray, y: ndarray) -> ndarray: + # AutoPyDiscipline infers the output name from this assignment, so the + # variable must be named "g" and returned as-is (not e.g. "return x + y"). + g = x + y + return g +``` + +:::warning +`AutoPyDiscipline` infers the output name(s) by parsing the literal +`return` statement of the function, so it must be `return g` with `g` +assigned beforehand, not an inlined expression like `return x + y`. +::: + +### Running the scenario + +The remote discipline and the local constraint discipline are combined in +a standard `MDOScenario`, built with `create_scenario`: + +```python +server = create_server() + +try: + paraboloid_disc = create_discipline(server) + design_space = create_design_space() + design_space.add_variable("x", lower_bound=-50, upper_bound=50, value=3.0) + design_space.add_variable("y", lower_bound=-50, upper_bound=50, value=-4.0) + + scenario = create_scenario( + [paraboloid_disc, AutoPyDiscipline(py_func=compute_constraint)], + objective_name="f_xy", + design_space=design_space, + formulation_settings_model=DisciplinaryOpt_Settings(), + ) + + # 0 <= g <= 10 + scenario.add_constraint("g", constraint_type="ineq", positive=True) + scenario.add_constraint("g", constraint_type="ineq", value=10.0) + scenario.execute(algo_settings_model=COBYQA_Settings(max_iter=100)) + + print("Optimal design found:", design_space.get_current_value(as_dict=True)) +finally: + # Stop the server explicitly: + # its worker threads would otherwise keep the process alive. + server.stop(0) +``` + +Running the script prints the optimal design found by COBYQA: + +```text +Optimal design found: {'x': array([7.]), 'y': array([-7.])} +``` + +which matches `f_xy(7, -7) = -27`, the known minimum of the paraboloid +under `0 <= x + y <= 10` (here reached at the boundary `g = 0`). + +## The full script + +```python title="examples/paraboloid_gemseo.py" +from __future__ import annotations + +from concurrent import futures + +import grpc +from gemseo import create_design_space +from gemseo import create_scenario +from gemseo.disciplines.auto_py import AutoPyDiscipline +from gemseo.settings.formulations import DisciplinaryOpt_Settings +from gemseo.settings.opt import COBYQA_Settings +from numpy import ndarray + +from philote_mdo.examples import Paraboloid +from philote_mdo.gemseo import PhiloteDiscipline +from philote_mdo.general import ExplicitServer + +HOST = "localhost" +PORT = 50051 + + +def create_discipline(server: grpc.Server) -> PhiloteDiscipline: + discipline = ExplicitServer(discipline=Paraboloid()) + discipline.attach_to_server(server) + return PhiloteDiscipline(channel=grpc.insecure_channel(f"{HOST}:{PORT}")) + + +def create_server() -> grpc.Server: + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + server.add_insecure_port(f"[::]:{PORT}") + server.start() + return server + + +def compute_constraint(x: ndarray, y: ndarray) -> ndarray: + # AutoPyDiscipline infers the output name from this assignment, so the + # variable must be named "g" and returned as-is (not e.g. "return x + y"). + g = x + y + return g + + +if __name__ == "__main__": + server = create_server() + + try: + paraboloid_disc = create_discipline(server) + design_space = create_design_space() + design_space.add_variable("x", lower_bound=-50, upper_bound=50, value=3.0) + design_space.add_variable("y", lower_bound=-50, upper_bound=50, value=-4.0) + + scenario = create_scenario( + [paraboloid_disc, AutoPyDiscipline(py_func=compute_constraint)], + objective_name="f_xy", + design_space=design_space, + formulation_settings_model=DisciplinaryOpt_Settings(), + ) + + # 0 <= g <= 10 + scenario.add_constraint("g", constraint_type="ineq", positive=True) + scenario.add_constraint("g", constraint_type="ineq", value=10.0) + scenario.execute(algo_settings_model=COBYQA_Settings(max_iter=100)) + + print("Optimal design found:", design_space.get_current_value(as_dict=True)) + finally: + # Stop the server explicitly: + # its worker threads would otherwise keep the process alive. + server.stop(0) +``` + +See the actual, always up-to-date source at +[`examples/paraboloid_gemseo.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/paraboloid_gemseo.py) +in the repository. + +## Where to go next + +- [Coupling OpenAeroStruct and GEMSEO](./gemseo-openaerostruct.md) applies + the same `PhiloteDiscipline` pattern to a realistic, internally-coupled + OpenMDAO analysis, and shows how the discipline's non-design inputs + must be set explicitly since Philote-MDO does not transfer GEMSEO's + default values. +- [`examples/sellar_gemseo_to_openmdao.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/sellar_gemseo_to_openmdao.py) + demonstrates the opposite direction with `GEMSEOtoPhiloteDiscipline`: + GEMSEO's Sellar disciplines are served over gRPC and optimized from an + OpenMDAO model. diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 89eb25d..587a113 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -27,6 +27,11 @@ const config: Config = { locales: ["en"], }, + markdown: { + mermaid: true, + }, + themes: ["@docusaurus/theme-mermaid"], + stylesheets: [ { href: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css", diff --git a/docs/package.json b/docs/package.json index 77fdde2..03d7252 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,6 +17,7 @@ "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/preset-classic": "3.9.2", + "@docusaurus/theme-mermaid": "3.9.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 421f70f..4206142 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -27,6 +27,14 @@ const sidebars: SidebarsConfig = { "openmdao/openmdao-groups", ], }, + { + type: "category", + label: "Working with GEMSEO", + items: [ + "gemseo/gemseo", + "gemseo/gemseo-openaerostruct", + ], + }, { type: "category", label: "About", From fda56c00edb429189609f96b48df2f455c9b3660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Gallard?= Date: Tue, 15 Sep 2026 13:31:15 +0200 Subject: [PATCH 03/21] Minor changes : remove changes on doc config, and update gemseo dep version in pyproject.toml --- docs/docusaurus.config.ts | 5 ----- docs/package.json | 1 - pyproject.toml | 2 +- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 587a113..89eb25d 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -27,11 +27,6 @@ const config: Config = { locales: ["en"], }, - markdown: { - mermaid: true, - }, - themes: ["@docusaurus/theme-mermaid"], - stylesheets: [ { href: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css", diff --git a/docs/package.json b/docs/package.json index 03d7252..77fdde2 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,7 +17,6 @@ "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/preset-classic": "3.9.2", - "@docusaurus/theme-mermaid": "3.9.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", diff --git a/pyproject.toml b/pyproject.toml index 4b2b7e2..cf4e65e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ Documentation = "https://mdo-standards.github.io/Philote-Python/" [project.optional-dependencies] openmdao = ["openmdao>=3.0"] -gemseo = ["gemseo>=6.0"] +gemseo = ["gemseo>=6.0,<7.0"] dev = [ "pytest>=7.0", "pytest-cov>=4.0", From 2debb0c86c26728bfd12797fe0ea225e1811bd5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Gallard?= Date: Tue, 15 Sep 2026 13:47:24 +0200 Subject: [PATCH 04/21] Add tests to close coverage gaps in philote_mdo.gemseo Covers the ValueError on a missing channel, sending discipline options to the server, the ndarray-sized default_output_data branch in GEMSEOtoPhiloteDiscipline.setup(), and the defensive skip of Jacobian entries for outputs outside a wrapped discipline's own output grammar. Also marks the TYPE_CHECKING-only imports as pragma: no cover, since they can never execute at runtime. Co-Authored-By: Claude Sonnet 5 --- philote_mdo/gemseo/gemseo_to_philote.py | 2 +- philote_mdo/gemseo/philote_to_gemseo.py | 2 +- tests/test_gemseo_to_philote.py | 42 +++++++++++++++++++++++++ tests/test_philote_to_gemseo.py | 32 +++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/philote_mdo/gemseo/gemseo_to_philote.py b/philote_mdo/gemseo/gemseo_to_philote.py index 63c3d32..8e33c39 100644 --- a/philote_mdo/gemseo/gemseo_to_philote.py +++ b/philote_mdo/gemseo/gemseo_to_philote.py @@ -27,7 +27,7 @@ from philote_mdo.general.explicit_discipline import ExplicitDiscipline -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from gemseo.core.discipline.discipline import Discipline diff --git a/philote_mdo/gemseo/philote_to_gemseo.py b/philote_mdo/gemseo/philote_to_gemseo.py index 2768ec0..7b643b8 100644 --- a/philote_mdo/gemseo/philote_to_gemseo.py +++ b/philote_mdo/gemseo/philote_to_gemseo.py @@ -29,7 +29,7 @@ import philote_mdo.general as pm import philote_mdo.generated.data_pb2 as data -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from collections.abc import Iterable diff --git a/tests/test_gemseo_to_philote.py b/tests/test_gemseo_to_philote.py index 8fe5326..cca321c 100644 --- a/tests/test_gemseo_to_philote.py +++ b/tests/test_gemseo_to_philote.py @@ -17,7 +17,9 @@ # originally authored by Francois Gallard, IRT Saint Exupery. from concurrent import futures import unittest +from unittest.mock import patch import grpc +from numpy import array from numpy.testing import assert_allclose from scipy.sparse import issparse from gemseo.problems.mdo.sellar.sellar_1 import Sellar1 @@ -98,6 +100,46 @@ def test_sellar1_linearize(self): atol=1e-8, ) + def test_setup_uses_default_output_data_size(self): + """ + The size of a Philote output declared by setup() is taken from the + wrapped discipline's default_output_data when it holds an ndarray, + instead of falling back to default_data_size. + """ + sellar1 = Sellar1() + sellar1.default_output_data.update({"y_1": array([0.0, 0.0])}) + + wrapper = GEMSEOtoPhiloteDiscipline(sellar1) + wrapper.setup() + + y_1_meta = next(m for m in wrapper._var_meta if m.name == "y_1") + self.assertEqual(y_1_meta.shape, [2]) + + def test_compute_partials_skips_output_not_in_grammar(self): + """ + Jacobian entries returned by the wrapped discipline for an output + that is not part of its own output grammar are ignored, rather than + being forwarded to the Philote-MDO client. + """ + sellar1 = Sellar1() + wrapper = GEMSEOtoPhiloteDiscipline(sellar1) + wrapper.setup() + wrapper.setup_partials() + + inputs = sellar1.default_input_data + real_jac = sellar1.linearize(inputs, compute_all_jacobians=True) + jac_with_extra_output = dict(real_jac) + jac_with_extra_output["not_a_declared_output"] = { + "x_1": array([[1.0]]) + } + + partials = {} + with patch.object(sellar1, "linearize", return_value=jac_with_extra_output): + wrapper.compute_partials(inputs, partials) + + self.assertIn(("y_1", "x_1"), partials) + self.assertNotIn(("not_a_declared_output", "x_1"), partials) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_philote_to_gemseo.py b/tests/test_philote_to_gemseo.py index 45fc9f5..72e9f77 100644 --- a/tests/test_philote_to_gemseo.py +++ b/tests/test_philote_to_gemseo.py @@ -21,6 +21,7 @@ from numpy import array import philote_mdo.general as pmdo from philote_mdo.examples import Paraboloid +from philote_mdo.examples import Rosenbrock from philote_mdo.gemseo import PhiloteDiscipline PORT = "[::]:50051" @@ -72,6 +73,37 @@ def test_paraboloid_linearize(self): self.assertEqual(paraboloid_disc.jac["f_xy"]["x"][0][0], -2.0) self.assertEqual(paraboloid_disc.jac["f_xy"]["y"][0][0], 13.0) + def test_missing_channel_raises_value_error(self): + """ + Constructing a PhiloteDiscipline without a channel raises ValueError. + """ + with self.assertRaises(ValueError): + PhiloteDiscipline(channel=None) + + with self.assertRaises(ValueError): + PhiloteDiscipline(channel="") + + def test_rosenbrock_with_options(self): + """ + Discipline options passed to the constructor are sent to the server + and used to build a Rosenbrock discipline of the requested dimension. + """ + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + discipline = pmdo.ExplicitServer(discipline=Rosenbrock()) + discipline.attach_to_server(server) + server.add_insecure_port(PORT) + server.start() + + rosenbrock_disc = PhiloteDiscipline( + channel=grpc.insecure_channel(CHANNEL), dimension=3 + ) + + out = rosenbrock_disc.execute({"x": array([1.0, 1.0, 1.0])}) + + server.stop(0) + + self.assertEqual(out["f"][0], 0.0) + if __name__ == "__main__": unittest.main(verbosity=2) From a3d31854c6abac450044e5979b73334f0094c5fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Gallard?= Date: Tue, 15 Sep 2026 18:11:03 +0200 Subject: [PATCH 05/21] Add gemseo in main doc pages --- docs/src/pages/index.tsx | 9 +- .../gemseo/gemseo-openaerostruct.md | 208 ++++++++++++++ .../version-0.8.0/gemseo/gemseo.md | 263 ++++++++++++++++++ .../version-0.8.0-sidebars.json | 8 + 4 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 docs/versioned_docs/version-0.8.0/gemseo/gemseo-openaerostruct.md create mode 100644 docs/versioned_docs/version-0.8.0/gemseo/gemseo.md diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 2c21b42..9f46318 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -249,11 +249,17 @@ const features: FeatureItem[] = [ "First-class support for both explicit components and residual-based implicit disciplines, with analytic partial derivatives.", }, { - label: "INTEROP", + label: "INTEROPERABILITY : OpenMDAO", title: "OpenMDAO Bindings", description: "Drop-in RemoteExplicitComponent and RemoteImplicitComponent for OpenMDAO models, plus a wrapper to host OpenMDAO groups as Philote servers.", }, + { + label: "INTEROPERABILITY : GEMSEO", + title: "GEMSEO Bindings", + description: + "Drop-in GEMSEO Disciplines for Philote Disciplines, plus a wrapper to host GEMSEO Disciplines or processes as Philote servers.", + }, { label: "DISTRIBUTED", title: "Network Native", @@ -357,6 +363,7 @@ export default function Home(): ReactNode { + diff --git a/docs/versioned_docs/version-0.8.0/gemseo/gemseo-openaerostruct.md b/docs/versioned_docs/version-0.8.0/gemseo/gemseo-openaerostruct.md new file mode 100644 index 0000000..c9a8549 --- /dev/null +++ b/docs/versioned_docs/version-0.8.0/gemseo/gemseo-openaerostruct.md @@ -0,0 +1,208 @@ +--- +sidebar_position: 2 +title: "Coupling OpenAeroStruct and GEMSEO" +--- + +# Coupling OpenAeroStruct and GEMSEO through Philote-MDO + +This tutorial builds on [Using GEMSEO with Philote-MDO](./gemseo.md) and +walks through +[`examples/openaerostruct_to_gemseo.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/openaerostruct_to_gemseo.py), +a more realistic case: instead of a toy analytic function, the remote +discipline is an [OpenAeroStruct](https://github.com/mdolab/OpenAeroStruct) +aerostructural analysis -- a coupled VLM aerodynamic model and a +finite-element structural model, solved together with an OpenMDAO +nonlinear solver -- and it is optimized with a gradient-based algorithm. + +This example additionally requires OpenMDAO, OpenAeroStruct and the +[`philote-examples`](https://pypi.org/project/philote-examples/) package +(`pip install openaerostruct philote-examples`), which provides the +`OasAerostructDiscipline` used below. + +## Why this matters: hiding an OpenMDAO MDA behind a Philote-MDO discipline + +OpenAeroStruct is written for [OpenMDAO](https://openmdao.org): the +`philote-examples` package provides `OasAerostructDiscipline`, which +builds an OpenMDAO `Group` combining `AerostructGeometry` and +`AerostructPoint` for a wing, resolves the aero-structural coupling +internally with `NonlinearBlockGS`, and wraps the whole `om.Problem` as a +**single Philote-MDO explicit discipline**. + +This is the key interoperability point: from the Philote-MDO (and +therefore GEMSEO) side, this coupled, iterative OpenMDAO analysis is +completely invisible. `PhiloteDiscipline` only ever sees a stateless +function `inputs -> outputs` plus a Jacobian -- exactly like the +`Paraboloid` discipline of the [previous tutorial](./gemseo.md). GEMSEO +does not need OpenMDAO, OpenAeroStruct, or any of their dependencies +installed: it only talks gRPC to a server that happens to run them. + +```mermaid +flowchart LR + subgraph GEMSEO_process["GEMSEO process"] + PD["PhiloteDiscipline"] + SC["MDOScenario
(SLSQP)"] + SC --> PD + end + subgraph Server_process["Server process"] + OAS["OasAerostructDiscipline
(Philote explicit discipline)"] + subgraph OM["OpenMDAO Problem"] + VLM["VLM aerodynamics"] + FEM["FEM structure"] + VLM <-->|"NonlinearBlockGS"| FEM + end + OAS --> OM + end + PD <-- "gRPC: alpha -> CL, CD, ...
+ Jacobian" --> OAS +``` + +## A gradient-based scenario is limited to what the server differentiates + +`OasAerostructDiscipline` only declares two partial derivatives on the +server side (in its `_build_discipline` method): + +```python +self.declare_subproblem_partial("CD", "alpha") +self.declare_subproblem_partial("CL", "alpha") +``` + +Only declared partials are computed and sent to the client: a +gradient-based GEMSEO scenario can therefore only use `alpha` as a design +variable, and `CL`/`CD` as objective/constraint candidates -- using any +other input as a design variable, or any other output as an objective or +constraint, would make GEMSEO request a Jacobian entry the server never +computes. + +This shapes the scenario: **trim the wing** by finding the angle of +attack `alpha` that **minimizes drag** `CD` while holding the **lift +coefficient** `CL` at a target value, a classic aerostructural design +problem. + +## Walkthrough + +### Fixing the non-design inputs + +The Philote-MDO protocol only transfers variable names, shapes and units +over gRPC -- not GEMSEO's default values. Since `alpha` is the only +design variable, every other input of the discipline (flight conditions +and mission parameters) must be given a value explicitly, or GEMSEO would +have no default to execute the discipline with. This is done once, by +updating the connected discipline's `default_input_data`: + +```python +FIXED_INPUTS = { + "v": array([248.136]), + "Mach_number": array([0.84]), + "re": array([1e6]), + "rho": array([0.38]), + "CT": array([grav_constant * 17.0e-6]), + "R": array([11.165e6]), + "W0": array([0.4 * 3e5]), + "speed_of_sound": array([295.4]), + "load_factor": array([1.0]), + "empty_cg": np.zeros(3), +} + + +def create_discipline(server: grpc.Server) -> PhiloteDiscipline: + discipline = pmdo.ExplicitServer(discipline=OasAerostructDiscipline()) + discipline.attach_to_server(server) + oas_discipline = PhiloteDiscipline(channel=grpc.insecure_channel(f"{HOST}:{PORT}")) + oas_discipline.default_input_data.update(FIXED_INPUTS) + return oas_discipline +``` + +:::note +This is not specific to GEMSEO: any Philote-MDO client that does not set +a value for a non-design input runs into the same issue. The +[Sellar example](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/sellar_gemseo_to_openmdao.py), +which drives remote *GEMSEO* disciplines from *OpenMDAO*, sets its fixed +parameters explicitly with `prob.set_val(...)` for the exact same reason. +::: + +### Serving the discipline + +As in the previous tutorial, serving the discipline only requires +starting a gRPC server and attaching an `ExplicitServer` wrapping it: + +```python +def create_server() -> grpc.Server: + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + server.add_insecure_port(f"[::]:{PORT}") + server.start() + return server +``` + +### Building and running the scenario + +The design space has a single variable, `alpha` (in degrees); the +scenario minimizes `CD` under the equality constraint `CL = 0.5`, using +the gradient-based `SLSQP` algorithm -- made possible because the server +provides the `CL`/`CD` derivatives with respect to `alpha` analytically: + +```python +if __name__ == "__main__": + server = create_server() + + try: + oas_disc = create_discipline(server) + + design_space = create_design_space() + # alpha is in degrees, as declared by OasAerostructDiscipline. + design_space.add_variable( + "alpha", lower_bound=-10.0, upper_bound=15.0, value=5.0 + ) + + scenario = create_scenario( + [oas_disc], + objective_name="CD", + design_space=design_space, + formulation_settings_model=DisciplinaryOpt_Settings(), + ) + # Trim the wing: CL = 0.5 + scenario.add_constraint("CL", constraint_type="eq", value=0.5) + scenario.execute(algo_settings_model=SLSQP_Settings(max_iter=20)) + + out = oas_disc.local_data + print( + "Trimmed solution:", + {name: out[name] for name in ("alpha", "CL", "CD", "failure")}, + ) + execute_post(scenario, OptHistoryView_Settings(save=True, show=False)) + + finally: + # Stop the server explicitly: + # its worker threads would otherwise keep the process alive. + server.stop(0) +``` + +Running the script converges in a handful of iterations and prints: + +```text +Trimmed solution: { + 'alpha': array([4.07490213]), + 'CL': array([0.5]), + 'CD': array([0.03553854]), + 'failure': array([-0.91249284]), +} +``` + +`CL` reaches the target `0.5` exactly (the equality constraint fully +determines `alpha` here, since there is a single design variable), and +`failure` stays negative, meaning the structure remains within its +allowable stress margin at the trimmed condition. + +## The full script + +See the actual, always up-to-date source at +[`examples/openaerostruct_to_gemseo.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/openaerostruct_to_gemseo.py) +in the repository. + +## Where to go next + +[`examples/sellar_gemseo_to_openmdao.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/sellar_gemseo_to_openmdao.py) +shows the mirror image of this tutorial: instead of GEMSEO calling an +OpenMDAO/OpenAeroStruct discipline, GEMSEO's own Sellar disciplines are +served over gRPC with `GEMSEOtoPhiloteDiscipline` and optimized from an +OpenMDAO model with SLSQP -- the same coupling pattern used here (an +internal Gauss-Seidel solver resolving an algebraic loop behind the +scenes), but with the two frameworks swapped. diff --git a/docs/versioned_docs/version-0.8.0/gemseo/gemseo.md b/docs/versioned_docs/version-0.8.0/gemseo/gemseo.md new file mode 100644 index 0000000..be03dcc --- /dev/null +++ b/docs/versioned_docs/version-0.8.0/gemseo/gemseo.md @@ -0,0 +1,263 @@ +--- +sidebar_position: 1 +title: "Using GEMSEO with Philote-MDO" +--- + +# Using GEMSEO with Philote-MDO + +## What problem does this solve? + +[Philote-MDO](https://github.com/MDO-Standards/Philote-Python) is a gRPC +protocol for exchanging MDO disciplines between tools. A discipline is +served by one process (possibly on another machine, in another language, +or built with another MDO framework) and called by a client process as if +it were local: the client sends input values, and the server streams back +the output values and, if available, the Jacobian. + +`philote_mdo.gemseo` implements this protocol on the GEMSEO side, in +**both** directions: + +- `PhiloteDiscipline` is a GEMSEO `Discipline` that connects to *any* + Philote-MDO server (be it a `philote-mdo` discipline, an OpenAeroStruct + model, or an OpenMDAO component served through + `philote_mdo.openmdao.RemoteExplicitComponent`) and exposes it as a + normal discipline, usable in an `MDOScenario` or an `MDA` like any + other. +- `GEMSEOtoPhiloteDiscipline` does the opposite: it wraps a GEMSEO + `Discipline` so that it can be served over gRPC and called from any + Philote-MDO client, in particular an OpenMDAO model. + +```mermaid +flowchart LR + subgraph GEMSEO_process["GEMSEO process"] + PD["PhiloteDiscipline"] + end + subgraph Server_process["Remote server process"] + S["Philote-MDO server
(any framework)"] + end + PD <-- "gRPC: values + Jacobian" --> S + + subgraph GEMSEO_process2["GEMSEO process"] + D["A GEMSEO Discipline"] + GTP["GEMSEOtoPhiloteDiscipline"] + D --> GTP + end + subgraph Client_process["Remote client process"] + C["Philote-MDO client
(e.g. OpenMDAO)"] + end + GTP <-- "gRPC: values + Jacobian" --> C +``` + +Both directions carry the discipline's Jacobian, not just its output +values, so a remote discipline can be used in a gradient-based +optimization exactly like a local one. + +This page walks through the first, and simplest, direction -- +`PhiloteDiscipline` consuming a remote discipline -- using the complete +example [`examples/paraboloid_gemseo.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/paraboloid_gemseo.py). +The [next tutorial](./gemseo-openaerostruct.md) covers a realistic, +coupled OpenMDAO analysis and the opposite direction is demonstrated in +[`examples/sellar_gemseo_to_openmdao.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/sellar_gemseo_to_openmdao.py). + +## Installation + +```bash +pip install philote-mdo gemseo +``` + +`philote_mdo.gemseo` (which provides `PhiloteDiscipline` and +`GEMSEOtoPhiloteDiscipline`) is part of `philote-mdo` itself; GEMSEO is +not a hard dependency of the package, so it must be installed separately +(or via the `philote-mdo[gemseo]` extra). + +## Walkthrough: optimizing a remote Paraboloid discipline + +The example serves `philote_mdo.examples.Paraboloid`, a toy discipline +computing `f_xy(x, y)`, over gRPC, and uses it as the objective of a +GEMSEO optimization scenario that also has a constraint computed locally. +The full script is reproduced at the end of this page; the sections below +explain it piece by piece. + +### Serving the discipline + +Any Philote-MDO discipline (here, the built-in `Paraboloid` example) is +served by wrapping it in a `philote_mdo.general.ExplicitServer` and +attaching that server to a started gRPC server: + +```python +def create_server() -> grpc.Server: + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + server.add_insecure_port(f"[::]:{PORT}") + server.start() + return server +``` + +### Connecting a GEMSEO client to it + +`PhiloteDiscipline` is the GEMSEO-side client. Connecting it to the +channel above is enough: at construction, it queries the server for the +discipline's inputs, outputs and available partial derivatives, and +builds its GEMSEO grammars from that metadata -- no manual declaration is +needed. + +```python +def create_discipline(server: grpc.Server) -> PhiloteDiscipline: + discipline = ExplicitServer(discipline=Paraboloid()) + discipline.attach_to_server(server) + return PhiloteDiscipline(channel=grpc.insecure_channel(f"{HOST}:{PORT}")) +``` + +From this point on, `paraboloid_disc` behaves like any other GEMSEO +`Discipline`: `paraboloid_disc.execute(...)` calls the remote +`ComputeFunction` RPC, and `paraboloid_disc.linearize(...)` calls the +remote `ComputeGradient` RPC. + +### Adding a local constraint + +Not every discipline of a scenario has to be remote. Here, the +constraint `g = x + y` is computed locally through GEMSEO's +`AutoPyDiscipline`, which turns a plain Python function into a +discipline by inspecting its signature and its `return` statement: + +```python +def compute_constraint(x: ndarray, y: ndarray) -> ndarray: + # AutoPyDiscipline infers the output name from this assignment, so the + # variable must be named "g" and returned as-is (not e.g. "return x + y"). + g = x + y + return g +``` + +:::warning +`AutoPyDiscipline` infers the output name(s) by parsing the literal +`return` statement of the function, so it must be `return g` with `g` +assigned beforehand, not an inlined expression like `return x + y`. +::: + +### Running the scenario + +The remote discipline and the local constraint discipline are combined in +a standard `MDOScenario`, built with `create_scenario`: + +```python +server = create_server() + +try: + paraboloid_disc = create_discipline(server) + design_space = create_design_space() + design_space.add_variable("x", lower_bound=-50, upper_bound=50, value=3.0) + design_space.add_variable("y", lower_bound=-50, upper_bound=50, value=-4.0) + + scenario = create_scenario( + [paraboloid_disc, AutoPyDiscipline(py_func=compute_constraint)], + objective_name="f_xy", + design_space=design_space, + formulation_settings_model=DisciplinaryOpt_Settings(), + ) + + # 0 <= g <= 10 + scenario.add_constraint("g", constraint_type="ineq", positive=True) + scenario.add_constraint("g", constraint_type="ineq", value=10.0) + scenario.execute(algo_settings_model=COBYQA_Settings(max_iter=100)) + + print("Optimal design found:", design_space.get_current_value(as_dict=True)) +finally: + # Stop the server explicitly: + # its worker threads would otherwise keep the process alive. + server.stop(0) +``` + +Running the script prints the optimal design found by COBYQA: + +```text +Optimal design found: {'x': array([7.]), 'y': array([-7.])} +``` + +which matches `f_xy(7, -7) = -27`, the known minimum of the paraboloid +under `0 <= x + y <= 10` (here reached at the boundary `g = 0`). + +## The full script + +```python title="examples/paraboloid_gemseo.py" +from __future__ import annotations + +from concurrent import futures + +import grpc +from gemseo import create_design_space +from gemseo import create_scenario +from gemseo.disciplines.auto_py import AutoPyDiscipline +from gemseo.settings.formulations import DisciplinaryOpt_Settings +from gemseo.settings.opt import COBYQA_Settings +from numpy import ndarray + +from philote_mdo.examples import Paraboloid +from philote_mdo.gemseo import PhiloteDiscipline +from philote_mdo.general import ExplicitServer + +HOST = "localhost" +PORT = 50051 + + +def create_discipline(server: grpc.Server) -> PhiloteDiscipline: + discipline = ExplicitServer(discipline=Paraboloid()) + discipline.attach_to_server(server) + return PhiloteDiscipline(channel=grpc.insecure_channel(f"{HOST}:{PORT}")) + + +def create_server() -> grpc.Server: + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + server.add_insecure_port(f"[::]:{PORT}") + server.start() + return server + + +def compute_constraint(x: ndarray, y: ndarray) -> ndarray: + # AutoPyDiscipline infers the output name from this assignment, so the + # variable must be named "g" and returned as-is (not e.g. "return x + y"). + g = x + y + return g + + +if __name__ == "__main__": + server = create_server() + + try: + paraboloid_disc = create_discipline(server) + design_space = create_design_space() + design_space.add_variable("x", lower_bound=-50, upper_bound=50, value=3.0) + design_space.add_variable("y", lower_bound=-50, upper_bound=50, value=-4.0) + + scenario = create_scenario( + [paraboloid_disc, AutoPyDiscipline(py_func=compute_constraint)], + objective_name="f_xy", + design_space=design_space, + formulation_settings_model=DisciplinaryOpt_Settings(), + ) + + # 0 <= g <= 10 + scenario.add_constraint("g", constraint_type="ineq", positive=True) + scenario.add_constraint("g", constraint_type="ineq", value=10.0) + scenario.execute(algo_settings_model=COBYQA_Settings(max_iter=100)) + + print("Optimal design found:", design_space.get_current_value(as_dict=True)) + finally: + # Stop the server explicitly: + # its worker threads would otherwise keep the process alive. + server.stop(0) +``` + +See the actual, always up-to-date source at +[`examples/paraboloid_gemseo.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/paraboloid_gemseo.py) +in the repository. + +## Where to go next + +- [Coupling OpenAeroStruct and GEMSEO](./gemseo-openaerostruct.md) applies + the same `PhiloteDiscipline` pattern to a realistic, internally-coupled + OpenMDAO analysis, and shows how the discipline's non-design inputs + must be set explicitly since Philote-MDO does not transfer GEMSEO's + default values. +- [`examples/sellar_gemseo_to_openmdao.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/sellar_gemseo_to_openmdao.py) + demonstrates the opposite direction with `GEMSEOtoPhiloteDiscipline`: + GEMSEO's Sellar disciplines are served over gRPC and optimized from an + OpenMDAO model. diff --git a/docs/versioned_sidebars/version-0.8.0-sidebars.json b/docs/versioned_sidebars/version-0.8.0-sidebars.json index 6eec8d4..1fca0b6 100644 --- a/docs/versioned_sidebars/version-0.8.0-sidebars.json +++ b/docs/versioned_sidebars/version-0.8.0-sidebars.json @@ -25,6 +25,14 @@ "openmdao/openmdao-groups" ] }, + { + "type": "category", + "label": "Working with GEMSEO", + "items": [ + "gemseo/gemseo", + "gemseo/gemseo-openaerostruct" + ] + }, { "type": "category", "label": "About", From c5d1465e87741150435e81228da27daad7780c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Gallard?= Date: Thu, 17 Sep 2026 16:03:20 +0200 Subject: [PATCH 06/21] Enable @docusaurus/theme-mermaid so the GEMSEO tutorial flowcharts render The GEMSEO tutorials use ```mermaid fences, but the theme was never actually wired in: package.json didn't list the dependency and docusaurus.config.ts had no markdown.mermaid / themes entry, so the diagrams were not compiled and rendered as plain code blocks instead. Verified by building the site and loading both tutorial pages in a browser: the flowcharts now render as SVG diagrams. Note: docs/package-lock.json still needs to be regenerated with real npm (this environment only has bun) before `npm ci` in CI will pick up the new dependency. Co-Authored-By: Claude Sonnet 5 --- docs/docusaurus.config.ts | 5 +++++ docs/package.json | 1 + 2 files changed, 6 insertions(+) diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 89eb25d..587a113 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -27,6 +27,11 @@ const config: Config = { locales: ["en"], }, + markdown: { + mermaid: true, + }, + themes: ["@docusaurus/theme-mermaid"], + stylesheets: [ { href: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css", diff --git a/docs/package.json b/docs/package.json index 77fdde2..03d7252 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,6 +17,7 @@ "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/preset-classic": "3.9.2", + "@docusaurus/theme-mermaid": "3.9.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", From 100bd80e13829a428619fb755732b220f006c266 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Thu, 17 Sep 2026 21:34:59 -0400 Subject: [PATCH 07/21] docs: keep the GEMSEO pages out of the 0.8.0 snapshot The 0.8.0 versioned docs describe the released package, which has no philote_mdo.gemseo module. The GEMSEO pages stay under docs/docs/ (the Next version) and will be snapshotted by the release workflow with the first release that ships them. Signed-off-by: Christopher Lupp --- .../gemseo/gemseo-openaerostruct.md | 208 -------------- .../version-0.8.0/gemseo/gemseo.md | 263 ------------------ .../version-0.8.0-sidebars.json | 8 - 3 files changed, 479 deletions(-) delete mode 100644 docs/versioned_docs/version-0.8.0/gemseo/gemseo-openaerostruct.md delete mode 100644 docs/versioned_docs/version-0.8.0/gemseo/gemseo.md diff --git a/docs/versioned_docs/version-0.8.0/gemseo/gemseo-openaerostruct.md b/docs/versioned_docs/version-0.8.0/gemseo/gemseo-openaerostruct.md deleted file mode 100644 index c9a8549..0000000 --- a/docs/versioned_docs/version-0.8.0/gemseo/gemseo-openaerostruct.md +++ /dev/null @@ -1,208 +0,0 @@ ---- -sidebar_position: 2 -title: "Coupling OpenAeroStruct and GEMSEO" ---- - -# Coupling OpenAeroStruct and GEMSEO through Philote-MDO - -This tutorial builds on [Using GEMSEO with Philote-MDO](./gemseo.md) and -walks through -[`examples/openaerostruct_to_gemseo.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/openaerostruct_to_gemseo.py), -a more realistic case: instead of a toy analytic function, the remote -discipline is an [OpenAeroStruct](https://github.com/mdolab/OpenAeroStruct) -aerostructural analysis -- a coupled VLM aerodynamic model and a -finite-element structural model, solved together with an OpenMDAO -nonlinear solver -- and it is optimized with a gradient-based algorithm. - -This example additionally requires OpenMDAO, OpenAeroStruct and the -[`philote-examples`](https://pypi.org/project/philote-examples/) package -(`pip install openaerostruct philote-examples`), which provides the -`OasAerostructDiscipline` used below. - -## Why this matters: hiding an OpenMDAO MDA behind a Philote-MDO discipline - -OpenAeroStruct is written for [OpenMDAO](https://openmdao.org): the -`philote-examples` package provides `OasAerostructDiscipline`, which -builds an OpenMDAO `Group` combining `AerostructGeometry` and -`AerostructPoint` for a wing, resolves the aero-structural coupling -internally with `NonlinearBlockGS`, and wraps the whole `om.Problem` as a -**single Philote-MDO explicit discipline**. - -This is the key interoperability point: from the Philote-MDO (and -therefore GEMSEO) side, this coupled, iterative OpenMDAO analysis is -completely invisible. `PhiloteDiscipline` only ever sees a stateless -function `inputs -> outputs` plus a Jacobian -- exactly like the -`Paraboloid` discipline of the [previous tutorial](./gemseo.md). GEMSEO -does not need OpenMDAO, OpenAeroStruct, or any of their dependencies -installed: it only talks gRPC to a server that happens to run them. - -```mermaid -flowchart LR - subgraph GEMSEO_process["GEMSEO process"] - PD["PhiloteDiscipline"] - SC["MDOScenario
(SLSQP)"] - SC --> PD - end - subgraph Server_process["Server process"] - OAS["OasAerostructDiscipline
(Philote explicit discipline)"] - subgraph OM["OpenMDAO Problem"] - VLM["VLM aerodynamics"] - FEM["FEM structure"] - VLM <-->|"NonlinearBlockGS"| FEM - end - OAS --> OM - end - PD <-- "gRPC: alpha -> CL, CD, ...
+ Jacobian" --> OAS -``` - -## A gradient-based scenario is limited to what the server differentiates - -`OasAerostructDiscipline` only declares two partial derivatives on the -server side (in its `_build_discipline` method): - -```python -self.declare_subproblem_partial("CD", "alpha") -self.declare_subproblem_partial("CL", "alpha") -``` - -Only declared partials are computed and sent to the client: a -gradient-based GEMSEO scenario can therefore only use `alpha` as a design -variable, and `CL`/`CD` as objective/constraint candidates -- using any -other input as a design variable, or any other output as an objective or -constraint, would make GEMSEO request a Jacobian entry the server never -computes. - -This shapes the scenario: **trim the wing** by finding the angle of -attack `alpha` that **minimizes drag** `CD` while holding the **lift -coefficient** `CL` at a target value, a classic aerostructural design -problem. - -## Walkthrough - -### Fixing the non-design inputs - -The Philote-MDO protocol only transfers variable names, shapes and units -over gRPC -- not GEMSEO's default values. Since `alpha` is the only -design variable, every other input of the discipline (flight conditions -and mission parameters) must be given a value explicitly, or GEMSEO would -have no default to execute the discipline with. This is done once, by -updating the connected discipline's `default_input_data`: - -```python -FIXED_INPUTS = { - "v": array([248.136]), - "Mach_number": array([0.84]), - "re": array([1e6]), - "rho": array([0.38]), - "CT": array([grav_constant * 17.0e-6]), - "R": array([11.165e6]), - "W0": array([0.4 * 3e5]), - "speed_of_sound": array([295.4]), - "load_factor": array([1.0]), - "empty_cg": np.zeros(3), -} - - -def create_discipline(server: grpc.Server) -> PhiloteDiscipline: - discipline = pmdo.ExplicitServer(discipline=OasAerostructDiscipline()) - discipline.attach_to_server(server) - oas_discipline = PhiloteDiscipline(channel=grpc.insecure_channel(f"{HOST}:{PORT}")) - oas_discipline.default_input_data.update(FIXED_INPUTS) - return oas_discipline -``` - -:::note -This is not specific to GEMSEO: any Philote-MDO client that does not set -a value for a non-design input runs into the same issue. The -[Sellar example](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/sellar_gemseo_to_openmdao.py), -which drives remote *GEMSEO* disciplines from *OpenMDAO*, sets its fixed -parameters explicitly with `prob.set_val(...)` for the exact same reason. -::: - -### Serving the discipline - -As in the previous tutorial, serving the discipline only requires -starting a gRPC server and attaching an `ExplicitServer` wrapping it: - -```python -def create_server() -> grpc.Server: - server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) - server.add_insecure_port(f"[::]:{PORT}") - server.start() - return server -``` - -### Building and running the scenario - -The design space has a single variable, `alpha` (in degrees); the -scenario minimizes `CD` under the equality constraint `CL = 0.5`, using -the gradient-based `SLSQP` algorithm -- made possible because the server -provides the `CL`/`CD` derivatives with respect to `alpha` analytically: - -```python -if __name__ == "__main__": - server = create_server() - - try: - oas_disc = create_discipline(server) - - design_space = create_design_space() - # alpha is in degrees, as declared by OasAerostructDiscipline. - design_space.add_variable( - "alpha", lower_bound=-10.0, upper_bound=15.0, value=5.0 - ) - - scenario = create_scenario( - [oas_disc], - objective_name="CD", - design_space=design_space, - formulation_settings_model=DisciplinaryOpt_Settings(), - ) - # Trim the wing: CL = 0.5 - scenario.add_constraint("CL", constraint_type="eq", value=0.5) - scenario.execute(algo_settings_model=SLSQP_Settings(max_iter=20)) - - out = oas_disc.local_data - print( - "Trimmed solution:", - {name: out[name] for name in ("alpha", "CL", "CD", "failure")}, - ) - execute_post(scenario, OptHistoryView_Settings(save=True, show=False)) - - finally: - # Stop the server explicitly: - # its worker threads would otherwise keep the process alive. - server.stop(0) -``` - -Running the script converges in a handful of iterations and prints: - -```text -Trimmed solution: { - 'alpha': array([4.07490213]), - 'CL': array([0.5]), - 'CD': array([0.03553854]), - 'failure': array([-0.91249284]), -} -``` - -`CL` reaches the target `0.5` exactly (the equality constraint fully -determines `alpha` here, since there is a single design variable), and -`failure` stays negative, meaning the structure remains within its -allowable stress margin at the trimmed condition. - -## The full script - -See the actual, always up-to-date source at -[`examples/openaerostruct_to_gemseo.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/openaerostruct_to_gemseo.py) -in the repository. - -## Where to go next - -[`examples/sellar_gemseo_to_openmdao.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/sellar_gemseo_to_openmdao.py) -shows the mirror image of this tutorial: instead of GEMSEO calling an -OpenMDAO/OpenAeroStruct discipline, GEMSEO's own Sellar disciplines are -served over gRPC with `GEMSEOtoPhiloteDiscipline` and optimized from an -OpenMDAO model with SLSQP -- the same coupling pattern used here (an -internal Gauss-Seidel solver resolving an algebraic loop behind the -scenes), but with the two frameworks swapped. diff --git a/docs/versioned_docs/version-0.8.0/gemseo/gemseo.md b/docs/versioned_docs/version-0.8.0/gemseo/gemseo.md deleted file mode 100644 index be03dcc..0000000 --- a/docs/versioned_docs/version-0.8.0/gemseo/gemseo.md +++ /dev/null @@ -1,263 +0,0 @@ ---- -sidebar_position: 1 -title: "Using GEMSEO with Philote-MDO" ---- - -# Using GEMSEO with Philote-MDO - -## What problem does this solve? - -[Philote-MDO](https://github.com/MDO-Standards/Philote-Python) is a gRPC -protocol for exchanging MDO disciplines between tools. A discipline is -served by one process (possibly on another machine, in another language, -or built with another MDO framework) and called by a client process as if -it were local: the client sends input values, and the server streams back -the output values and, if available, the Jacobian. - -`philote_mdo.gemseo` implements this protocol on the GEMSEO side, in -**both** directions: - -- `PhiloteDiscipline` is a GEMSEO `Discipline` that connects to *any* - Philote-MDO server (be it a `philote-mdo` discipline, an OpenAeroStruct - model, or an OpenMDAO component served through - `philote_mdo.openmdao.RemoteExplicitComponent`) and exposes it as a - normal discipline, usable in an `MDOScenario` or an `MDA` like any - other. -- `GEMSEOtoPhiloteDiscipline` does the opposite: it wraps a GEMSEO - `Discipline` so that it can be served over gRPC and called from any - Philote-MDO client, in particular an OpenMDAO model. - -```mermaid -flowchart LR - subgraph GEMSEO_process["GEMSEO process"] - PD["PhiloteDiscipline"] - end - subgraph Server_process["Remote server process"] - S["Philote-MDO server
(any framework)"] - end - PD <-- "gRPC: values + Jacobian" --> S - - subgraph GEMSEO_process2["GEMSEO process"] - D["A GEMSEO Discipline"] - GTP["GEMSEOtoPhiloteDiscipline"] - D --> GTP - end - subgraph Client_process["Remote client process"] - C["Philote-MDO client
(e.g. OpenMDAO)"] - end - GTP <-- "gRPC: values + Jacobian" --> C -``` - -Both directions carry the discipline's Jacobian, not just its output -values, so a remote discipline can be used in a gradient-based -optimization exactly like a local one. - -This page walks through the first, and simplest, direction -- -`PhiloteDiscipline` consuming a remote discipline -- using the complete -example [`examples/paraboloid_gemseo.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/paraboloid_gemseo.py). -The [next tutorial](./gemseo-openaerostruct.md) covers a realistic, -coupled OpenMDAO analysis and the opposite direction is demonstrated in -[`examples/sellar_gemseo_to_openmdao.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/sellar_gemseo_to_openmdao.py). - -## Installation - -```bash -pip install philote-mdo gemseo -``` - -`philote_mdo.gemseo` (which provides `PhiloteDiscipline` and -`GEMSEOtoPhiloteDiscipline`) is part of `philote-mdo` itself; GEMSEO is -not a hard dependency of the package, so it must be installed separately -(or via the `philote-mdo[gemseo]` extra). - -## Walkthrough: optimizing a remote Paraboloid discipline - -The example serves `philote_mdo.examples.Paraboloid`, a toy discipline -computing `f_xy(x, y)`, over gRPC, and uses it as the objective of a -GEMSEO optimization scenario that also has a constraint computed locally. -The full script is reproduced at the end of this page; the sections below -explain it piece by piece. - -### Serving the discipline - -Any Philote-MDO discipline (here, the built-in `Paraboloid` example) is -served by wrapping it in a `philote_mdo.general.ExplicitServer` and -attaching that server to a started gRPC server: - -```python -def create_server() -> grpc.Server: - server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) - server.add_insecure_port(f"[::]:{PORT}") - server.start() - return server -``` - -### Connecting a GEMSEO client to it - -`PhiloteDiscipline` is the GEMSEO-side client. Connecting it to the -channel above is enough: at construction, it queries the server for the -discipline's inputs, outputs and available partial derivatives, and -builds its GEMSEO grammars from that metadata -- no manual declaration is -needed. - -```python -def create_discipline(server: grpc.Server) -> PhiloteDiscipline: - discipline = ExplicitServer(discipline=Paraboloid()) - discipline.attach_to_server(server) - return PhiloteDiscipline(channel=grpc.insecure_channel(f"{HOST}:{PORT}")) -``` - -From this point on, `paraboloid_disc` behaves like any other GEMSEO -`Discipline`: `paraboloid_disc.execute(...)` calls the remote -`ComputeFunction` RPC, and `paraboloid_disc.linearize(...)` calls the -remote `ComputeGradient` RPC. - -### Adding a local constraint - -Not every discipline of a scenario has to be remote. Here, the -constraint `g = x + y` is computed locally through GEMSEO's -`AutoPyDiscipline`, which turns a plain Python function into a -discipline by inspecting its signature and its `return` statement: - -```python -def compute_constraint(x: ndarray, y: ndarray) -> ndarray: - # AutoPyDiscipline infers the output name from this assignment, so the - # variable must be named "g" and returned as-is (not e.g. "return x + y"). - g = x + y - return g -``` - -:::warning -`AutoPyDiscipline` infers the output name(s) by parsing the literal -`return` statement of the function, so it must be `return g` with `g` -assigned beforehand, not an inlined expression like `return x + y`. -::: - -### Running the scenario - -The remote discipline and the local constraint discipline are combined in -a standard `MDOScenario`, built with `create_scenario`: - -```python -server = create_server() - -try: - paraboloid_disc = create_discipline(server) - design_space = create_design_space() - design_space.add_variable("x", lower_bound=-50, upper_bound=50, value=3.0) - design_space.add_variable("y", lower_bound=-50, upper_bound=50, value=-4.0) - - scenario = create_scenario( - [paraboloid_disc, AutoPyDiscipline(py_func=compute_constraint)], - objective_name="f_xy", - design_space=design_space, - formulation_settings_model=DisciplinaryOpt_Settings(), - ) - - # 0 <= g <= 10 - scenario.add_constraint("g", constraint_type="ineq", positive=True) - scenario.add_constraint("g", constraint_type="ineq", value=10.0) - scenario.execute(algo_settings_model=COBYQA_Settings(max_iter=100)) - - print("Optimal design found:", design_space.get_current_value(as_dict=True)) -finally: - # Stop the server explicitly: - # its worker threads would otherwise keep the process alive. - server.stop(0) -``` - -Running the script prints the optimal design found by COBYQA: - -```text -Optimal design found: {'x': array([7.]), 'y': array([-7.])} -``` - -which matches `f_xy(7, -7) = -27`, the known minimum of the paraboloid -under `0 <= x + y <= 10` (here reached at the boundary `g = 0`). - -## The full script - -```python title="examples/paraboloid_gemseo.py" -from __future__ import annotations - -from concurrent import futures - -import grpc -from gemseo import create_design_space -from gemseo import create_scenario -from gemseo.disciplines.auto_py import AutoPyDiscipline -from gemseo.settings.formulations import DisciplinaryOpt_Settings -from gemseo.settings.opt import COBYQA_Settings -from numpy import ndarray - -from philote_mdo.examples import Paraboloid -from philote_mdo.gemseo import PhiloteDiscipline -from philote_mdo.general import ExplicitServer - -HOST = "localhost" -PORT = 50051 - - -def create_discipline(server: grpc.Server) -> PhiloteDiscipline: - discipline = ExplicitServer(discipline=Paraboloid()) - discipline.attach_to_server(server) - return PhiloteDiscipline(channel=grpc.insecure_channel(f"{HOST}:{PORT}")) - - -def create_server() -> grpc.Server: - server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) - server.add_insecure_port(f"[::]:{PORT}") - server.start() - return server - - -def compute_constraint(x: ndarray, y: ndarray) -> ndarray: - # AutoPyDiscipline infers the output name from this assignment, so the - # variable must be named "g" and returned as-is (not e.g. "return x + y"). - g = x + y - return g - - -if __name__ == "__main__": - server = create_server() - - try: - paraboloid_disc = create_discipline(server) - design_space = create_design_space() - design_space.add_variable("x", lower_bound=-50, upper_bound=50, value=3.0) - design_space.add_variable("y", lower_bound=-50, upper_bound=50, value=-4.0) - - scenario = create_scenario( - [paraboloid_disc, AutoPyDiscipline(py_func=compute_constraint)], - objective_name="f_xy", - design_space=design_space, - formulation_settings_model=DisciplinaryOpt_Settings(), - ) - - # 0 <= g <= 10 - scenario.add_constraint("g", constraint_type="ineq", positive=True) - scenario.add_constraint("g", constraint_type="ineq", value=10.0) - scenario.execute(algo_settings_model=COBYQA_Settings(max_iter=100)) - - print("Optimal design found:", design_space.get_current_value(as_dict=True)) - finally: - # Stop the server explicitly: - # its worker threads would otherwise keep the process alive. - server.stop(0) -``` - -See the actual, always up-to-date source at -[`examples/paraboloid_gemseo.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/paraboloid_gemseo.py) -in the repository. - -## Where to go next - -- [Coupling OpenAeroStruct and GEMSEO](./gemseo-openaerostruct.md) applies - the same `PhiloteDiscipline` pattern to a realistic, internally-coupled - OpenMDAO analysis, and shows how the discipline's non-design inputs - must be set explicitly since Philote-MDO does not transfer GEMSEO's - default values. -- [`examples/sellar_gemseo_to_openmdao.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/sellar_gemseo_to_openmdao.py) - demonstrates the opposite direction with `GEMSEOtoPhiloteDiscipline`: - GEMSEO's Sellar disciplines are served over gRPC and optimized from an - OpenMDAO model. diff --git a/docs/versioned_sidebars/version-0.8.0-sidebars.json b/docs/versioned_sidebars/version-0.8.0-sidebars.json index 1fca0b6..6eec8d4 100644 --- a/docs/versioned_sidebars/version-0.8.0-sidebars.json +++ b/docs/versioned_sidebars/version-0.8.0-sidebars.json @@ -25,14 +25,6 @@ "openmdao/openmdao-groups" ] }, - { - "type": "category", - "label": "Working with GEMSEO", - "items": [ - "gemseo/gemseo", - "gemseo/gemseo-openaerostruct" - ] - }, { "type": "category", "label": "About", From 105673d74a05654edede32f534ea3d245da34878 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Thu, 17 Sep 2026 21:56:05 -0400 Subject: [PATCH 08/21] docs: sync package-lock.json with the mermaid theme dependency package.json gained @docusaurus/theme-mermaid without a matching lock update, so the documentation workflow's npm ci refused to install. Regenerated with npm 10 (the version CI's Node 20 ships) in lockfile-only mode: this adds mermaid and its dependencies, and bumps katex from 0.16.45 to 0.16.47 because mermaid requires ^0.16.47. Signed-off-by: Christopher Lupp --- docs/package-lock.json | 1429 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 1285 insertions(+), 144 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index d5bce68..c05c360 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/preset-classic": "3.9.2", + "@docusaurus/theme-mermaid": "3.9.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", @@ -262,6 +263,19 @@ "node": ">= 14.0.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.1.0.tgz", + "integrity": "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.8.0", + "tinyexec": "^1.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1992,6 +2006,51 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", + "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.1.2", + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", + "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", + "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", + "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", + "license": "Apache-2.0" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -3932,6 +3991,34 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/@docusaurus/theme-mermaid": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-mermaid/-/theme-mermaid-3.9.2.tgz", + "integrity": "sha512-5vhShRDq/ntLzdInsQkTdoKWSzw8d1jB17sNPYhA/KvYYFXfuVEGHLM6nrf8MFbV8TruAHDG21Fn3W4lO8GaDw==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.9.2", + "@docusaurus/module-type-aliases": "3.9.2", + "@docusaurus/theme-common": "3.9.2", + "@docusaurus/types": "3.9.2", + "@docusaurus/utils-validation": "3.9.2", + "mermaid": ">=11.6.0", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "@mermaid-js/layout-elk": "^0.1.9", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@mermaid-js/layout-elk": { + "optional": true + } + } + }, "node_modules/@docusaurus/theme-search-algolia": { "version": "3.9.2", "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.9.2.tgz", @@ -4098,6 +4185,23 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz", + "integrity": "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^2.0.1", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -4651,6 +4755,18 @@ "react": ">=16" } }, + "node_modules/@mermaid-js/parser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-2.0.0.tgz", + "integrity": "sha512-K8BeapFUfrfxbRAUQAG5oBOCwo1+bNWzaVnPxTCutkfrTBn6T/j91FIhqxWJ32SUeQ9T3iy1zcmPZ5ROZEvDrg==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@noble/hashes": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", @@ -5250,6 +5366,259 @@ "@types/node": "*" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -5318,6 +5687,12 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/gtag.js": { "version": "0.0.12", "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.12.tgz", @@ -5560,6 +5935,13 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5596,6 +5978,16 @@ "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "license": "ISC" }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -6696,6 +7088,20 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chevrotain": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", + "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.1.2", + "@chevrotain/gast": "11.1.2", + "@chevrotain/regexp-to-ast": "11.1.2", + "@chevrotain/types": "11.1.2", + "@chevrotain/utils": "11.1.2", + "lodash-es": "4.17.23" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -7193,6 +7599,15 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -7495,187 +7910,713 @@ "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", "license": "MIT", "dependencies": { - "mdn-data": "2.0.30", - "source-map-js": "^1.0.1" + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssdb": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.10.0.tgz", + "integrity": "sha512-+JWEEjjoqkPY7iGHps3SaCT2w67Zpaj9zHvhCJ2iPBavKHwgtOOD2YEbZSCU8VO2uVGpKdYjVz+j6bhkNSjZ0g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.1.2.tgz", + "integrity": "sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==", + "license": "MIT", + "dependencies": { + "cssnano-preset-default": "^6.1.2", + "lilconfig": "^3.1.1" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-preset-advanced": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-6.1.2.tgz", + "integrity": "sha512-Nhao7eD8ph2DoHolEzQs5CfRpiEP0xa1HBdnFZ82kvqdmbwVBUr2r1QuQ4t1pi+D1ZpqpcO4T+wy/7RxzJ/WPQ==", + "license": "MIT", + "dependencies": { + "autoprefixer": "^10.4.19", + "browserslist": "^4.23.0", + "cssnano-preset-default": "^6.1.2", + "postcss-discard-unused": "^6.0.5", + "postcss-merge-idents": "^6.0.3", + "postcss-reduce-idents": "^6.0.3", + "postcss-zindex": "^6.0.2" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-preset-default": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.1.2.tgz", + "integrity": "sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "css-declaration-sorter": "^7.2.0", + "cssnano-utils": "^4.0.2", + "postcss-calc": "^9.0.1", + "postcss-colormin": "^6.1.0", + "postcss-convert-values": "^6.1.0", + "postcss-discard-comments": "^6.0.2", + "postcss-discard-duplicates": "^6.0.3", + "postcss-discard-empty": "^6.0.3", + "postcss-discard-overridden": "^6.0.2", + "postcss-merge-longhand": "^6.0.5", + "postcss-merge-rules": "^6.1.1", + "postcss-minify-font-values": "^6.1.0", + "postcss-minify-gradients": "^6.0.3", + "postcss-minify-params": "^6.1.0", + "postcss-minify-selectors": "^6.0.4", + "postcss-normalize-charset": "^6.0.2", + "postcss-normalize-display-values": "^6.0.2", + "postcss-normalize-positions": "^6.0.2", + "postcss-normalize-repeat-style": "^6.0.2", + "postcss-normalize-string": "^6.0.2", + "postcss-normalize-timing-functions": "^6.0.2", + "postcss-normalize-unicode": "^6.1.0", + "postcss-normalize-url": "^6.0.2", + "postcss-normalize-whitespace": "^6.0.2", + "postcss-ordered-values": "^6.0.2", + "postcss-reduce-initial": "^6.1.0", + "postcss-reduce-transforms": "^6.0.2", + "postcss-svgo": "^6.0.3", + "postcss-unique-selectors": "^6.0.4" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-utils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.2.tgz", + "integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.34.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz", + "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + "node": ">=12" } }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" }, - "funding": { - "url": "https://github.com/sponsors/fb55" + "engines": { + "node": ">=12" } }, - "node_modules/cssdb": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.10.0.tgz", - "integrity": "sha512-+JWEEjjoqkPY7iGHps3SaCT2w67Zpaj9zHvhCJ2iPBavKHwgtOOD2YEbZSCU8VO2uVGpKdYjVz+j6bhkNSjZ0g==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - } - ], - "license": "MIT-0" - }, - "node_modules/cssesc": { + "node_modules/d3-selection": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", "engines": { - "node": ">=4" + "node": ">=12" } }, - "node_modules/cssnano": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.1.2.tgz", - "integrity": "sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==", - "license": "MIT", + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", "dependencies": { - "cssnano-preset-default": "^6.1.2", - "lilconfig": "^3.1.1" + "d3-path": "^3.1.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/cssnano" - }, - "peerDependencies": { - "postcss": "^8.4.31" + "node": ">=12" } }, - "node_modules/cssnano-preset-advanced": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-6.1.2.tgz", - "integrity": "sha512-Nhao7eD8ph2DoHolEzQs5CfRpiEP0xa1HBdnFZ82kvqdmbwVBUr2r1QuQ4t1pi+D1ZpqpcO4T+wy/7RxzJ/WPQ==", - "license": "MIT", + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", "dependencies": { - "autoprefixer": "^10.4.19", - "browserslist": "^4.23.0", - "cssnano-preset-default": "^6.1.2", - "postcss-discard-unused": "^6.0.5", - "postcss-merge-idents": "^6.0.3", - "postcss-reduce-idents": "^6.0.3", - "postcss-zindex": "^6.0.2" + "d3-array": "2 - 3" }, "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" + "node": ">=12" } }, - "node_modules/cssnano-preset-default": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.1.2.tgz", - "integrity": "sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==", - "license": "MIT", + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", "dependencies": { - "browserslist": "^4.23.0", - "css-declaration-sorter": "^7.2.0", - "cssnano-utils": "^4.0.2", - "postcss-calc": "^9.0.1", - "postcss-colormin": "^6.1.0", - "postcss-convert-values": "^6.1.0", - "postcss-discard-comments": "^6.0.2", - "postcss-discard-duplicates": "^6.0.3", - "postcss-discard-empty": "^6.0.3", - "postcss-discard-overridden": "^6.0.2", - "postcss-merge-longhand": "^6.0.5", - "postcss-merge-rules": "^6.1.1", - "postcss-minify-font-values": "^6.1.0", - "postcss-minify-gradients": "^6.0.3", - "postcss-minify-params": "^6.1.0", - "postcss-minify-selectors": "^6.0.4", - "postcss-normalize-charset": "^6.0.2", - "postcss-normalize-display-values": "^6.0.2", - "postcss-normalize-positions": "^6.0.2", - "postcss-normalize-repeat-style": "^6.0.2", - "postcss-normalize-string": "^6.0.2", - "postcss-normalize-timing-functions": "^6.0.2", - "postcss-normalize-unicode": "^6.1.0", - "postcss-normalize-url": "^6.0.2", - "postcss-normalize-whitespace": "^6.0.2", - "postcss-ordered-values": "^6.0.2", - "postcss-reduce-initial": "^6.1.0", - "postcss-reduce-transforms": "^6.0.2", - "postcss-svgo": "^6.0.3", - "postcss-unique-selectors": "^6.0.4" + "d3-time": "1 - 3" }, "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" + "node": ">=12" } }, - "node_modules/cssnano-utils": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.2.tgz", - "integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==", - "license": "MIT", + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" + "node": ">=12" } }, - "node_modules/csso": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", - "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", - "license": "MIT", + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", "dependencies": { - "css-tree": "~2.2.0" + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" } }, - "node_modules/csso/node_modules/css-tree": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", - "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", - "license": "MIT", + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", "dependencies": { - "mdn-data": "2.0.28", - "source-map-js": "^1.0.1" + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" + "node": ">=12" } }, - "node_modules/csso/node_modules/mdn-data": { - "version": "2.0.28", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", - "license": "CC0-1.0" + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "license": "MIT" }, "node_modules/debounce": { @@ -7839,6 +8780,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -7977,6 +8927,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -8063,6 +9022,12 @@ "integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==", "license": "ISC" }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -8173,6 +9138,18 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/esast-util-from-estree": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", @@ -9209,6 +10186,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -9920,6 +10903,16 @@ "node": ">=8" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -9968,6 +10961,15 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -10474,9 +11476,9 @@ } }, "node_modules/katex": { - "version": "0.16.45", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", - "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -10507,6 +11509,11 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -10550,6 +11557,12 @@ "shell-quote": "^1.8.4" } }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -10625,6 +11638,12 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -10717,6 +11736,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -11215,6 +12246,40 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-12.0.0.tgz", + "integrity": "sha512-/wQXC9iBxoGV8p3erbvaXs9h77VyLDBH6GdayVjj3hEcSQhFU4N1WUhUppotCEqlIxI2pRMwjwBSwTB1MfZBgQ==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^2.0.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "chevrotain": "~11.1.2", + "cytoscape": "^3.34.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.21", + "dompurify": "^3.4.12", + "elkjs": "^0.9.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.47", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -13656,6 +14721,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -13783,6 +14854,12 @@ "tslib": "^2.0.3" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", @@ -13881,6 +14958,22 @@ "node": ">=16.0.0" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -16338,6 +17431,24 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/rtlcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz", @@ -16391,6 +17502,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -17223,6 +18340,12 @@ "postcss": "^8.4.31" } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -17423,6 +18546,15 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -17498,6 +18630,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", From 6722a20dbc4981ffaa912f971c24c92b9d9ef380 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Fri, 18 Sep 2026 20:59:47 -0400 Subject: [PATCH 09/21] docs: require philote-examples 0.5.1 for the OpenAeroStruct example philote-examples 0.5.1 is the first release whose OasAerostructDiscipline declares the CD and CL partials with respect to alpha. With earlier versions the SLSQP scenario fails on its first gradient request. Signed-off-by: Christopher Lupp --- docs/docs/gemseo/gemseo-openaerostruct.md | 9 ++++++--- examples/openaerostruct_to_gemseo.py | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/docs/gemseo/gemseo-openaerostruct.md b/docs/docs/gemseo/gemseo-openaerostruct.md index c9a8549..92c2db7 100644 --- a/docs/docs/gemseo/gemseo-openaerostruct.md +++ b/docs/docs/gemseo/gemseo-openaerostruct.md @@ -15,9 +15,12 @@ finite-element structural model, solved together with an OpenMDAO nonlinear solver -- and it is optimized with a gradient-based algorithm. This example additionally requires OpenMDAO, OpenAeroStruct and the -[`philote-examples`](https://pypi.org/project/philote-examples/) package -(`pip install openaerostruct philote-examples`), which provides the -`OasAerostructDiscipline` used below. +[`philote-examples`](https://pypi.org/project/philote-examples/) package, +version 0.5.1 or later +(`pip install openaerostruct "philote-examples>=0.5.1"`), which provides +the `OasAerostructDiscipline` used below. Earlier versions of +`philote-examples` do not declare the partial derivatives this tutorial +relies on. ## Why this matters: hiding an OpenMDAO MDA behind a Philote-MDO discipline diff --git a/examples/openaerostruct_to_gemseo.py b/examples/openaerostruct_to_gemseo.py index 187d5c0..28a7d57 100644 --- a/examples/openaerostruct_to_gemseo.py +++ b/examples/openaerostruct_to_gemseo.py @@ -36,8 +36,10 @@ constraint, for a gradient-based scenario. The other flight-condition and mission parameters are kept fixed at their default values. -This requires GEMSEO, OpenMDAO, OpenAeroStruct and ``philote-examples`` to -be installed (``pip install gemseo openaerostruct philote-examples``). +This requires GEMSEO, OpenMDAO, OpenAeroStruct and ``philote-examples`` +0.5.1 or later, the first version whose ``OasAerostructDiscipline`` +declares these partial derivatives, to be installed +(``pip install gemseo openaerostruct "philote-examples>=0.5.1"``). """ from __future__ import annotations From ec9254a91e7b173a2da6349c5502c13e7e12481b Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Fri, 18 Sep 2026 21:01:57 -0400 Subject: [PATCH 10/21] docs: correct the PhiloteDiscipline description in the GEMSEO intro PhiloteDiscipline is built on ExplicitClient, so it connects to explicit Philote-MDO servers only; against an implicit server it fails at execute with "Method not found". The intro also named RemoteExplicitComponent, the OpenMDAO client, as the way to serve an OpenMDAO model; serving is done with OpenMdaoSubProblem. Signed-off-by: Christopher Lupp --- docs/docs/gemseo/gemseo.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs/gemseo/gemseo.md b/docs/docs/gemseo/gemseo.md index be03dcc..112d865 100644 --- a/docs/docs/gemseo/gemseo.md +++ b/docs/docs/gemseo/gemseo.md @@ -18,9 +18,9 @@ the output values and, if available, the Jacobian. **both** directions: - `PhiloteDiscipline` is a GEMSEO `Discipline` that connects to *any* - Philote-MDO server (be it a `philote-mdo` discipline, an OpenAeroStruct - model, or an OpenMDAO component served through - `philote_mdo.openmdao.RemoteExplicitComponent`) and exposes it as a + (explicit) Philote-MDO server (be it a `philote-mdo` discipline, an + OpenAeroStruct model, or an OpenMDAO group served through + `philote_mdo.openmdao.OpenMdaoSubProblem`) and exposes it as a normal discipline, usable in an `MDOScenario` or an `MDA` like any other. - `GEMSEOtoPhiloteDiscipline` does the opposite: it wraps a GEMSEO From 2764659f849fa6f69521a8841dc304774850fbb1 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Fri, 18 Sep 2026 21:08:12 -0400 Subject: [PATCH 11/21] docs: list GEMSEO as a test dependency The GEMSEO tests import gemseo at module level, so without it installed they fail to import. List it next to OpenMDAO in the README and the installation page. Signed-off-by: Christopher Lupp --- README.md | 1 + docs/docs/getting-started/installation.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index a8e8fce..b38e7c3 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ installed automatically during the installation process: To run the unit and integration tests, you will need: - openmdao (can be found [here](https://github.com/OpenMDAO/OpenMDAO) or installed via pip) +- gemseo (can be found [here](https://gitlab.com/gemseo/dev/gemseo) or installed via pip) ## Installation diff --git a/docs/docs/getting-started/installation.md b/docs/docs/getting-started/installation.md index 9c62167..31a2db8 100644 --- a/docs/docs/getting-started/installation.md +++ b/docs/docs/getting-started/installation.md @@ -23,6 +23,7 @@ Additionally, the following dependencies are required by Philote-MDO and will be To run the unit and integration tests, you will need: - [OpenMDAO](https://github.com/OpenMDAO/OpenMDAO) (also installable via pip) +- [GEMSEO](https://gitlab.com/gemseo/dev/gemseo) (also installable via pip) ## Compiling Definitions and Installation From 766ab3905be8c4786bf758e8ac9e74cc26671eeb Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Fri, 18 Sep 2026 21:08:12 -0400 Subject: [PATCH 12/21] Rename gemseo_discipine to gemseo_discipline Fix the misspelled GEMSEOtoPhiloteDiscipline attribute and constructor argument before they are released as public API. Signed-off-by: Christopher Lupp --- philote_mdo/gemseo/gemseo_to_philote.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/philote_mdo/gemseo/gemseo_to_philote.py b/philote_mdo/gemseo/gemseo_to_philote.py index 8e33c39..d670e39 100644 --- a/philote_mdo/gemseo/gemseo_to_philote.py +++ b/philote_mdo/gemseo/gemseo_to_philote.py @@ -55,24 +55,24 @@ class GEMSEOtoPhiloteDiscipline( (see :meth:`~gemseo.core.discipline.discipline.Discipline.linearize`). """ - gemseo_discipine: Discipline + gemseo_discipline: Discipline """The GEMSEO discipline to be wrapped.""" default_data_size: int """The default size used to declare a variable that has no default value.""" - def __init__(self, gemseo_discipine: Discipline, default_data_size: int = 1): + def __init__(self, gemseo_discipline: Discipline, default_data_size: int = 1): """Initialize the GEMSEO discipline. Args: - gemseo_discipine: The GEMSEO discipline to be wrapped. + gemseo_discipline: The GEMSEO discipline to be wrapped. default_data_size: The default data size to be declared for an input or output variable when the wrapped GEMSEO discipline does not define a default value (as a :class:`~numpy.ndarray`) for it in its grammars. """ super().__init__() - self.gemseo_discipine = gemseo_discipine + self.gemseo_discipline = gemseo_discipline self.default_data_size = default_data_size def setup(self): @@ -87,7 +87,7 @@ def setup(self): when it is a :class:`~numpy.ndarray`, and from :attr:`.default_data_size` otherwise. """ - disc = self.gemseo_discipine + disc = self.gemseo_discipline for input_name in disc.input_grammar.names: data = disc.default_input_data.get(input_name) size = self.default_data_size @@ -108,8 +108,8 @@ def setup_partials(self): wrapped GEMSEO discipline are declared as partial derivatives to be computed by :meth:`.compute_partials`. """ - for output_name in self.gemseo_discipine.output_grammar.names: - for input_name in self.gemseo_discipine.input_grammar.names: + for output_name in self.gemseo_discipline.output_grammar.names: + for input_name in self.gemseo_discipline.input_grammar.names: self.declare_partials(output_name, input_name) def compute(self, inputs, outputs): @@ -121,8 +121,8 @@ def compute(self, inputs, outputs): computed by the wrapped GEMSEO discipline, indexed by output name. """ - out = self.gemseo_discipine.execute(inputs) - outputs.update({k: out[k] for k in self.gemseo_discipine.output_grammar.names}) + out = self.gemseo_discipline.execute(inputs) + outputs.update({k: out[k] for k in self.gemseo_discipline.output_grammar.names}) def compute_partials(self, inputs, partials): """Linearize the wrapped GEMSEO discipline. @@ -133,9 +133,9 @@ def compute_partials(self, inputs, partials): computed by the wrapped GEMSEO discipline, indexed by ``(output_name, input_name)`` pairs. """ - jac = self.gemseo_discipine.linearize(inputs, compute_all_jacobians=True) - output_names = self.gemseo_discipine.output_grammar.names - input_names = self.gemseo_discipine.input_grammar.names + jac = self.gemseo_discipline.linearize(inputs, compute_all_jacobians=True) + output_names = self.gemseo_discipline.output_grammar.names + input_names = self.gemseo_discipline.input_grammar.names for out_k, jac_in in jac.items(): if out_k not in output_names: From ed64e7fddb9847988dae5fb0a084e0b73c151361 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Fri, 18 Sep 2026 21:14:45 -0400 Subject: [PATCH 13/21] PhiloteDiscipline: use the server's name, reject unsupported variables The constructor documented that an empty name would use the remote discipline's name, but passed it straight to GEMSEO, so every remote discipline was named PhiloteDiscipline. Query GetInfo and use the server-reported name when none is given; GEMSEO still falls back to the class name when the server reports none (the default). Dynamic-shape and discrete server variables were not handled. Dynamic shapes were never sent, so the first execute failed with an error asking for SetVariableShapes, which a GEMSEO user cannot call. Discrete variables were left out of the grammars and silently kept their server-side defaults. Reject both at construction with a NotImplementedError naming the variables, until they are supported. Signed-off-by: Christopher Lupp --- philote_mdo/gemseo/philote_to_gemseo.py | 29 +++++++- tests/test_philote_to_gemseo.py | 95 +++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/philote_mdo/gemseo/philote_to_gemseo.py b/philote_mdo/gemseo/philote_to_gemseo.py index 7b643b8..189a93a 100644 --- a/philote_mdo/gemseo/philote_to_gemseo.py +++ b/philote_mdo/gemseo/philote_to_gemseo.py @@ -59,12 +59,15 @@ def __init__(self, channel, name="", **options): channel: The gRPC channel to the Philote discipline server, e.g. created with ``grpc.insecure_channel("localhost:50051")``. name: The name of the discipline. - If empty, use the name of the remote discipline class. + If empty, use the name reported by the server, + or the class name if the server reports none. **options: The discipline options to send to the server, if any. Raises: ValueError: When ``channel`` is empty or ``None``. + NotImplementedError: When the server declares dynamic-shape + or discrete variables, which are not supported yet. """ if not channel: msg = "No channel provided, the Philote client will not be able to connect." @@ -76,8 +79,10 @@ def __init__(self, channel, name="", **options): # generic Philote client self._client = pm.ExplicitClient(channel=channel) - # call the init function of the explicit component - super().__init__(name=name) + # call the init function of the explicit component; an empty name + # makes GEMSEO fall back to the class name + self._client.get_discipline_info() + super().__init__(name=name or self._client._name) self._client.send_stream_options() if options: @@ -150,7 +155,25 @@ def _initialize_grammars(self): from the remote discipline server by :meth:`~philote_mdo.general.discipline_client.DisciplineClient.get_variable_definitions`. It does not perform any RPC call itself. + + Raises: + NotImplementedError: When the server declares dynamic-shape or + discrete variables. Their shapes would have to be sent to + the server, and discrete variables would be missing from the + grammars and silently left at their server-side defaults. """ + unsupported = [ + f"{var.name} (dynamic shape)" + for var in self._client._var_meta + if var.dynamic_shape + ] + [f"{var.name} (discrete)" for var in self._client._discrete_var_meta] + if unsupported: + msg = ( + "PhiloteDiscipline does not support these server variables yet: " + + ", ".join(unsupported) + ) + raise NotImplementedError(msg) + input_names = [] output_names = [] for var in self._client._var_meta: diff --git a/tests/test_philote_to_gemseo.py b/tests/test_philote_to_gemseo.py index 72e9f77..f8419db 100644 --- a/tests/test_philote_to_gemseo.py +++ b/tests/test_philote_to_gemseo.py @@ -28,6 +28,29 @@ CHANNEL = "localhost:50051" +class DynamicShapeDiscipline(pmdo.ExplicitDiscipline): + """A discipline whose input and output shapes are set by the client.""" + + def setup(self): + self.add_input("x", dynamic_shape=True) + self.add_output("y", dynamic_shape=True) + + def compute(self, inputs, outputs): + outputs["y"] = 2.0 * inputs["x"] + + +class DiscreteInputDiscipline(pmdo.ExplicitDiscipline): + """A discipline with a discrete input next to a continuous one.""" + + def setup(self): + self.add_input("x", shape=(1,)) + self.add_discrete_input("mode", default="a") + self.add_output("y", shape=(1,)) + + def compute(self, inputs, outputs, discrete_inputs=None, discrete_outputs=None): + outputs["y"] = 2.0 * inputs["x"] + + class PhiloteToGEMSEOTests(unittest.TestCase): """ Integration tests for PhiloteDiscipline, which wraps a remote Philote @@ -104,6 +127,78 @@ def test_rosenbrock_with_options(self): self.assertEqual(out["f"][0], 0.0) + def _serve(self, discipline): + """ + Serves a Philote discipline for the duration of the test. + """ + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + pmdo.ExplicitServer(discipline=discipline).attach_to_server(server) + server.add_insecure_port(PORT) + server.start() + self.addCleanup(server.stop, 0) + + def test_name_defaults_to_server_reported_name(self): + """ + Without an explicit name, the discipline takes the name reported by + the server. + """ + paraboloid = Paraboloid() + paraboloid._name = "paraboloid" + self._serve(paraboloid) + + disc = PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + self.assertEqual(disc.name, "paraboloid") + + def test_name_falls_back_to_class_name(self): + """ + When the server reports no name, GEMSEO's class-name default is used. + """ + self._serve(Paraboloid()) + + disc = PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + self.assertEqual(disc.name, "PhiloteDiscipline") + + def test_explicit_name_overrides_server_name(self): + """ + A name passed to the constructor takes precedence over the server's. + """ + paraboloid = Paraboloid() + paraboloid._name = "paraboloid" + self._serve(paraboloid) + + disc = PhiloteDiscipline( + channel=grpc.insecure_channel(CHANNEL), name="remote_paraboloid" + ) + + self.assertEqual(disc.name, "remote_paraboloid") + + def test_dynamic_shape_variables_raise(self): + """ + Dynamic-shape server variables are rejected at construction, naming + the offending variables. + """ + self._serve(DynamicShapeDiscipline()) + + with self.assertRaises(NotImplementedError) as ctx: + PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + self.assertIn("x (dynamic shape)", str(ctx.exception)) + self.assertIn("y (dynamic shape)", str(ctx.exception)) + + def test_discrete_variables_raise(self): + """ + Discrete server variables are rejected at construction rather than + silently left at their server-side defaults. + """ + self._serve(DiscreteInputDiscipline()) + + with self.assertRaises(NotImplementedError) as ctx: + PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + self.assertIn("mode (discrete)", str(ctx.exception)) + if __name__ == "__main__": unittest.main(verbosity=2) From e8c7fb0d381bdda11df209f40c3bf96675e3d4c8 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Fri, 18 Sep 2026 21:14:45 -0400 Subject: [PATCH 14/21] Check Sellar MDA outputs against the canonical result test_sellar_mda_compute only asserted that obj and con1 were present, so any values passed. Evaluate at the canonical Sellar starting point (x = 1, z = [5, 2]) and compare against the canonical result there. Signed-off-by: Christopher Lupp --- tests/test_openmdao_to_gemseo.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_openmdao_to_gemseo.py b/tests/test_openmdao_to_gemseo.py index f6d3628..1d8e3bd 100644 --- a/tests/test_openmdao_to_gemseo.py +++ b/tests/test_openmdao_to_gemseo.py @@ -19,6 +19,7 @@ import unittest import grpc from numpy import array +from numpy.testing import assert_allclose import philote_mdo.general as pmdo import philote_mdo.openmdao as pmdo_om from philote_mdo.examples.sellar import SellarMDA @@ -65,12 +66,14 @@ def test_sellar_mda_compute(self): sellar_mda = PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) - out = sellar_mda.execute({"x": array([1.0]), "z": array([2.0, 3.0])}) + # canonical Sellar starting point + out = sellar_mda.execute({"x": array([1.0]), "z": array([5.0, 2.0])}) server.stop(0) - self.assertIn("obj", out) - self.assertIn("con1", out) + # canonical Sellar result at that point + assert_allclose(out["obj"], 28.58830817, rtol=1e-8) + assert_allclose(out["con1"], -22.42830237, rtol=1e-8) def test_sellar_mda_linearize(self): """ From 4f88d2d7321cb23b01ac1d2db2b3e0f893f2610b Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Fri, 18 Sep 2026 21:22:11 -0400 Subject: [PATCH 15/21] docs: list GEMSEO among the clients in the quickstart Signed-off-by: Christopher Lupp --- docs/docs/getting-started/quickstart.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/getting-started/quickstart.md b/docs/docs/getting-started/quickstart.md index aae3fb7..72fcc83 100644 --- a/docs/docs/getting-started/quickstart.md +++ b/docs/docs/getting-started/quickstart.md @@ -119,9 +119,9 @@ server.wait_for_termination() ## Calling the Discipline Using a Client -Now that a server is running, it can be queried using a client. Philote-Python offers a number of clients for this purpose, ranging from the general implementation to OpenMDAO and CSDL components. However, under the hood, the OpenMDAO and CSDL components use the general client implementation. +Now that a server is running, it can be queried using a client. Philote-Python offers a number of clients for this purpose, ranging from the general implementation to OpenMDAO and CSDL components and GEMSEO disciplines. However, under the hood, these all use the general client implementation. -This example will use the general explicit client implementation. Despite it being fully functional and able to run in scientific workflows, it probably is not a realistic MDO workflow. The [OpenMDAO clients](../openmdao/openmdao-clients.md) tutorial demonstrates calling Philote disciplines from the OpenMDAO framework. +This example will use the general explicit client implementation. Despite it being fully functional and able to run in scientific workflows, it probably is not a realistic MDO workflow. The [OpenMDAO clients](../openmdao/openmdao-clients.md) and [GEMSEO](../gemseo/gemseo.md) tutorials demonstrate calling Philote disciplines from the OpenMDAO and GEMSEO frameworks. First, the explicit client must be imported and initialized using a gRPC channel. From 3472f27f806a70ddd02772ffbc0873f301431a78 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Fri, 18 Sep 2026 21:24:52 -0400 Subject: [PATCH 16/21] Drop support for Python 3.9 Python 3.9 reached end of life in October 2025, and GEMSEO 6.3 requires 3.10, so the 3.9 CI job resolved GEMSEO 6.2 while every other job tested 6.3. Require Python >=3.10, remove 3.9 from the CI matrix and the classifiers, and update the agent guidelines and the landing page. Signed-off-by: Christopher Lupp --- .github/workflows/tests.yaml | 2 +- CHANGELOG.md | 5 +++++ CLAUDE.md | 4 ++-- docs/src/pages/index.tsx | 2 +- pyproject.toml | 3 +-- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 60e67b9..ee30bb8 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@main diff --git a/CHANGELOG.md b/CHANGELOG.md index 484dd98..51639ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Documentation & Infrastructure +- Dropped support for Python 3.9, which reached end of life in October + 2025. `requires-python` is now `>=3.10`, and CI no longer tests 3.9. + GEMSEO 6.3 requires Python 3.10, so the 3.9 job was the only one + testing an older GEMSEO (6.2); the latest `grpcio`, `protobuf` and + OpenMDAO also require 3.10 or later. - Updated the documentation site's dependencies to clear known npm advisories, taking the audit from 42 findings (2 critical, 26 high) to 17 (all high). `npm audit fix` resolved the `webpack-dev-server`, `sockjs`, diff --git a/CLAUDE.md b/CLAUDE.md index f219efe..8ffd17e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ Philote-Python is the reference Python implementation of the Philote-MDO standar ## Build System ### Requirements -- Python 3.9 - 3.12 +- Python 3.10 - 3.12 - `numpy`, `scipy`, `grpcio`, `protobuf` (installed automatically) - `openmdao` (only for the OpenMDAO bindings and integration tests) @@ -51,7 +51,7 @@ docs/ # Docusaurus documentation site (see docs/README.md) ## CI/CD ### Tests workflow (`.github/workflows/tests.yaml`) -Runs `pytest` against Python 3.9 / 3.10 / 3.11 / 3.12 on every push to `main`, `develop`, `release/*`, `support/*`, and on PRs into `main` / `develop`. +Runs `pytest` against Python 3.10 / 3.11 / 3.12 on every push to `main`, `develop`, `release/*`, `support/*`, and on PRs into `main` / `develop`. ### Documentation workflow (`.github/workflows/documentation.yaml`) Builds the Docusaurus site under `docs/` and deploys it to GitHub Pages whenever `docs/**` changes on `develop`. Uses the GitHub Pages artifact pipeline (`actions/configure-pages` + `upload-pages-artifact` + `deploy-pages`). diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 9f46318..6521bb8 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -360,7 +360,7 @@ export default function Home(): ReactNode {
- + diff --git a/pyproject.toml b/pyproject.toml index cf4e65e..7ece48f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,12 +11,11 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ "grpcio~=1.66.0", "protobuf~=5.29.0", From 9b479b531d0bc3645ae3ea0e620f3c55d72c88bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Gallard?= Date: Sat, 19 Sep 2026 14:30:23 +0200 Subject: [PATCH 17/21] feat(gemseo): support discrete variables in PhiloteDiscipline PhiloteDiscipline rejected any server that declared discrete variables. They are now part of the GEMSEO grammars next to the continuous ones, bound to no type, since a Philote discrete variable carries a google.protobuf.Value and may hold any JSON-compatible value. Discrete inputs are split off the GEMSEO input data and sent on their own side of the wire protocol, for both ComputeFunction and ComputeGradient, and the discrete outputs returned by the server are merged back into the output data. Discrete variables are excluded from the Jacobian. GEMSEO already filters non-continuous variables out when the differentiated variables are chosen with add_differentiated_inputs/outputs, but not when the whole Jacobian is requested, so _get_differentiated_io is overridden to do it there too. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 5 + philote_mdo/gemseo/philote_to_gemseo.py | 130 +++++++++++++++++++++--- tests/test_philote_to_gemseo.py | 91 ++++++++++++++--- 3 files changed, 201 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51639ac..70caeb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a GEMSEO interface (`philote_mdo.gemseo`) allowing GEMSEO disciplines to be called from Philote-MDO, and Philote-MDO disciplines (local or remote, e.g. served from OpenMDAO) to be called from GEMSEO. + The discrete variables of a remote Philote discipline are part of the + GEMSEO grammars next to the continuous ones, bound to no type since a + discrete variable may carry any JSON-compatible value, and are excluded + from the Jacobian, including when it is requested in full with + `compute_all_jacobians=True`. - Added examples and tutorials demonstrating interoperability between GEMSEO, OpenMDAO and OpenAeroStruct through Philote-MDO. diff --git a/philote_mdo/gemseo/philote_to_gemseo.py b/philote_mdo/gemseo/philote_to_gemseo.py index 189a93a..7585980 100644 --- a/philote_mdo/gemseo/philote_to_gemseo.py +++ b/philote_mdo/gemseo/philote_to_gemseo.py @@ -20,6 +20,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from typing import Any from typing import ClassVar from gemseo.core.discipline.discipline import Discipline @@ -31,6 +32,7 @@ if TYPE_CHECKING: # pragma: no cover from collections.abc import Iterable + from collections.abc import Mapping class PhiloteDiscipline(Discipline): @@ -47,6 +49,14 @@ class PhiloteDiscipline(Discipline): Executing the discipline (:meth:`.execute`) and linearizing it (:meth:`.linearize`) transparently call the remote server to compute the outputs and the Jacobian, respectively. + + The discrete variables of the remote discipline are part of the + grammars, next to the continuous ones, and are read from and written to + the local data like any other variable. Since a Philote discrete + variable may carry any JSON-compatible value, its grammar element is + bound to no type. Discrete variables are never differentiated: they are + excluded from the Jacobian, including when it is requested in full with + ``compute_all_jacobians=True``. """ default_grammar_type: ClassVar[GrammarType] = GrammarType.SIMPLE @@ -67,15 +77,21 @@ def __init__(self, channel, name="", **options): Raises: ValueError: When ``channel`` is empty or ``None``. NotImplementedError: When the server declares dynamic-shape - or discrete variables, which are not supported yet. + variables, which are not supported yet. """ if not channel: msg = "No channel provided, the Philote client will not be able to connect." raise ValueError(msg) - # The shape of each input and output variable, indexed by name; - # filled in by _initialize_grammars() and used by _compute_jacobian() - # to reshape the flattened partial derivatives sent by the server. + # The shape of each continuous input and output variable, indexed by + # name; filled in by _initialize_grammars() and used by + # _compute_jacobian() to reshape the flattened partial derivatives + # sent by the server. self._shapes = {} + # The names of the discrete input and output variables, filled in by + # _initialize_grammars(); discrete inputs travel on their own side of + # the wire protocol and so must be split off the GEMSEO input data. + self._discrete_input_names = set() + self._discrete_output_names = set() # generic Philote client self._client = pm.ExplicitClient(channel=channel) @@ -99,7 +115,9 @@ def _run(self, input_data: dict) -> dict: This sends the input values to the remote Philote discipline server through the ``ComputeFunction`` RPC and returns the resulting output - values. + values. Continuous and discrete inputs are sent separately, and the + discrete outputs returned by the server, if any, are merged back into + the output data. Args: input_data: The input data, without namespace prefixes. @@ -107,7 +125,37 @@ def _run(self, input_data: dict) -> dict: Returns: The output data computed by the remote discipline. """ - return self._client.run_compute(input_data) + inputs, discrete_inputs = self._split_input_data(input_data) + outputs = self._client.run_compute(inputs, discrete_inputs=discrete_inputs) + # run_compute returns (outputs, discrete_outputs) when the server + # sends back discrete output data, and a plain dictionary otherwise. + if isinstance(outputs, tuple): + outputs, discrete_outputs = outputs + outputs.update(discrete_outputs) + return outputs + + def _split_input_data(self, input_data: Mapping[str, Any]) -> tuple[dict, dict]: + """Split the GEMSEO input data into continuous and discrete inputs. + + The GEMSEO input grammar holds the continuous and the discrete inputs + side by side, while the Philote wire protocol carries them in two + distinct kinds of message. + + Args: + input_data: The input data, without namespace prefixes. + + Returns: + The continuous input values and the discrete input values, + both indexed by variable name. + """ + inputs = {} + discrete_inputs = {} + for name, value in input_data.items(): + if name in self._discrete_input_names: + discrete_inputs[name] = value + else: + inputs[name] = value + return inputs, discrete_inputs def _compute_jacobian( self, @@ -136,7 +184,10 @@ def _compute_jacobian( output_names: The names of the outputs to be differentiated. If empty, use all the outputs. """ - jac_flat = self._client.run_compute_partials(self.get_input_data()) + inputs, discrete_inputs = self._split_input_data(self.get_input_data()) + jac_flat = self._client.run_compute_partials( + inputs, discrete_inputs=discrete_inputs + ) for jac_key, jac_val in jac_flat.items(): out_name, in_name = jac_key out_size = int(prod(self._shapes[out_name])) @@ -147,6 +198,43 @@ def _compute_jacobian( else: self.jac[out_name][in_name] = jac_loc + def _get_differentiated_io( + self, + compute_all_jacobians: bool = False, + ) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Return the inputs and outputs used in the differentiation. + + The discrete variables are filtered out, as they cannot be + differentiated. GEMSEO already does this when the differentiated + variables are selected with :meth:`.add_differentiated_inputs` and + :meth:`.add_differentiated_outputs`, but not when the whole Jacobian + is requested. + + Args: + compute_all_jacobians: Whether to compute the Jacobians of all the + outputs with respect to all the inputs. + + Returns: + The names of the differentiated inputs + and the names of the differentiated outputs. + """ + input_names, output_names = super()._get_differentiated_io( + compute_all_jacobians + ) + if not compute_all_jacobians: + return input_names, output_names + + return ( + tuple( + name for name in input_names if name not in self._discrete_input_names + ), + tuple( + name + for name in output_names + if name not in self._discrete_output_names + ), + ) + def _initialize_grammars(self): """Set up the GEMSEO discipline input and output grammars. @@ -156,17 +244,19 @@ def _initialize_grammars(self): :meth:`~philote_mdo.general.discipline_client.DisciplineClient.get_variable_definitions`. It does not perform any RPC call itself. + Continuous variables are bound to :class:`~numpy.ndarray`, while + discrete variables are bound to no type at all, since a Philote + discrete variable may hold any JSON-compatible value. + Raises: - NotImplementedError: When the server declares dynamic-shape or - discrete variables. Their shapes would have to be sent to - the server, and discrete variables would be missing from the - grammars and silently left at their server-side defaults. + NotImplementedError: When the server declares dynamic-shape + variables, whose shapes would have to be sent to the server. """ unsupported = [ f"{var.name} (dynamic shape)" for var in self._client._var_meta if var.dynamic_shape - ] + [f"{var.name} (discrete)" for var in self._client._discrete_var_meta] + ] if unsupported: msg = ( "PhiloteDiscipline does not support these server variables yet: " @@ -184,5 +274,21 @@ def _initialize_grammars(self): if var.type == data.kOutput: output_names.append(var.name) self._shapes[var.name] = tuple(var.shape) + + for var in self._client._discrete_var_meta: + if var.type == data.kDiscreteInput: + self._discrete_input_names.add(var.name) + + if var.type == data.kDiscreteOutput: + self._discrete_output_names.add(var.name) + self.input_grammar.update_from_names(input_names) self.output_grammar.update_from_names(output_names) + # A None type means that the grammar accepts any value for that name, + # which is what a Philote discrete variable may carry. + self.input_grammar.update_from_types( + dict.fromkeys(self._discrete_input_names) + ) + self.output_grammar.update_from_types( + dict.fromkeys(self._discrete_output_names) + ) diff --git a/tests/test_philote_to_gemseo.py b/tests/test_philote_to_gemseo.py index f8419db..7f14a9c 100644 --- a/tests/test_philote_to_gemseo.py +++ b/tests/test_philote_to_gemseo.py @@ -19,6 +19,9 @@ import unittest import grpc from numpy import array +from numpy import eye +from numpy import ndarray +from numpy.testing import assert_allclose import philote_mdo.general as pmdo from philote_mdo.examples import Paraboloid from philote_mdo.examples import Rosenbrock @@ -39,16 +42,29 @@ def compute(self, inputs, outputs): outputs["y"] = 2.0 * inputs["x"] -class DiscreteInputDiscipline(pmdo.ExplicitDiscipline): - """A discipline with a discrete input next to a continuous one.""" +class DiscreteDiscipline(pmdo.ExplicitDiscipline): + """A discipline with a discrete input and a discrete output. + + The discrete input ``factor`` scales the continuous output ``y``, so + that both the outputs and the Jacobian depend on it, and the discrete + output ``tags`` echoes a non-scalar discrete value back to the client. + """ def setup(self): - self.add_input("x", shape=(1,)) - self.add_discrete_input("mode", default="a") - self.add_output("y", shape=(1,)) + self.add_input("x", shape=(2,)) + self.add_discrete_input("factor", default=2.0) + self.add_output("y", shape=(2,)) + self.add_discrete_output("tags") + + def setup_partials(self): + self.declare_partials("y", "x") def compute(self, inputs, outputs, discrete_inputs=None, discrete_outputs=None): - outputs["y"] = 2.0 * inputs["x"] + outputs["y"] = discrete_inputs["factor"] * inputs["x"] + discrete_outputs["tags"] = ["scaled", discrete_inputs["factor"]] + + def compute_partials(self, inputs, partials, discrete_inputs=None): + partials["y", "x"] = discrete_inputs["factor"] * eye(2) class PhiloteToGEMSEOTests(unittest.TestCase): @@ -187,17 +203,66 @@ def test_dynamic_shape_variables_raise(self): self.assertIn("x (dynamic shape)", str(ctx.exception)) self.assertIn("y (dynamic shape)", str(ctx.exception)) - def test_discrete_variables_raise(self): + def test_discrete_variables_in_grammars(self): """ - Discrete server variables are rejected at construction rather than - silently left at their server-side defaults. + Discrete server variables are added to the grammars next to the + continuous ones, and bound to no type, since they may carry any + JSON-compatible value. """ - self._serve(DiscreteInputDiscipline()) + self._serve(DiscreteDiscipline()) - with self.assertRaises(NotImplementedError) as ctx: - PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + disc = PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + self.assertEqual({n: disc.input_grammar[n] for n in disc.input_grammar}, + {"x": ndarray, "factor": None}) + self.assertEqual({n: disc.output_grammar[n] for n in disc.output_grammar}, + {"y": ndarray, "tags": None}) + + def test_discrete_variables_compute(self): + """ + Discrete inputs are sent to the server and discrete outputs are + returned in the local data, next to the continuous ones. + """ + self._serve(DiscreteDiscipline()) + + disc = PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + out = disc.execute({"x": array([1.0, 2.0]), "factor": 3.0}) + + assert_allclose(out["y"], array([3.0, 6.0])) + self.assertEqual(out["tags"], ["scaled", 3.0]) + + def test_discrete_input_changes_output(self): + """ + A different discrete input value yields a different output, which + shows the value is actually sent rather than left at the + server-side default. + """ + self._serve(DiscreteDiscipline()) + + disc = PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + out = disc.execute({"x": array([1.0, 2.0]), "factor": 10.0}) + + assert_allclose(out["y"], array([10.0, 20.0])) + + def test_discrete_variables_linearize(self): + """ + Discrete variables are excluded from the full Jacobian, and the + discrete inputs are sent along with the continuous ones so that the + server can use them to compute the partials. + """ + self._serve(DiscreteDiscipline()) + + disc = PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + jac = disc.linearize( + {"x": array([1.0, 2.0]), "factor": 3.0}, compute_all_jacobians=True + ) - self.assertIn("mode (discrete)", str(ctx.exception)) + self.assertEqual(list(jac), ["y"]) + self.assertEqual(list(jac["y"]), ["x"]) + assert_allclose(jac["y"]["x"], 3.0 * eye(2)) if __name__ == "__main__": From 6e9dd57af8b0e8e97a6d74271637ec2cb405f6a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Gallard?= Date: Sat, 19 Sep 2026 14:41:45 +0200 Subject: [PATCH 18/21] feat(gemseo): serve discrete variables from GEMSEOtoPhiloteDiscipline Every variable of the wrapped GEMSEO discipline was served as a flat continuous Philote variable. The names of the input and output grammars are now classified with their data converter's is_numeric(): a numeric name is declared as a continuous Philote variable, any other name as a discrete one, which carries any JSON-compatible value. The discrete inputs sent by the client are merged back into the GEMSEO input data before execute() and linearize(), and the discrete outputs of the wrapped discipline are streamed back to the client. Only the variables holding continuous data take part in the Jacobian, so that no partial derivative is declared that compute_partials() would then leave at zero. This also drops the integer-valued variables, which are numeric but not differentiable. compute_partials() therefore selects the differentiated variables with add_differentiated_inputs/outputs, which apply that same filter, rather than asking for the whole Jacobian. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 13 +- philote_mdo/gemseo/gemseo_to_philote.py | 192 +++++++++++++++++++----- tests/test_gemseo_to_philote.py | 131 ++++++++++++++++ 3 files changed, 296 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70caeb9..8cab782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,11 +57,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a GEMSEO interface (`philote_mdo.gemseo`) allowing GEMSEO disciplines to be called from Philote-MDO, and Philote-MDO disciplines (local or remote, e.g. served from OpenMDAO) to be called from GEMSEO. - The discrete variables of a remote Philote discipline are part of the - GEMSEO grammars next to the continuous ones, bound to no type since a - discrete variable may carry any JSON-compatible value, and are excluded - from the Jacobian, including when it is requested in full with - `compute_all_jacobians=True`. + Discrete variables are carried in both directions. The discrete + variables of a remote Philote discipline are part of the GEMSEO + grammars next to the continuous ones, bound to no type since a discrete + variable may carry any JSON-compatible value. Conversely, a variable + of a wrapped GEMSEO discipline is served as a discrete Philote variable + when its grammar's data converter does not report it as numeric. + Discrete variables are excluded from the Jacobian, including when it is + requested in full with `compute_all_jacobians=True`. - Added examples and tutorials demonstrating interoperability between GEMSEO, OpenMDAO and OpenAeroStruct through Philote-MDO. diff --git a/philote_mdo/gemseo/gemseo_to_philote.py b/philote_mdo/gemseo/gemseo_to_philote.py index d670e39..4694eca 100644 --- a/philote_mdo/gemseo/gemseo_to_philote.py +++ b/philote_mdo/gemseo/gemseo_to_philote.py @@ -20,6 +20,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from typing import Any from gemseo.core.serializable import GoogleDocstringInheritanceMeta from numpy import ndarray @@ -28,7 +29,10 @@ from philote_mdo.general.explicit_discipline import ExplicitDiscipline if TYPE_CHECKING: # pragma: no cover + from collections.abc import Mapping + from gemseo.core.discipline.discipline import Discipline + from gemseo.core.grammars.base_grammar import BaseGrammar class GEMSEOtoPhiloteDiscipline( @@ -48,11 +52,19 @@ class GEMSEOtoPhiloteDiscipline( ) server.attach_to_server(grpc_server) - All the input and output variables of the wrapped discipline are - exposed as flat (1D) continuous Philote variables, and the Jacobian of - every output with respect to every input is made available, computed - on demand using GEMSEO's own differentiation capabilities - (see :meth:`~gemseo.core.discipline.discipline.Discipline.linearize`). + The input and output variables of the wrapped discipline that hold + numeric data, as reported by the ``is_numeric`` method of their + grammar's data converter, are exposed as flat (1D) continuous Philote + variables. Every other variable is exposed as a Philote discrete + variable, which carries any JSON-compatible value. + + The Jacobian of every continuous output with respect to every + continuous input is made available, computed on demand using GEMSEO's + own differentiation capabilities (see + :meth:`~gemseo.core.discipline.discipline.Discipline.linearize`). The + variables that do not hold continuous data are left out of the + Jacobian: the discrete ones, and also the integer-valued ones, which + are numeric but not differentiable. """ gemseo_discipline: Discipline @@ -74,32 +86,89 @@ def __init__(self, gemseo_discipline: Discipline, default_data_size: int = 1): super().__init__() self.gemseo_discipline = gemseo_discipline self.default_data_size = default_data_size + # The names of the variables of the wrapped discipline, split by + # setup() into the ones Philote carries as continuous arrays and the + # ones it carries as discrete values. + self._input_names = () + self._output_names = () + self._discrete_input_names = () + self._discrete_output_names = () def setup(self): """Declare the Philote inputs and outputs from the GEMSEO grammars. Every name of the wrapped GEMSEO discipline's input and output - grammars is declared as a flat (1D) continuous Philote variable. - Its size is taken from the corresponding default value in + grammars that holds numeric data is declared as a flat (1D) + continuous Philote variable. Its size is taken from the + corresponding default value in :attr:`~gemseo.core.discipline.discipline.Discipline.default_input_data` or :attr:`~gemseo.core.discipline.discipline.Discipline.default_output_data` when it is a :class:`~numpy.ndarray`, and from :attr:`.default_data_size` otherwise. + + Every other name is declared as a discrete Philote variable, with + the default value of the wrapped discipline, if any. """ disc = self.gemseo_discipline - for input_name in disc.input_grammar.names: - data = disc.default_input_data.get(input_name) - size = self.default_data_size - if isinstance(data, ndarray): - size = data.size - self.add_input(input_name, shape=(size,)) - for output_name in disc.output_grammar.names: - data = disc.default_output_data.get(output_name) - size = self.default_data_size - if isinstance(data, ndarray): - size = data.size - self.add_output(output_name, shape=(size,)) + self._input_names, self._discrete_input_names = self._split_names( + disc.input_grammar + ) + self._output_names, self._discrete_output_names = self._split_names( + disc.output_grammar + ) + + defaults = disc.default_input_data + for input_name in self._input_names: + self.add_input(input_name, shape=(self._get_size(defaults, input_name),)) + for input_name in self._discrete_input_names: + self.add_discrete_input(input_name, default=defaults.get(input_name)) + + defaults = disc.default_output_data + for output_name in self._output_names: + self.add_output(output_name, shape=(self._get_size(defaults, output_name),)) + for output_name in self._discrete_output_names: + self.add_discrete_output(output_name, default=defaults.get(output_name)) + + @staticmethod + def _split_names(grammar: BaseGrammar) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Split the names of a grammar into numeric and non-numeric ones. + + Args: + grammar: The input or output grammar of the wrapped discipline. + + Returns: + The names bound to numeric data, + which Philote carries as continuous variables, + and the other names, + which Philote carries as discrete variables. + """ + is_numeric = grammar.data_converter.is_numeric + numeric_names = [] + discrete_names = [] + for name in grammar.names: + if is_numeric(name): + numeric_names.append(name) + else: + discrete_names.append(name) + return tuple(numeric_names), tuple(discrete_names) + + def _get_size(self, defaults: Mapping[str, Any], name: str) -> int: + """Return the size to declare for a continuous Philote variable. + + Args: + defaults: The default input or output data of the wrapped + discipline. + name: The name of the variable. + + Returns: + The size of the default value when it is a NumPy array, + and :attr:`.default_data_size` otherwise. + """ + value = defaults.get(name) + if isinstance(value, ndarray): + return value.size + return self.default_data_size def setup_partials(self): """Declare the Jacobian of every output with respect to every input. @@ -107,45 +176,82 @@ def setup_partials(self): No sparsity pattern is assumed: all the input-output pairs of the wrapped GEMSEO discipline are declared as partial derivatives to be computed by :meth:`.compute_partials`. + + Only the variables that hold continuous data take part in the + Jacobian, so that no partial derivative is declared that + :meth:`.compute_partials` would then leave at zero. """ - for output_name in self.gemseo_discipline.output_grammar.names: - for input_name in self.gemseo_discipline.input_grammar.names: - self.declare_partials(output_name, input_name) + disc = self.gemseo_discipline + is_continuous_input = disc.input_grammar.data_converter.is_continuous + is_continuous_output = disc.output_grammar.data_converter.is_continuous + for output_name in self._output_names: + if not is_continuous_output(output_name): + continue + for input_name in self._input_names: + if is_continuous_input(input_name): + self.declare_partials(output_name, input_name) - def compute(self, inputs, outputs): + def compute(self, inputs, outputs, discrete_inputs=None, discrete_outputs=None): """Execute the wrapped GEMSEO discipline. Args: - inputs: The input values, indexed by input name. - outputs: The dictionary to be filled with the output values - computed by the wrapped GEMSEO discipline, indexed by + inputs: The continuous input values, indexed by input name. + outputs: The dictionary to be filled with the continuous output + values computed by the wrapped GEMSEO discipline, indexed by output name. + discrete_inputs: The discrete input values, indexed by input + name, if any. + discrete_outputs: The dictionary to be filled with the discrete + output values computed by the wrapped GEMSEO discipline, + indexed by output name, if the discipline has any. """ - out = self.gemseo_discipline.execute(inputs) - outputs.update({k: out[k] for k in self.gemseo_discipline.output_grammar.names}) + out = self.gemseo_discipline.execute( + self._merge_inputs(inputs, discrete_inputs) + ) + for output_name in self._output_names: + outputs[output_name] = out[output_name] - def compute_partials(self, inputs, partials): + if discrete_outputs is not None: + for output_name in self._discrete_output_names: + discrete_outputs[output_name] = out[output_name] + + def compute_partials(self, inputs, partials, discrete_inputs=None): """Linearize the wrapped GEMSEO discipline. + The discrete inputs are passed to the wrapped discipline along with + the continuous ones, so that it can use them to compute the + Jacobian, but the discipline is not differentiated with respect to + them. + Args: - inputs: The input values, indexed by input name. + inputs: The continuous input values, indexed by input name. partials: The dictionary to be filled with the Jacobian matrices computed by the wrapped GEMSEO discipline, indexed by ``(output_name, input_name)`` pairs. + discrete_inputs: The discrete input values, indexed by input + name, if any. """ - jac = self.gemseo_discipline.linearize(inputs, compute_all_jacobians=True) - output_names = self.gemseo_discipline.output_grammar.names - input_names = self.gemseo_discipline.input_grammar.names + if not self._partials_meta: + # Every variable is discrete or integer-valued, so setup_partials() + # declared nothing to differentiate. + return + + disc = self.gemseo_discipline + # Both of these filter out the names that do not hold continuous + # data, which leaves exactly the subset setup_partials() declared. + disc.add_differentiated_inputs(self._input_names) + disc.add_differentiated_outputs(self._output_names) + jac = disc.linearize(self._merge_inputs(inputs, discrete_inputs)) for out_k, jac_in in jac.items(): - if out_k not in output_names: + if out_k not in self._output_names: # Some GEMSEO disciplines (e.g. the Sellar ones) populate # their Jacobian with extra entries beyond their own # declared outputs; only forward what setup_partials() # actually declared to the Philote-MDO client. continue for in_k, jac_loc in jac_in.items(): - if in_k not in input_names: + if in_k not in self._input_names: continue # The Philote-MDO wire protocol only transfers dense arrays, # while a GEMSEO discipline may return a sparse Jacobian @@ -153,3 +259,19 @@ def compute_partials(self, inputs, partials): if issparse(jac_loc): jac_loc = jac_loc.toarray() partials[out_k, in_k] = jac_loc + + @staticmethod + def _merge_inputs(inputs, discrete_inputs): + """Merge the continuous and the discrete inputs into GEMSEO input data. + + Args: + inputs: The continuous input values, indexed by input name. + discrete_inputs: The discrete input values, indexed by input + name, if any. + + Returns: + The input data of the wrapped GEMSEO discipline. + """ + if not discrete_inputs: + return inputs + return {**inputs, **discrete_inputs} diff --git a/tests/test_gemseo_to_philote.py b/tests/test_gemseo_to_philote.py index cca321c..7e5888e 100644 --- a/tests/test_gemseo_to_philote.py +++ b/tests/test_gemseo_to_philote.py @@ -20,8 +20,12 @@ from unittest.mock import patch import grpc from numpy import array +from numpy import eye +from numpy import ndarray from numpy.testing import assert_allclose from scipy.sparse import issparse +from gemseo.core.discipline.discipline import Discipline +from gemseo.core.grammars.factory import GrammarType from gemseo.problems.mdo.sellar.sellar_1 import Sellar1 import philote_mdo.general as pmdo from philote_mdo.gemseo import GEMSEOtoPhiloteDiscipline @@ -31,6 +35,46 @@ CHANNEL = "localhost:50051" +class ScalingDiscipline(Discipline): + """A GEMSEO discipline with a string input and a string output. + + The string input ``mode`` scales the array output ``y``, so that both + the outputs and the Jacobian depend on it, and the string output + ``used_mode`` echoes it back. + """ + + default_grammar_type = GrammarType.SIMPLE + + def __init__(self): + super().__init__() + self.input_grammar.update_from_names(["x"]) + self.input_grammar.update_from_types({"mode": str}) + self.output_grammar.update_from_names(["y"]) + self.output_grammar.update_from_types({"used_mode": str}) + self.default_input_data = {"x": array([1.0, 1.0]), "mode": "double"} + self.default_output_data = {"y": array([0.0, 0.0]), "used_mode": "double"} + + @staticmethod + def _get_factor(mode): + """Return the factor scaling the output. + + Args: + mode: The value of the string input. + + Returns: + The factor by which the input array is scaled. + """ + return 2.0 if mode == "double" else 3.0 + + def _run(self, input_data): + factor = self._get_factor(input_data["mode"]) + return {"y": factor * input_data["x"], "used_mode": input_data["mode"]} + + def _compute_jacobian(self, input_names=(), output_names=()): + self._init_jacobian(input_names, output_names) + self.jac["y"]["x"] = self._get_factor(self.io.data["mode"]) * eye(2) + + class GEMSEOToPhiloteTests(unittest.TestCase): """ Integration tests for GEMSEOtoPhiloteDiscipline, which wraps a GEMSEO @@ -141,5 +185,92 @@ def test_compute_partials_skips_output_not_in_grammar(self): self.assertNotIn(("not_a_declared_output", "x_1"), partials) +class DiscreteVariableTests(unittest.TestCase): + """ + Tests for the handling of the variables of a wrapped GEMSEO discipline + that do not hold numeric data, which are exposed as Philote discrete + variables. + """ + + def test_setup_splits_numeric_and_discrete_variables(self): + """ + The variables whose grammar data converter reports them as numeric + are declared as continuous Philote variables, and the others as + discrete ones. + """ + wrapper = GEMSEOtoPhiloteDiscipline(ScalingDiscipline()) + wrapper.setup() + + self.assertEqual([m.name for m in wrapper._var_meta], ["x", "y"]) + self.assertEqual( + [m.name for m in wrapper._discrete_var_meta], ["mode", "used_mode"] + ) + + def test_setup_partials_skips_discrete_variables(self): + """ + Only the variables holding continuous data take part in the + Jacobian, so no partial derivative is declared for a discrete one. + """ + wrapper = GEMSEOtoPhiloteDiscipline(ScalingDiscipline()) + wrapper.setup() + wrapper.setup_partials() + + self.assertEqual( + [(m.name, m.subname) for m in wrapper._partials_meta], [("y", "x")] + ) + + def test_discrete_variables_round_trip(self): + """ + A discrete input travels to the wrapped GEMSEO discipline and a + discrete output travels back, through a remote PhiloteDiscipline. + """ + remote = self._serve_scaling_discipline() + + self.assertEqual( + {n: remote.input_grammar[n] for n in remote.input_grammar}, + {"x": ndarray, "mode": None}, + ) + self.assertEqual( + {n: remote.output_grammar[n] for n in remote.output_grammar}, + {"y": ndarray, "used_mode": None}, + ) + + out = remote.execute({"x": array([1.0, 2.0]), "mode": "triple"}) + + assert_allclose(out["y"], array([3.0, 6.0])) + self.assertEqual(out["used_mode"], "triple") + + def test_discrete_input_used_by_compute_partials(self): + """ + The discrete inputs are passed to the wrapped discipline when it is + linearized, and are themselves left out of the Jacobian. + """ + remote = self._serve_scaling_discipline() + + jac = remote.linearize( + {"x": array([1.0, 2.0]), "mode": "triple"}, compute_all_jacobians=True + ) + + self.assertEqual(list(jac), ["y"]) + self.assertEqual(list(jac["y"]), ["x"]) + assert_allclose(jac["y"]["x"], 3.0 * eye(2)) + + def _serve_scaling_discipline(self): + """ + Serves a ScalingDiscipline for the duration of the test. + + Returns: + A PhiloteDiscipline client connected to the served discipline. + """ + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + pmdo.ExplicitServer( + discipline=GEMSEOtoPhiloteDiscipline(ScalingDiscipline()) + ).attach_to_server(server) + server.add_insecure_port(PORT) + server.start() + self.addCleanup(server.stop, 0) + return PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + + if __name__ == "__main__": unittest.main(verbosity=2) From dd1cdd890d70a88b9a7145eb717877ec6470cc2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Gallard?= Date: Sat, 19 Sep 2026 14:41:51 +0200 Subject: [PATCH 19/21] docs(gemseo): document discrete variable support Add a "Discrete variables" section to the GEMSEO page covering both directions: how GEMSEOtoPhiloteDiscipline classifies a grammar name with its data converter's is_numeric(), and how PhiloteDiscipline puts the server's discrete variables in the GEMSEO grammars. Note that they are never differentiated, and that Philote-MDO does not transfer their default values. Co-Authored-By: Claude Opus 5 --- docs/docs/gemseo/gemseo.md | 70 +++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/docs/docs/gemseo/gemseo.md b/docs/docs/gemseo/gemseo.md index 112d865..58ab588 100644 --- a/docs/docs/gemseo/gemseo.md +++ b/docs/docs/gemseo/gemseo.md @@ -50,7 +50,9 @@ flowchart LR Both directions carry the discipline's Jacobian, not just its output values, so a remote discipline can be used in a gradient-based -optimization exactly like a local one. +optimization exactly like a local one. Both also carry the discipline's +discrete variables next to its continuous ones -- see +[Discrete variables](#discrete-variables) below. This page walks through the first, and simplest, direction -- `PhiloteDiscipline` consuming a remote discipline -- using the complete @@ -250,6 +252,72 @@ See the actual, always up-to-date source at [`examples/paraboloid_gemseo.py`](https://github.com/MDO-Standards/Philote-Python/blob/main/examples/paraboloid_gemseo.py) in the repository. +## Discrete variables + +Philote-MDO distinguishes **continuous** variables, which travel over the +wire as arrays of doubles, from **discrete** variables, which carry any +JSON-compatible value: a string, a boolean, an integer, a list, or a +nested structure. `philote_mdo.gemseo` supports them in both directions, +and nothing has to be configured to enable it. + +### Serving a GEMSEO discipline that has discrete variables + +`GEMSEOtoPhiloteDiscipline` classifies every name of the wrapped +discipline's grammars by asking the grammar's data converter whether that +name holds numeric data: + +```python +discipline.input_grammar.data_converter.is_numeric(name) +``` + +A name reported as numeric is declared as a continuous Philote variable; +every other name is declared as a discrete one. A grammar element typed +`str`, `bool`, `dict` or `list` is therefore served as a discrete +variable automatically: + +```python +class ScalingDiscipline(Discipline): + default_grammar_type = GrammarType.SIMPLE + + def __init__(self): + super().__init__() + self.input_grammar.update_from_names(["x"]) # continuous + self.input_grammar.update_from_types({"mode": str}) # discrete + self.output_grammar.update_from_names(["y"]) # continuous + self.output_grammar.update_from_types({"used_mode": str}) # discrete +``` + +### Consuming a remote discipline that has discrete variables + +`PhiloteDiscipline` puts the server's discrete variables in its GEMSEO +grammars next to the continuous ones, bound to no type, since a discrete +variable may carry any value. They are then read and written like any +other variable: + +```python +remote = PhiloteDiscipline(channel=grpc.insecure_channel("localhost:50051")) + +out = remote.execute({"x": array([1.0, 2.0]), "mode": "triple"}) + +print(out["y"], out["used_mode"]) +``` + +:::note +Discrete variables are never differentiated: no partial derivative is +declared for them, and they are excluded from the Jacobian, including +when it is requested in full with `compute_all_jacobians=True`. + +Integer-valued variables are numeric, so they travel as continuous +Philote variables, but they are not differentiable either and are +likewise left out of the Jacobian. +::: + +:::warning +Philote-MDO does not transfer default values, so a discrete input has no +default on the client side. It must be part of the input data passed to +`execute()`, just like a continuous one. +::: + ## Where to go next - [Coupling OpenAeroStruct and GEMSEO](./gemseo-openaerostruct.md) applies From 56adcf2891485ddfc55c11957185577a296f1cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Gallard?= Date: Sat, 19 Sep 2026 14:43:24 +0200 Subject: [PATCH 20/21] test(gemseo): cover the non-differentiable branches of the wrapper Two branches of GEMSEOtoPhiloteDiscipline were unreached: skipping an integer-valued output in setup_partials(), and the early return of compute_partials() when nothing was declared to differentiate. Add a discipline with integer variables and one whose variables are all non-numeric, which brings philote_mdo.gemseo to 100% statement and branch coverage. Co-Authored-By: Claude Opus 5 --- tests/test_gemseo_to_philote.py | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/test_gemseo_to_philote.py b/tests/test_gemseo_to_philote.py index 7e5888e..616bd2d 100644 --- a/tests/test_gemseo_to_philote.py +++ b/tests/test_gemseo_to_philote.py @@ -75,6 +75,42 @@ def _compute_jacobian(self, input_names=(), output_names=()): self.jac["y"]["x"] = self._get_factor(self.io.data["mode"]) * eye(2) +class IntegerDiscipline(Discipline): + """A GEMSEO discipline with an integer input and an integer output. + + An integer variable is numeric, so Philote carries it as a continuous + variable, but it is not differentiable. + """ + + default_grammar_type = GrammarType.SIMPLE + + def __init__(self): + super().__init__() + self.input_grammar.update_from_names(["x"]) + self.input_grammar.update_from_types({"n": int}) + self.output_grammar.update_from_names(["y"]) + self.output_grammar.update_from_types({"count": int}) + self.default_input_data = {"x": array([1.0]), "n": 2} + + def _run(self, input_data): + return {"y": input_data["n"] * input_data["x"], "count": input_data["n"]} + + +class DiscreteOnlyDiscipline(Discipline): + """A GEMSEO discipline whose variables are all non-numeric.""" + + default_grammar_type = GrammarType.SIMPLE + + def __init__(self): + super().__init__() + self.input_grammar.update_from_types({"mode": str}) + self.output_grammar.update_from_types({"used_mode": str}) + self.default_input_data = {"mode": "double"} + + def _run(self, input_data): + return {"used_mode": input_data["mode"]} + + class GEMSEOToPhiloteTests(unittest.TestCase): """ Integration tests for GEMSEOtoPhiloteDiscipline, which wraps a GEMSEO @@ -272,5 +308,42 @@ def _serve_scaling_discipline(self): return PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) + def test_setup_partials_skips_integer_variables(self): + """ + An integer variable is numeric, so it is served as a continuous + Philote variable rather than a discrete one, but it is not + differentiable and so takes no part in the Jacobian. + """ + wrapper = GEMSEOtoPhiloteDiscipline(IntegerDiscipline()) + wrapper.setup() + wrapper.setup_partials() + + self.assertEqual( + [m.name for m in wrapper._var_meta], ["x", "n", "y", "count"] + ) + self.assertEqual(list(wrapper._discrete_var_meta), []) + self.assertEqual( + [(m.name, m.subname) for m in wrapper._partials_meta], [("y", "x")] + ) + + def test_compute_partials_without_declared_partials(self): + """ + When no variable holds continuous data, setup_partials() declares + nothing and compute_partials() does not linearize the wrapped + discipline at all. + """ + discipline = DiscreteOnlyDiscipline() + wrapper = GEMSEOtoPhiloteDiscipline(discipline) + wrapper.setup() + wrapper.setup_partials() + + partials = {} + with patch.object(discipline, "linearize") as linearize: + wrapper.compute_partials({}, partials, {"mode": "double"}) + + linearize.assert_not_called() + self.assertEqual(partials, {}) + + if __name__ == "__main__": unittest.main(verbosity=2) From e44e3c87f255e3f83e07bc39ebbb093c33f1d369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Gallard?= Date: Sat, 19 Sep 2026 14:51:30 +0200 Subject: [PATCH 21/21] fix(gemseo): classify a wrapped variable with is_continuous, not is_numeric A continuous Philote variable travels as an array of doubles, and the DiscreteVariable message carries a google.protobuf.Value whose documented contents include integers. An integer-valued variable therefore belongs to the discrete side of the protocol, as it does in OpenMDAO, whose bindings map Philote discrete variables onto add_discrete_input/output. is_numeric differs from is_continuous by exactly the int type, so it sent integers down the double path, where they were silently converted to floats and came back as floats. This also removes the need for the second predicate: setup_partials() had to re-filter its declarations with is_continuous so as not to declare a partial derivative that compute_partials() would leave at zero. The classification and the Jacobian now agree by construction. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 +-- docs/docs/gemseo/gemseo.md | 28 +++++++---- philote_mdo/gemseo/gemseo_to_philote.py | 65 ++++++++++++------------- tests/test_gemseo_to_philote.py | 19 ++++---- 4 files changed, 62 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cab782..2a7fe83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,9 +62,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 grammars next to the continuous ones, bound to no type since a discrete variable may carry any JSON-compatible value. Conversely, a variable of a wrapped GEMSEO discipline is served as a discrete Philote variable - when its grammar's data converter does not report it as numeric. - Discrete variables are excluded from the Jacobian, including when it is - requested in full with `compute_all_jacobians=True`. + when its grammar's data converter does not report it as continuous, + which includes the integer-valued ones, since a continuous Philote + variable is an array of doubles. Discrete variables are excluded from + the Jacobian, including when it is requested in full with + `compute_all_jacobians=True`. - Added examples and tutorials demonstrating interoperability between GEMSEO, OpenMDAO and OpenAeroStruct through Philote-MDO. diff --git a/docs/docs/gemseo/gemseo.md b/docs/docs/gemseo/gemseo.md index 58ab588..00a8e45 100644 --- a/docs/docs/gemseo/gemseo.md +++ b/docs/docs/gemseo/gemseo.md @@ -264,16 +264,16 @@ and nothing has to be configured to enable it. `GEMSEOtoPhiloteDiscipline` classifies every name of the wrapped discipline's grammars by asking the grammar's data converter whether that -name holds numeric data: +name holds continuous data: ```python -discipline.input_grammar.data_converter.is_numeric(name) +discipline.input_grammar.data_converter.is_continuous(name) ``` -A name reported as numeric is declared as a continuous Philote variable; -every other name is declared as a discrete one. A grammar element typed -`str`, `bool`, `dict` or `list` is therefore served as a discrete -variable automatically: +A name reported as continuous is declared as a continuous Philote +variable; every other name is declared as a discrete one. A grammar +element typed `str`, `bool`, `int`, `dict` or `list` is therefore served +as a discrete variable automatically: ```python class ScalingDiscipline(Discipline): @@ -287,6 +287,18 @@ class ScalingDiscipline(Discipline): self.output_grammar.update_from_types({"used_mode": str}) # discrete ``` +:::note +`is_continuous` is the right question to ask, rather than `is_numeric`, +because an integer is numeric but a continuous Philote variable is an +array of doubles. An integer-valued variable therefore belongs to the +discrete side of the protocol, as it does in OpenMDAO. + +A `SimpleGrammar` cannot see the dtype of an element it types as +`ndarray`, so an integer-valued *array* still travels as a continuous +variable. Use a `JSONGrammar` or a `PydanticGrammar` if that distinction +matters. +::: + ### Consuming a remote discipline that has discrete variables `PhiloteDiscipline` puts the server's discrete variables in its GEMSEO @@ -306,10 +318,6 @@ print(out["y"], out["used_mode"]) Discrete variables are never differentiated: no partial derivative is declared for them, and they are excluded from the Jacobian, including when it is requested in full with `compute_all_jacobians=True`. - -Integer-valued variables are numeric, so they travel as continuous -Philote variables, but they are not differentiable either and are -likewise left out of the Jacobian. ::: :::warning diff --git a/philote_mdo/gemseo/gemseo_to_philote.py b/philote_mdo/gemseo/gemseo_to_philote.py index 4694eca..06186ab 100644 --- a/philote_mdo/gemseo/gemseo_to_philote.py +++ b/philote_mdo/gemseo/gemseo_to_philote.py @@ -53,18 +53,21 @@ class GEMSEOtoPhiloteDiscipline( server.attach_to_server(grpc_server) The input and output variables of the wrapped discipline that hold - numeric data, as reported by the ``is_numeric`` method of their + continuous data, as reported by the ``is_continuous`` method of their grammar's data converter, are exposed as flat (1D) continuous Philote variables. Every other variable is exposed as a Philote discrete variable, which carries any JSON-compatible value. - The Jacobian of every continuous output with respect to every - continuous input is made available, computed on demand using GEMSEO's - own differentiation capabilities (see - :meth:`~gemseo.core.discipline.discipline.Discipline.linearize`). The - variables that do not hold continuous data are left out of the - Jacobian: the discrete ones, and also the integer-valued ones, which - are numeric but not differentiable. + This is the split of the Philote-MDO protocol itself: a continuous + variable travels as an array of doubles, while a discrete variable + travels as a ``google.protobuf.Value``. An integer-valued variable + therefore belongs to the discrete side, as it does in OpenMDAO. + + The Jacobian of every output with respect to every input is made + available, computed on demand using GEMSEO's own differentiation + capabilities (see + :meth:`~gemseo.core.discipline.discipline.Discipline.linearize`). + The discrete variables are left out of it. """ gemseo_discipline: Discipline @@ -98,7 +101,7 @@ def setup(self): """Declare the Philote inputs and outputs from the GEMSEO grammars. Every name of the wrapped GEMSEO discipline's input and output - grammars that holds numeric data is declared as a flat (1D) + grammars that holds continuous data is declared as a flat (1D) continuous Philote variable. Its size is taken from the corresponding default value in :attr:`~gemseo.core.discipline.discipline.Discipline.default_input_data` @@ -132,26 +135,26 @@ def setup(self): @staticmethod def _split_names(grammar: BaseGrammar) -> tuple[tuple[str, ...], tuple[str, ...]]: - """Split the names of a grammar into numeric and non-numeric ones. + """Split the names of a grammar into continuous and discrete ones. Args: grammar: The input or output grammar of the wrapped discipline. Returns: - The names bound to numeric data, - which Philote carries as continuous variables, + The names bound to continuous data, + which Philote carries as arrays of doubles, and the other names, which Philote carries as discrete variables. """ - is_numeric = grammar.data_converter.is_numeric - numeric_names = [] + is_continuous = grammar.data_converter.is_continuous + continuous_names = [] discrete_names = [] for name in grammar.names: - if is_numeric(name): - numeric_names.append(name) + if is_continuous(name): + continuous_names.append(name) else: discrete_names.append(name) - return tuple(numeric_names), tuple(discrete_names) + return tuple(continuous_names), tuple(discrete_names) def _get_size(self, defaults: Mapping[str, Any], name: str) -> int: """Return the size to declare for a continuous Philote variable. @@ -173,23 +176,15 @@ def _get_size(self, defaults: Mapping[str, Any], name: str) -> int: def setup_partials(self): """Declare the Jacobian of every output with respect to every input. - No sparsity pattern is assumed: all the input-output pairs of the - wrapped GEMSEO discipline are declared as partial derivatives to - be computed by :meth:`.compute_partials`. - - Only the variables that hold continuous data take part in the - Jacobian, so that no partial derivative is declared that - :meth:`.compute_partials` would then leave at zero. + No sparsity pattern is assumed: all the continuous input-output + pairs of the wrapped GEMSEO discipline are declared as partial + derivatives to be computed by :meth:`.compute_partials`. The + discrete variables are not differentiable, so they take no part in + the Jacobian. """ - disc = self.gemseo_discipline - is_continuous_input = disc.input_grammar.data_converter.is_continuous - is_continuous_output = disc.output_grammar.data_converter.is_continuous for output_name in self._output_names: - if not is_continuous_output(output_name): - continue for input_name in self._input_names: - if is_continuous_input(input_name): - self.declare_partials(output_name, input_name) + self.declare_partials(output_name, input_name) def compute(self, inputs, outputs, discrete_inputs=None, discrete_outputs=None): """Execute the wrapped GEMSEO discipline. @@ -232,13 +227,13 @@ def compute_partials(self, inputs, partials, discrete_inputs=None): name, if any. """ if not self._partials_meta: - # Every variable is discrete or integer-valued, so setup_partials() - # declared nothing to differentiate. + # The discipline has no continuous input or no continuous output, + # so setup_partials() declared nothing to differentiate. return disc = self.gemseo_discipline - # Both of these filter out the names that do not hold continuous - # data, which leaves exactly the subset setup_partials() declared. + # Restrict the differentiation to the continuous variables, which is + # exactly the subset that setup_partials() declared. disc.add_differentiated_inputs(self._input_names) disc.add_differentiated_outputs(self._output_names) jac = disc.linearize(self._merge_inputs(inputs, discrete_inputs)) diff --git a/tests/test_gemseo_to_philote.py b/tests/test_gemseo_to_philote.py index 616bd2d..cf99353 100644 --- a/tests/test_gemseo_to_philote.py +++ b/tests/test_gemseo_to_philote.py @@ -78,8 +78,8 @@ def _compute_jacobian(self, input_names=(), output_names=()): class IntegerDiscipline(Discipline): """A GEMSEO discipline with an integer input and an integer output. - An integer variable is numeric, so Philote carries it as a continuous - variable, but it is not differentiable. + A Philote continuous variable travels as an array of doubles, so an + integer variable belongs to the discrete side of the protocol. """ default_grammar_type = GrammarType.SIMPLE @@ -97,7 +97,7 @@ def _run(self, input_data): class DiscreteOnlyDiscipline(Discipline): - """A GEMSEO discipline whose variables are all non-numeric.""" + """A GEMSEO discipline whose variables are all discrete.""" default_grammar_type = GrammarType.SIMPLE @@ -307,21 +307,20 @@ def _serve_scaling_discipline(self): self.addCleanup(server.stop, 0) return PhiloteDiscipline(channel=grpc.insecure_channel(CHANNEL)) - - def test_setup_partials_skips_integer_variables(self): + def test_integer_variables_are_discrete(self): """ - An integer variable is numeric, so it is served as a continuous - Philote variable rather than a discrete one, but it is not - differentiable and so takes no part in the Jacobian. + A Philote continuous variable is an array of doubles, so an + integer-valued variable is served as a discrete one and takes no + part in the Jacobian. """ wrapper = GEMSEOtoPhiloteDiscipline(IntegerDiscipline()) wrapper.setup() wrapper.setup_partials() + self.assertEqual([m.name for m in wrapper._var_meta], ["x", "y"]) self.assertEqual( - [m.name for m in wrapper._var_meta], ["x", "n", "y", "count"] + [m.name for m in wrapper._discrete_var_meta], ["n", "count"] ) - self.assertEqual(list(wrapper._discrete_var_meta), []) self.assertEqual( [(m.name, m.subname) for m in wrapper._partials_meta], [("y", "x")] )