From 2a67340abe482cdd598ee202476087b9bb5036b2 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Sun, 2 Nov 2025 22:01:27 -0500 Subject: [PATCH 01/15] Add Julia discipline wrapper infrastructure Add wrappers/julia module that enables serving pure Julia Philote disciplines via Python gRPC server using juliacall. - Add JuliaWrapperDiscipline for explicit disciplines - Add JuliaImplicitWrapperDiscipline for implicit disciplines - Add YAML configuration support via PhiloteConfig - Add explicit and implicit server functions - Add CLI entry point (philote-julia-serve command) - Add example disciplines (paraboloid, quadratic) - Add example configurations --- examples/julia/configs/paraboloid.yaml | 29 +++ examples/julia/configs/quadratic.yaml | 16 ++ examples/julia/paraboloid.jl | 179 ++++++++++++++ examples/julia/quadratic.jl | 117 +++++++++ philote_mdo/wrappers/__init__.py | 3 + philote_mdo/wrappers/julia/__init__.py | 20 ++ philote_mdo/wrappers/julia/cli.py | 68 ++++++ philote_mdo/wrappers/julia/config.py | 129 ++++++++++ philote_mdo/wrappers/julia/explicit.py | 62 +++++ philote_mdo/wrappers/julia/implicit.py | 62 +++++ philote_mdo/wrappers/julia/wrapper.py | 316 +++++++++++++++++++++++++ 11 files changed, 1001 insertions(+) create mode 100644 examples/julia/configs/paraboloid.yaml create mode 100644 examples/julia/configs/quadratic.yaml create mode 100644 examples/julia/paraboloid.jl create mode 100644 examples/julia/quadratic.jl create mode 100644 philote_mdo/wrappers/__init__.py create mode 100644 philote_mdo/wrappers/julia/__init__.py create mode 100644 philote_mdo/wrappers/julia/cli.py create mode 100644 philote_mdo/wrappers/julia/config.py create mode 100644 philote_mdo/wrappers/julia/explicit.py create mode 100644 philote_mdo/wrappers/julia/implicit.py create mode 100644 philote_mdo/wrappers/julia/wrapper.py diff --git a/examples/julia/configs/paraboloid.yaml b/examples/julia/configs/paraboloid.yaml new file mode 100644 index 0000000..f1c0453 --- /dev/null +++ b/examples/julia/configs/paraboloid.yaml @@ -0,0 +1,29 @@ +# Philote-Julia Configuration Example: Paraboloid Discipline +# +# This example shows how to serve a simple explicit Julia discipline. +# The paraboloid function computes: f(x,y) = (x-3)^2 + xy + (y+4)^2 + +discipline: + # Type of discipline: "explicit" or "implicit" + kind: explicit + + # Path to Julia file (relative to this config file or absolute) + julia_file: ../paraboloid.jl + + # Name of the Julia type/struct to instantiate + julia_type: ParaboloidDiscipline + + # Optional: Discipline-specific options + # These will be passed to the Julia discipline via set_options!() + # options: + # scale_factor: 2.0 + # debug: true + +server: + # gRPC server address + # Use [::]:PORT to listen on all interfaces (IPv4 and IPv6) + # Use localhost:PORT to listen only on localhost + address: "[::]:50051" + + # Maximum number of worker threads for the gRPC server + max_workers: 10 diff --git a/examples/julia/configs/quadratic.yaml b/examples/julia/configs/quadratic.yaml new file mode 100644 index 0000000..fa5b23f --- /dev/null +++ b/examples/julia/configs/quadratic.yaml @@ -0,0 +1,16 @@ +# Configuration for quadratic equation solver (implicit discipline) +# +# This demonstrates how to deploy an implicit Julia discipline where outputs +# must satisfy residual equations rather than being directly computed from inputs. +# +# Example: Solving x^2 - 5x + 6 = 0 (which has solutions x=2 and x=3) +# Set inputs: a=1, b=-5, c=6 + +discipline: + kind: implicit # Type of discipline ("explicit" or "implicit") + julia_file: ../quadratic.jl # Path to Julia file containing the discipline + julia_type: QuadraticDiscipline # Julia struct name + +server: + address: "[::]:50052" # Server address (different from paraboloid example) + max_workers: 10 # Thread pool size diff --git a/examples/julia/paraboloid.jl b/examples/julia/paraboloid.jl new file mode 100644 index 0000000..0af5eda --- /dev/null +++ b/examples/julia/paraboloid.jl @@ -0,0 +1,179 @@ +""" + Paraboloid Discipline Example + +This example implements the paraboloid function: + f(x, y) = (x - 3)^2 + x*y + (y + 4)^2 - 3 + +This is the same example used in Philote-Cpp for consistency. +""" + +# Load the Philote module from the parent directory +push!(LOAD_PATH, joinpath(@__DIR__, "..")) +using Philote + +""" + ParaboloidDiscipline <: ExplicitDiscipline + +Example explicit discipline computing a paraboloid function. + +Options: +- scale_factor (float): Scaling factor applied to output (default: 1.0) +- offset (float): Offset added to output (default: 0.0) +""" +mutable struct ParaboloidDiscipline <: Philote.ExplicitDiscipline + scale_factor::Float64 + offset::Float64 + + function ParaboloidDiscipline() + new(1.0, 0.0) + end +end + +""" + setup!(discipline::ParaboloidDiscipline) + +Declare inputs, outputs, options, and partials for the paraboloid discipline. +""" +function Philote.setup!(discipline::ParaboloidDiscipline) + # Declare options + Philote.add_option!(discipline, "scale_factor", "float") + Philote.add_option!(discipline, "offset", "float") + + # Declare inputs + Philote.add_input!(discipline, "x", [1], "m") + Philote.add_input!(discipline, "y", [1], "m") + + # Declare outputs + Philote.add_output!(discipline, "f_xy", [1], "m**2") + + # Declare partials (gradients) + Philote.declare_partials!(discipline, "f_xy", "x") + Philote.declare_partials!(discipline, "f_xy", "y") + + # Set metadata + meta = Philote.get_metadata(discipline) + meta.name = "ParaboloidDiscipline" + meta.version = "0.1.0" +end + +""" + compute(discipline::ParaboloidDiscipline, inputs::Dict{String, Array{Float64}}) + +Compute the paraboloid function output. + +# Formula +f(x, y) = scale_factor * [(x - 3)^2 + x*y + (y + 4)^2 - 3] + offset +""" +function Philote.compute(discipline::ParaboloidDiscipline, + inputs::Dict{String, <:AbstractArray{Float64}}) + # Extract scalar inputs + x = inputs["x"][1] + y = inputs["y"][1] + + # Compute paraboloid function + f_xy = (x - 3.0)^2 + x * y + (y + 4.0)^2 - 3.0 + + # Apply scaling and offset + f_xy = discipline.scale_factor * f_xy + discipline.offset + + # Return outputs as dictionary + return Dict("f_xy" => [f_xy]) +end + +""" + compute_partials(discipline::ParaboloidDiscipline, inputs::Dict{String, Array{Float64}}) + +Compute analytical gradients of the paraboloid function. + +# Derivatives +∂f/∂x = scale_factor * [2(x - 3) + y] +∂f/∂y = scale_factor * [2(y + 4) + x] +""" +function Philote.compute_partials(discipline::ParaboloidDiscipline, + inputs::Dict{String, <:AbstractArray{Float64}}) + # Extract scalar inputs + x = inputs["x"][1] + y = inputs["y"][1] + + # Compute partials + df_dx = discipline.scale_factor * (2.0 * (x - 3.0) + y) + df_dy = discipline.scale_factor * (2.0 * (y + 4.0) + x) + + # Return as nested dictionary + return Dict( + "f_xy" => Dict( + "x" => [df_dx], + "y" => [df_dy] + ) + ) +end + +""" + set_options!(discipline::ParaboloidDiscipline, options::Dict{String, <:Any}) + +Set discipline options from a dictionary. +""" +function Philote.set_options!(discipline::ParaboloidDiscipline, options::Dict{String, <:Any}) + if haskey(options, "scale_factor") + discipline.scale_factor = Float64(options["scale_factor"]) + end + if haskey(options, "offset") + discipline.offset = Float64(options["offset"]) + end +end + +# Example usage when run as a standalone script +if abspath(PROGRAM_FILE) == @__FILE__ + println("Paraboloid Discipline Example") + println("=" ^ 50) + + # Create discipline instance + discipline = ParaboloidDiscipline() + + # Setup discipline + Philote.setup!(discipline) + + # Print metadata + meta = Philote.get_metadata(discipline) + println("Discipline: $(meta.name) v$(meta.version)") + println("\nInputs:") + for (name, (shape, units)) in meta.inputs + println(" $name: shape=$(shape), units=$units") + end + println("\nOutputs:") + for (name, (shape, units)) in meta.outputs + println(" $name: shape=$(shape), units=$units") + end + println("\nPartials:") + for (output, input) in meta.partials + println(" ∂$output/∂$input") + end + + # Test computation + println("\n" * "=" ^ 50) + println("Test Evaluation") + println("=" ^ 50) + + inputs = Dict("x" => [1.0], "y" => [2.0]) + println("Inputs: x=$(inputs["x"][1]), y=$(inputs["y"][1])") + + outputs = Philote.compute(discipline, inputs) + println("Output: f_xy=$(outputs["f_xy"][1])") + + partials = Philote.compute_partials(discipline, inputs) + println("Gradient: ∂f/∂x=$(partials["f_xy"]["x"][1]), ∂f/∂y=$(partials["f_xy"]["y"][1])") + + # Test with options + println("\n" * "=" ^ 50) + println("Test with Options") + println("=" ^ 50) + + Philote.set_options!(discipline, Dict("scale_factor" => 2.0, "offset" => 10.0)) + println("Options: scale_factor=$(discipline.scale_factor), offset=$(discipline.offset)") + + outputs = Philote.compute(discipline, inputs) + println("Output: f_xy=$(outputs["f_xy"][1])") + + partials = Philote.compute_partials(discipline, inputs) + println("Gradient: ∂f/∂x=$(partials["f_xy"]["x"][1]), ∂f/∂y=$(partials["f_xy"]["y"][1])") +end diff --git a/examples/julia/quadratic.jl b/examples/julia/quadratic.jl new file mode 100644 index 0000000..288898e --- /dev/null +++ b/examples/julia/quadratic.jl @@ -0,0 +1,117 @@ +""" +Simple implicit discipline example: Quadratic equation solver. + +This discipline solves the equation: a*x^2 + b*x + c = 0 + +Inputs: a, b, c (coefficients) +Outputs: x (solution) +Residual: r = a*x^2 + b*x + c + +This demonstrates implicit disciplines where the output must satisfy +a residual equation rather than being directly computed from inputs. +""" + +using Philote + +mutable struct QuadraticDiscipline <: Philote.ImplicitDiscipline + tolerance::Float64 + + function QuadraticDiscipline() + new(1e-10) + end +end + +function Philote.setup!(discipline::QuadraticDiscipline) + # Define inputs (coefficients of quadratic equation) + Philote.add_input!(discipline, "a", [1], "unitless") + Philote.add_input!(discipline, "b", [1], "unitless") + Philote.add_input!(discipline, "c", [1], "unitless") + + # Define output (solution to equation) + Philote.add_output!(discipline, "x", [1], "unitless") + + # Define residual (r = a*x^2 + b*x + c) + Philote.add_residual!(discipline, "r", [1], "unitless") + + # Declare partial derivatives + # ∂r/∂a = x^2 + Philote.declare_partials!(discipline, "r", "a") + # ∂r/∂b = x + Philote.declare_partials!(discipline, "r", "b") + # ∂r/∂c = 1 + Philote.declare_partials!(discipline, "r", "c") + # ∂r/∂x = 2*a*x + b + Philote.declare_partials!(discipline, "r", "x") +end + +function Philote.compute_residuals(discipline::QuadraticDiscipline, + inputs::Dict{String,Array}, + outputs::Dict{String,Array}) + # Extract inputs + a = inputs["a"][1] + b = inputs["b"][1] + c = inputs["c"][1] + x = outputs["x"][1] + + # Compute residual: r = a*x^2 + b*x + c + r = a * x^2 + b * x + c + + return Dict("r" => [r]) +end + +function Philote.solve_residuals(discipline::QuadraticDiscipline, + inputs::Dict{String,Array}, + outputs::Dict{String,Array}) + # Extract coefficients + a = inputs["a"][1] + b = inputs["b"][1] + c = inputs["c"][1] + + # Solve quadratic equation using quadratic formula + # x = (-b ± sqrt(b^2 - 4ac)) / (2a) + discriminant = b^2 - 4*a*c + + if discriminant < 0 + error("No real solution: discriminant is negative") + end + + # Take the positive root (could make this configurable) + x = (-b + sqrt(discriminant)) / (2*a) + + # Update output in place + outputs["x"][1] = x +end + +function Philote.residual_partials(discipline::QuadraticDiscipline, + inputs::Dict{String,Array}, + outputs::Dict{String,Array}) + # Extract values + a = inputs["a"][1] + b = inputs["b"][1] + c = inputs["c"][1] + x = outputs["x"][1] + + # Compute partial derivatives of residual + # r = a*x^2 + b*x + c + + # ∂r/∂a = x^2 + dr_da = x^2 + + # ∂r/∂b = x + dr_db = x + + # ∂r/∂c = 1 + dr_dc = 1.0 + + # ∂r/∂x = 2*a*x + b + dr_dx = 2*a*x + b + + return Dict( + "r" => Dict( + "a" => reshape([dr_da], 1, 1), + "b" => reshape([dr_db], 1, 1), + "c" => reshape([dr_dc], 1, 1), + "x" => reshape([dr_dx], 1, 1) + ) + ) +end diff --git a/philote_mdo/wrappers/__init__.py b/philote_mdo/wrappers/__init__.py new file mode 100644 index 0000000..e8f7b9b --- /dev/null +++ b/philote_mdo/wrappers/__init__.py @@ -0,0 +1,3 @@ +""" +Wrappers for interfacing Philote with other language implementations. +""" diff --git a/philote_mdo/wrappers/julia/__init__.py b/philote_mdo/wrappers/julia/__init__.py new file mode 100644 index 0000000..5f6fa11 --- /dev/null +++ b/philote_mdo/wrappers/julia/__init__.py @@ -0,0 +1,20 @@ +""" +Julia discipline wrapper for Philote. + +This module provides Python wrappers for Julia Philote disciplines via juliacall, +allowing pure Julia code to be served via the Philote gRPC protocol. +""" +from .wrapper import JuliaWrapperDiscipline, JuliaImplicitWrapperDiscipline +from .config import PhiloteConfig, DisciplineConfig, ServerConfig +from .explicit import serve_explicit_discipline +from .implicit import serve_implicit_discipline + +__all__ = [ + 'JuliaWrapperDiscipline', + 'JuliaImplicitWrapperDiscipline', + 'PhiloteConfig', + 'DisciplineConfig', + 'ServerConfig', + 'serve_explicit_discipline', + 'serve_implicit_discipline', +] diff --git a/philote_mdo/wrappers/julia/cli.py b/philote_mdo/wrappers/julia/cli.py new file mode 100644 index 0000000..d36cb34 --- /dev/null +++ b/philote_mdo/wrappers/julia/cli.py @@ -0,0 +1,68 @@ +""" +Command-line interface for Julia discipline server. +""" +import sys +import argparse +from pathlib import Path + +from .config import PhiloteConfig +from .explicit import serve_explicit_discipline +from .implicit import serve_implicit_discipline + + +def main(): + """Main entry point for philote-julia-serve command.""" + parser = argparse.ArgumentParser( + description="Serve Julia Philote disciplines via gRPC", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Serve from YAML config + philote-julia-serve examples/configs/paraboloid.yaml + + # Also works with absolute paths + philote-julia-serve /path/to/config.yaml + +For more information, see the documentation in examples/configs/README.md +""" + ) + + parser.add_argument( + "config", + type=str, + help="Path to YAML configuration file" + ) + + parser.add_argument( + "--version", + action="version", + version="philote-julia 0.1.0" + ) + + args = parser.parse_args() + + # Load configuration + try: + config = PhiloteConfig.from_yaml(args.config) + except FileNotFoundError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except ValueError as e: + print(f"Configuration error: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Unexpected error loading config: {e}", file=sys.stderr) + sys.exit(1) + + # Route to appropriate server based on discipline kind + if config.discipline.kind == "explicit": + serve_explicit_discipline(config) + elif config.discipline.kind == "implicit": + serve_implicit_discipline(config) + else: + print(f"Error: Unknown discipline kind '{config.discipline.kind}'", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/philote_mdo/wrappers/julia/config.py b/philote_mdo/wrappers/julia/config.py new file mode 100644 index 0000000..51114c3 --- /dev/null +++ b/philote_mdo/wrappers/julia/config.py @@ -0,0 +1,129 @@ +""" +Configuration file loading and validation for Philote-Julia. +""" +import os +from dataclasses import dataclass, field +from typing import Dict, Optional +import yaml + + +@dataclass +class DisciplineConfig: + """Configuration for a Julia discipline.""" + + kind: str # "explicit" or "implicit" + julia_file: str + julia_type: str + options: Dict[str, any] = field(default_factory=dict) + + def __post_init__(self): + """Validate configuration after initialization.""" + if self.kind not in ["explicit", "implicit"]: + raise ValueError(f"discipline.kind must be 'explicit' or 'implicit', got '{self.kind}'") + + if not self.julia_file: + raise ValueError("discipline.julia_file is required") + + if not self.julia_type: + raise ValueError("discipline.julia_type is required") + + +@dataclass +class ServerConfig: + """Configuration for the gRPC server.""" + + address: str = "[::]:50051" + max_workers: int = 10 + + def __post_init__(self): + """Validate configuration after initialization.""" + if self.max_workers < 1: + raise ValueError(f"server.max_workers must be >= 1, got {self.max_workers}") + + +@dataclass +class PhiloteConfig: + """Complete configuration for Philote-Julia server.""" + + discipline: DisciplineConfig + server: ServerConfig = field(default_factory=ServerConfig) + + @classmethod + def from_yaml(cls, yaml_path: str) -> "PhiloteConfig": + """ + Load configuration from a YAML file. + + Args: + yaml_path: Path to YAML configuration file + + Returns: + PhiloteConfig object + + Raises: + FileNotFoundError: If yaml_path doesn't exist + ValueError: If configuration is invalid + """ + if not os.path.exists(yaml_path): + raise FileNotFoundError(f"Configuration file not found: {yaml_path}") + + with open(yaml_path, 'r') as f: + data = yaml.safe_load(f) + + if not isinstance(data, dict): + raise ValueError(f"Invalid YAML: expected dict, got {type(data)}") + + # Parse discipline config + if 'discipline' not in data: + raise ValueError("Missing required 'discipline' section in config") + + disc_data = data['discipline'] + discipline = DisciplineConfig( + kind=disc_data.get('kind', 'explicit'), + julia_file=disc_data.get('julia_file', ''), + julia_type=disc_data.get('julia_type', ''), + options=disc_data.get('options', {}) + ) + + # Resolve relative path to julia_file from yaml file location + if not os.path.isabs(discipline.julia_file): + yaml_dir = os.path.dirname(os.path.abspath(yaml_path)) + discipline.julia_file = os.path.join(yaml_dir, discipline.julia_file) + + # Verify julia_file exists + if not os.path.exists(discipline.julia_file): + raise FileNotFoundError( + f"Julia file not found: {discipline.julia_file}\n" + f"(specified in {yaml_path})" + ) + + # Parse server config (optional) + server_data = data.get('server', {}) + server = ServerConfig( + address=server_data.get('address', '[::]:50051'), + max_workers=server_data.get('max_workers', 10) + ) + + return cls(discipline=discipline, server=server) + + def to_yaml(self, yaml_path: str): + """ + Write configuration to a YAML file. + + Args: + yaml_path: Path to write YAML configuration + """ + data = { + 'discipline': { + 'kind': self.discipline.kind, + 'julia_file': self.discipline.julia_file, + 'julia_type': self.discipline.julia_type, + 'options': self.discipline.options + }, + 'server': { + 'address': self.server.address, + 'max_workers': self.server.max_workers + } + } + + with open(yaml_path, 'w') as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) diff --git a/philote_mdo/wrappers/julia/explicit.py b/philote_mdo/wrappers/julia/explicit.py new file mode 100644 index 0000000..0fb615f --- /dev/null +++ b/philote_mdo/wrappers/julia/explicit.py @@ -0,0 +1,62 @@ +""" +Explicit discipline server for Julia disciplines. +""" +from concurrent import futures +import grpc + +from .wrapper import JuliaWrapperDiscipline +from .config import PhiloteConfig +import philote_mdo.general as pmdo + + +def serve_explicit_discipline(config: PhiloteConfig): + """ + Start a gRPC server hosting an explicit Julia discipline. + + Args: + config: PhiloteConfig object with discipline and server configuration + """ + print("=" * 70) + print(" Philote Julia Server (Python wrapper + juliacall)") + print("=" * 70) + print() + print(f"Configuration:") + print(f" Julia file: {config.discipline.julia_file}") + print(f" Julia type: {config.discipline.julia_type}") + print(f" Server addr: {config.server.address}") + print(f" Max workers: {config.server.max_workers}") + if config.discipline.options: + print(f" Options: {config.discipline.options}") + print() + + # Create the wrapper discipline + discipline_wrapper = JuliaWrapperDiscipline( + julia_file=config.discipline.julia_file, + julia_type=config.discipline.julia_type, + options=config.discipline.options + ) + + print() + + # Create gRPC server + server = grpc.server(futures.ThreadPoolExecutor(max_workers=config.server.max_workers)) + + # Create discipline server and attach to gRPC server + discipline_server = pmdo.ExplicitServer(discipline=discipline_wrapper) + discipline_server.attach_to_server(server) + + # Start server + server.add_insecure_port(config.server.address) + server.start() + print(f"✓ Server started successfully!") + print(f" Listening on: {config.server.address}") + print() + print("Press Ctrl+C to stop the server.") + print("=" * 70) + + try: + server.wait_for_termination() + except KeyboardInterrupt: + print("\n\nShutting down server...") + server.stop(grace=2.0) + print("Server stopped.") diff --git a/philote_mdo/wrappers/julia/implicit.py b/philote_mdo/wrappers/julia/implicit.py new file mode 100644 index 0000000..e11e792 --- /dev/null +++ b/philote_mdo/wrappers/julia/implicit.py @@ -0,0 +1,62 @@ +""" +Implicit discipline server for Julia disciplines. +""" +from concurrent import futures +import grpc + +from .wrapper import JuliaImplicitWrapperDiscipline +from .config import PhiloteConfig +import philote_mdo.general as pmdo + + +def serve_implicit_discipline(config: PhiloteConfig): + """ + Start a gRPC server hosting an implicit Julia discipline. + + Args: + config: PhiloteConfig object with discipline and server configuration + """ + print("=" * 70) + print(" Philote Julia Implicit Server (Python wrapper + juliacall)") + print("=" * 70) + print() + print(f"Configuration:") + print(f" Julia file: {config.discipline.julia_file}") + print(f" Julia type: {config.discipline.julia_type}") + print(f" Server addr: {config.server.address}") + print(f" Max workers: {config.server.max_workers}") + if config.discipline.options: + print(f" Options: {config.discipline.options}") + print() + + # Create the wrapper discipline + discipline_wrapper = JuliaImplicitWrapperDiscipline( + julia_file=config.discipline.julia_file, + julia_type=config.discipline.julia_type, + options=config.discipline.options + ) + + print() + + # Create gRPC server + server = grpc.server(futures.ThreadPoolExecutor(max_workers=config.server.max_workers)) + + # Create discipline server and attach to gRPC server + discipline_server = pmdo.ImplicitServer(discipline=discipline_wrapper) + discipline_server.attach_to_server(server) + + # Start server + server.add_insecure_port(config.server.address) + server.start() + print(f"✓ Server started successfully!") + print(f" Listening on: {config.server.address}") + print() + print("Press Ctrl+C to stop the server.") + print("=" * 70) + + try: + server.wait_for_termination() + except KeyboardInterrupt: + print("\n\nShutting down server...") + server.stop(grace=2.0) + print("Server stopped.") diff --git a/philote_mdo/wrappers/julia/wrapper.py b/philote_mdo/wrappers/julia/wrapper.py new file mode 100644 index 0000000..398d5c1 --- /dev/null +++ b/philote_mdo/wrappers/julia/wrapper.py @@ -0,0 +1,316 @@ +""" +Python discipline wrapper that calls Julia code via juliacall. + +This allows using pure Julia disciplines with the proven Python gRPC server. +""" +import os +import numpy as np + +# Import Philote disciplines +from philote_mdo.general.explicit_discipline import ExplicitDiscipline +from philote_mdo.general.implicit_discipline import ImplicitDiscipline + +# Import juliacall +try: + from juliacall import Main as jl +except ImportError as e: + raise ImportError( + f"Cannot import juliacall. Please install it with: pip install juliacall\n" + f"Original error: {e}" + ) + + +class JuliaWrapperDiscipline(ExplicitDiscipline): + """ + Python discipline that wraps a Julia Philote discipline. + + This uses juliacall to load and execute Julia code, while presenting + a pure Python interface that works with the Philote-Python server. + """ + + def __init__(self, julia_file, julia_type, options=None): + """ + Initialize with a Julia discipline. + + Args: + julia_file: Path to Julia file containing the discipline + julia_type: Name of the Julia type (e.g., "ParaboloidDiscipline") + options: Optional dict of discipline options to set after initialization + """ + super().__init__() + + self.julia_file = os.path.abspath(julia_file) + self.julia_type = julia_type + self.julia_discipline = None + self.julia_metadata = None + self._options = options or {} + + # Load Julia discipline + self._load_julia_discipline() + + def _load_julia_discipline(self): + """Load the Julia discipline using juliacall.""" + print(f"Loading Julia discipline from: {self.julia_file}") + + # Load the Philote.jl module + philote_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + jl.seval(f'push!(LOAD_PATH, "{philote_dir}")') + jl.seval('using Philote') + + # Load the discipline file + if not os.path.exists(self.julia_file): + raise FileNotFoundError(f"Julia file not found: {self.julia_file}") + + jl.seval(f'include("{self.julia_file}")') + + # Create discipline instance + try: + self.julia_discipline = jl.seval(f'{self.julia_type}()') + except Exception as e: + raise ValueError( + f"Failed to instantiate Julia type '{self.julia_type}'. " + f"Make sure it exists in {self.julia_file}\n" + f"Original error: {e}" + ) + + # Set options if provided + if self._options: + jl.seval('Philote.set_options!')(self.julia_discipline, jl.Dict(self._options)) + + # Call setup! + jl.seval('Philote.setup!')(self.julia_discipline) + + # Get metadata + self.julia_metadata = jl.seval('Philote.get_metadata')(self.julia_discipline) + + print(f"✓ Julia discipline loaded: {self.julia_metadata.name}") + + def setup(self): + """ + Setup the discipline - define inputs, outputs, and partials. + + This reads metadata from the Julia discipline and configures + the Python discipline interface. + """ + # Add inputs from Julia metadata + for name, (shape, units) in self.julia_metadata.inputs.items(): + self.add_input(name, shape=tuple(shape), units=units) + + # Add outputs from Julia metadata + for name, (shape, units) in self.julia_metadata.outputs.items(): + self.add_output(name, shape=tuple(shape), units=units) + + # Declare partials from Julia metadata + for output_name, input_name in self.julia_metadata.partials: + self.declare_partials(output_name, input_name) + + def compute(self, inputs, outputs): + """ + Compute outputs from inputs by calling Julia discipline. + + Args: + inputs: Dict of input arrays + outputs: Dict to populate with output arrays + """ + try: + # Convert Python dict to Julia dict + jl_inputs = jl.Dict(inputs) + + # Call Julia compute + jl_outputs = jl.seval('Philote.compute')(self.julia_discipline, jl_inputs) + + # Convert Julia outputs back to Python and populate outputs dict + for name in jl_outputs.keys(): + outputs[name] = np.array(jl_outputs[name]) + except Exception as e: + raise RuntimeError(f"Error in Julia compute: {e}") + + def compute_partials(self, inputs, partials): + """ + Compute partial derivatives by calling Julia discipline. + + Args: + inputs: Dict of input arrays + partials: Dict to populate with partial derivative arrays + """ + try: + # Convert Python dict to Julia dict + jl_inputs = jl.Dict(inputs) + + # Call Julia compute_partials + jl_partials = jl.seval('Philote.compute_partials')(self.julia_discipline, jl_inputs) + + # Convert Julia partials back to Python + # Julia returns: Dict{output_name => Dict{input_name => jacobian}} + for output_name in jl_partials.keys(): + for input_name in jl_partials[output_name].keys(): + key = (output_name, input_name) + partials[key] = np.array(jl_partials[output_name][input_name]) + except Exception as e: + raise RuntimeError(f"Error in Julia compute_partials: {e}") + + +class JuliaImplicitWrapperDiscipline(ImplicitDiscipline): + """ + Python implicit discipline that wraps a Julia Philote implicit discipline. + + This uses juliacall to load and execute Julia code for implicit disciplines, + while presenting a pure Python interface that works with the Philote-Python server. + """ + + def __init__(self, julia_file, julia_type, options=None): + """ + Initialize with a Julia implicit discipline. + + Args: + julia_file: Path to Julia file containing the discipline + julia_type: Name of the Julia type (e.g., "MyImplicitDiscipline") + options: Optional dict of discipline options to set after initialization + """ + super().__init__() + + self.julia_file = os.path.abspath(julia_file) + self.julia_type = julia_type + self.julia_discipline = None + self.julia_metadata = None + self._options = options or {} + + # Load Julia discipline + self._load_julia_discipline() + + def _load_julia_discipline(self): + """Load the Julia implicit discipline using juliacall.""" + print(f"Loading Julia implicit discipline from: {self.julia_file}") + + # Load the Philote.jl module + philote_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + jl.seval(f'push!(LOAD_PATH, "{philote_dir}")') + jl.seval('using Philote') + + # Load the discipline file + if not os.path.exists(self.julia_file): + raise FileNotFoundError(f"Julia file not found: {self.julia_file}") + + jl.seval(f'include("{self.julia_file}")') + + # Create discipline instance + try: + self.julia_discipline = jl.seval(f'{self.julia_type}()') + except Exception as e: + raise ValueError( + f"Failed to instantiate Julia type '{self.julia_type}'. " + f"Make sure it exists in {self.julia_file}\n" + f"Original error: {e}" + ) + + # Set options if provided + if self._options: + jl.seval('Philote.set_options!')(self.julia_discipline, jl.Dict(self._options)) + + # Call setup! + jl.seval('Philote.setup!')(self.julia_discipline) + + # Get metadata + self.julia_metadata = jl.seval('Philote.get_metadata')(self.julia_discipline) + + print(f"✓ Julia implicit discipline loaded: {self.julia_metadata.name}") + + def setup(self): + """ + Setup the implicit discipline - define inputs, outputs, residuals, and partials. + + This reads metadata from the Julia discipline and configures + the Python discipline interface. + """ + # Add inputs from Julia metadata + for name, (shape, units) in self.julia_metadata.inputs.items(): + self.add_input(name, shape=tuple(shape), units=units) + + # Add outputs from Julia metadata + for name, (shape, units) in self.julia_metadata.outputs.items(): + self.add_output(name, shape=tuple(shape), units=units) + + # Add residuals from Julia metadata (implicit disciplines) + for name, (shape, units) in self.julia_metadata.residuals.items(): + self.add_residual(name, shape=tuple(shape), units=units) + + # Declare partials from Julia metadata + for output_name, input_name in self.julia_metadata.partials: + self.declare_partials(output_name, input_name) + + def compute_residuals(self, inputs, outputs, residuals): + """ + Compute residuals by calling Julia discipline. + + Args: + inputs: Dict of input arrays + outputs: Dict of output arrays + residuals: Dict to populate with residual arrays + """ + try: + # Convert Python dicts to Julia dicts + jl_inputs = jl.Dict(inputs) + jl_outputs = jl.Dict(outputs) + + # Call Julia compute_residuals + jl_residuals = jl.seval('Philote.compute_residuals')( + self.julia_discipline, jl_inputs, jl_outputs + ) + + # Convert Julia residuals back to Python and populate residuals dict + for name in jl_residuals.keys(): + residuals[name] = np.array(jl_residuals[name]) + except Exception as e: + raise RuntimeError(f"Error in Julia compute_residuals: {e}") + + def solve_residuals(self, inputs, outputs): + """ + Solve for outputs that drive residuals to zero by calling Julia discipline. + + Args: + inputs: Dict of input arrays + outputs: Dict of output arrays (modified in place) + """ + try: + # Convert Python dicts to Julia dicts + jl_inputs = jl.Dict(inputs) + jl_outputs = jl.Dict(outputs) + + # Call Julia solve_residuals (modifies outputs in place) + jl.seval('Philote.solve_residuals')( + self.julia_discipline, jl_inputs, jl_outputs + ) + + # Update Python outputs from Julia outputs + for name in jl_outputs.keys(): + outputs[name] = np.array(jl_outputs[name]) + except Exception as e: + raise RuntimeError(f"Error in Julia solve_residuals: {e}") + + def residual_partials(self, inputs, outputs, partials): + """ + Compute residual partial derivatives by calling Julia discipline. + + Args: + inputs: Dict of input arrays + outputs: Dict of output arrays + partials: Dict to populate with partial derivative arrays + """ + try: + # Convert Python dicts to Julia dicts + jl_inputs = jl.Dict(inputs) + jl_outputs = jl.Dict(outputs) + + # Call Julia residual_partials + jl_partials = jl.seval('Philote.residual_partials')( + self.julia_discipline, jl_inputs, jl_outputs + ) + + # Convert Julia partials back to Python + # Julia returns: Dict{residual_name => Dict{variable_name => jacobian}} + for residual_name in jl_partials.keys(): + for variable_name in jl_partials[residual_name].keys(): + key = (residual_name, variable_name) + partials[key] = np.array(jl_partials[residual_name][variable_name]) + except Exception as e: + raise RuntimeError(f"Error in Julia residual_partials: {e}") From c6364cad80c20fd5856169ff4d5cb78408ef3544 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Sun, 2 Nov 2025 22:01:32 -0500 Subject: [PATCH 02/15] Add Julia optional dependencies and CLI command - Add 'julia' extra with juliacall and pyyaml dependencies - Add philote-julia-serve console script entry point - Enables: pip install philote-mdo[julia] --- pyproject.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 86f127c..ef1e1c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,8 +29,15 @@ Homepage = "https://mdo-standards.github.io/Philote-Python/" Repository = "https://github.com/mdo-standards/Philote-Python" Documentation = "https://mdo-standards.github.io/Philote-Python/" +[project.scripts] +philote-julia-serve = "philote_mdo.wrappers.julia.cli:main" + [project.optional-dependencies] openmdao = ["openmdao>=3.0"] +julia = [ + "juliacall>=0.9.0", + "pyyaml>=6.0", +] dev = [ "pytest>=7.0", "pytest-cov>=4.0", From 08d6d6dd8b359c0eb44544b06e7e1fee2f4d5a8a Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Mon, 3 Nov 2025 20:16:59 -0500 Subject: [PATCH 03/15] Add automated release workflow for Python package - Create GitHub Actions workflow for release automation - Trigger on merged PRs with release/prerelease labels - Validate label combinations (major/minor/patch + alpha/beta/rc) - Update version in pyproject.toml automatically - Update CHANGELOG.md with new version sections - Create GPG-signed commits and git tags - Build package and create GitHub releases - Add copyright update script for Python files --- .github/workflows/release.yml | 244 ++++++++++++++++++++++++++++++++++ scripts/update_copyright.py | 124 +++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100755 scripts/update_copyright.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..73fd374 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,244 @@ +name: Release + +on: + pull_request: + types: [closed] + branches: + - main + +jobs: + release: + if: github.event.pull_request.merged == true && (contains(github.event.pull_request.labels.*.name, 'release') || contains(github.event.pull_request.labels.*.name, 'prerelease')) + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Create GPG key for github-actions bot + run: | + cat >keydetails <> $GITHUB_ENV + + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --global user.signingkey ${{ env.GPG_KEY_ID }} + git config --global commit.gpgsign true + + - name: Validate PR labels + id: validate_labels + run: | + LABELS='${{ toJson(github.event.pull_request.labels.*.name) }}' + echo "Labels: $LABELS" + + # Check for exactly one of release or prerelease + HAS_RELEASE=$(echo $LABELS | jq 'map(select(. == "release")) | length') + HAS_PRERELEASE=$(echo $LABELS | jq 'map(select(. == "prerelease")) | length') + + if [ "$HAS_RELEASE" -eq 1 ] && [ "$HAS_PRERELEASE" -eq 1 ]; then + echo "Error: PR cannot have both 'release' and 'prerelease' labels" + exit 1 + fi + + if [ "$HAS_RELEASE" -eq 0 ] && [ "$HAS_PRERELEASE" -eq 0 ]; then + echo "Error: PR must have either 'release' or 'prerelease' label" + exit 1 + fi + + # Check for exactly one of major, minor, or patch + HAS_MAJOR=$(echo $LABELS | jq 'map(select(. == "major")) | length') + HAS_MINOR=$(echo $LABELS | jq 'map(select(. == "minor")) | length') + HAS_PATCH=$(echo $LABELS | jq 'map(select(. == "patch")) | length') + + TOTAL=$((HAS_MAJOR + HAS_MINOR + HAS_PATCH)) + if [ "$TOTAL" -ne 1 ]; then + echo "Error: PR must have exactly one of 'major', 'minor', or 'patch' labels" + exit 1 + fi + + # For prereleases, check for exactly one of alpha, beta, or rc + if [ "$HAS_PRERELEASE" -eq 1 ]; then + HAS_ALPHA=$(echo $LABELS | jq 'map(select(. == "alpha")) | length') + HAS_BETA=$(echo $LABELS | jq 'map(select(. == "beta")) | length') + HAS_RC=$(echo $LABELS | jq 'map(select(. == "rc")) | length') + + PRERELEASE_TOTAL=$((HAS_ALPHA + HAS_BETA + HAS_RC)) + if [ "$PRERELEASE_TOTAL" -ne 1 ]; then + echo "Error: Prerelease PR must have exactly one of 'alpha', 'beta', or 'rc' labels" + exit 1 + fi + + echo "IS_PRERELEASE=true" >> $GITHUB_OUTPUT + if [ "$HAS_ALPHA" -eq 1 ]; then + echo "PRERELEASE_TYPE=alpha" >> $GITHUB_OUTPUT + elif [ "$HAS_BETA" -eq 1 ]; then + echo "PRERELEASE_TYPE=beta" >> $GITHUB_OUTPUT + else + echo "PRERELEASE_TYPE=rc" >> $GITHUB_OUTPUT + fi + else + echo "IS_PRERELEASE=false" >> $GITHUB_OUTPUT + fi + + # Set version bump type + if [ "$HAS_MAJOR" -eq 1 ]; then + echo "BUMP_TYPE=major" >> $GITHUB_OUTPUT + elif [ "$HAS_MINOR" -eq 1 ]; then + echo "BUMP_TYPE=minor" >> $GITHUB_OUTPUT + else + echo "BUMP_TYPE=patch" >> $GITHUB_OUTPUT + fi + + - name: Get current version + id: get_version + run: | + # Extract version from pyproject.toml + CURRENT_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + echo "Current version: $CURRENT_VERSION" + echo "CURRENT_VERSION=$CURRENT_VERSION" >> $GITHUB_OUTPUT + + # Parse version components (handles both X.Y.Z and X.Y.Z-prerelease.N) + if [[ $CURRENT_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-([a-z]+)\.([0-9]+))?$ ]]; then + echo "MAJOR=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT + echo "MINOR=${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT + echo "PATCH=${BASH_REMATCH[3]}" >> $GITHUB_OUTPUT + if [ -n "${BASH_REMATCH[5]}" ]; then + echo "CURRENT_PRERELEASE_TYPE=${BASH_REMATCH[5]}" >> $GITHUB_OUTPUT + echo "CURRENT_PRERELEASE_NUM=${BASH_REMATCH[6]}" >> $GITHUB_OUTPUT + fi + else + echo "Error: Could not parse version from pyproject.toml" + exit 1 + fi + + - name: Calculate new version + id: calc_version + run: | + MAJOR=${{ steps.get_version.outputs.MAJOR }} + MINOR=${{ steps.get_version.outputs.MINOR }} + PATCH=${{ steps.get_version.outputs.PATCH }} + BUMP_TYPE=${{ steps.validate_labels.outputs.BUMP_TYPE }} + IS_PRERELEASE=${{ steps.validate_labels.outputs.IS_PRERELEASE }} + PRERELEASE_TYPE=${{ steps.validate_labels.outputs.PRERELEASE_TYPE }} + CURRENT_PRERELEASE_TYPE=${{ steps.get_version.outputs.CURRENT_PRERELEASE_TYPE }} + CURRENT_PRERELEASE_NUM=${{ steps.get_version.outputs.CURRENT_PRERELEASE_NUM }} + + # Calculate new version based on bump type + if [ "$BUMP_TYPE" == "major" ]; then + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + elif [ "$BUMP_TYPE" == "minor" ]; then + MINOR=$((MINOR + 1)) + PATCH=0 + else + PATCH=$((PATCH + 1)) + fi + + # Handle prerelease versioning + if [ "$IS_PRERELEASE" == "true" ]; then + # Check if we're continuing the same prerelease type + if [ "$CURRENT_PRERELEASE_TYPE" == "$PRERELEASE_TYPE" ] && [ "$BUMP_TYPE" == "patch" ]; then + # Increment prerelease number + PRERELEASE_NUM=$((CURRENT_PRERELEASE_NUM + 1)) + else + # New prerelease series + PRERELEASE_NUM=0 + fi + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}-${PRERELEASE_TYPE}.${PRERELEASE_NUM}" + else + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" + fi + + echo "New version: $NEW_VERSION" + echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Update copyright years + if: steps.validate_labels.outputs.IS_PRERELEASE == 'false' + run: | + python scripts/update_copyright.py + + - name: Update version in pyproject.toml + run: | + sed -i 's/^version = ".*"/version = "${{ steps.calc_version.outputs.NEW_VERSION }}"/' pyproject.toml + echo "Updated pyproject.toml to version ${{ steps.calc_version.outputs.NEW_VERSION }}" + + - name: Update CHANGELOG.md + id: update_changelog + run: | + NEW_VERSION=${{ steps.calc_version.outputs.NEW_VERSION }} + TODAY=$(date +%Y-%m-%d) + + # Replace [Unreleased] with new version + sed -i "s/## \[Unreleased\]/## [${NEW_VERSION}] - ${TODAY}/" CHANGELOG.md + + # Add new [Unreleased] section at the top (after the header) + sed -i "/## \[${NEW_VERSION}\]/i ## [Unreleased]\n" CHANGELOG.md + + - name: Commit changes + run: | + git add pyproject.toml CHANGELOG.md + if [ "${{ steps.validate_labels.outputs.IS_PRERELEASE }}" == "false" ]; then + git add -u # Add all modified files (for copyright updates) + fi + git commit -S -m "Release version ${{ steps.calc_version.outputs.NEW_VERSION }}" + git push origin main + + - name: Create and push tag + run: | + git tag -a "v${{ steps.calc_version.outputs.NEW_VERSION }}" -m "Release version ${{ steps.calc_version.outputs.NEW_VERSION }}" + git push origin "v${{ steps.calc_version.outputs.NEW_VERSION }}" + + - name: Extract changelog for release + id: extract_changelog + run: | + # Extract the changelog section for this version + VERSION=${{ steps.calc_version.outputs.NEW_VERSION }} + awk "/## \[${VERSION}\]/,/## \[/" CHANGELOG.md | sed '1d;$d' > release_notes.md + + # If empty, provide a default message + if [ ! -s release_notes.md ]; then + echo "No changelog entries for this release." > release_notes.md + fi + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build package + run: | + python -m build + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.calc_version.outputs.NEW_VERSION }} + name: Release ${{ steps.calc_version.outputs.NEW_VERSION }} + body_path: release_notes.md + draft: false + prerelease: ${{ steps.validate_labels.outputs.IS_PRERELEASE }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/scripts/update_copyright.py b/scripts/update_copyright.py new file mode 100755 index 0000000..9492a33 --- /dev/null +++ b/scripts/update_copyright.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Update copyright year ranges in Python source files. + +This script searches for copyright notices in Python files and updates +the year range to include the current year. +""" + +import os +import re +from datetime import datetime +from pathlib import Path + + +def update_copyright_in_file(filepath: Path, current_year: int) -> bool: + """ + Update copyright year in a single file. + + Args: + filepath: Path to the file to update + current_year: Current year to update to + + Returns: + True if file was modified, False otherwise + """ + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + original_content = content + + # Pattern 1: Copyright YYYY-YYYY (range format) + # Example: # Copyright 2022-2024 -> # Copyright 2022-2025 + pattern1 = re.compile( + r'(#\s*Copyright\s+)(\d{4})-(\d{4})', + re.IGNORECASE + ) + + def replace_range(match): + prefix = match.group(1) + start_year = match.group(2) + end_year = match.group(3) + + # Update end year if it's not current + if int(end_year) < current_year: + return f"{prefix}{start_year}-{current_year}" + return match.group(0) + + content = pattern1.sub(replace_range, content) + + # Pattern 2: Copyright YYYY (single year) + # Example: # Copyright 2024 -> # Copyright 2024-2025 (if not current year) + pattern2 = re.compile( + r'(#\s*Copyright\s+)(\d{4})(?!-)', + re.IGNORECASE + ) + + def replace_single(match): + prefix = match.group(1) + year = match.group(2) + + # If it's not the current year, make it a range + if int(year) < current_year: + return f"{prefix}{year}-{current_year}" + return match.group(0) + + content = pattern2.sub(replace_single, content) + + # Only write if content changed + if content != original_content: + with open(filepath, 'w', encoding='utf-8') as f: + f.write(content) + return True + + return False + + +def main(): + """Main function to update copyright years in all Python files.""" + current_year = datetime.now().year + repo_root = Path(__file__).parent.parent + + # Directories to exclude + exclude_dirs = { + 'proto', + 'venv', + '.venv', + 'env', + '.env', + 'build', + 'dist', + '.git', + '__pycache__', + '.pytest_cache', + '.tox', + 'node_modules', + '.eggs', + '*.egg-info', + } + + files_updated = 0 + files_processed = 0 + + # Walk through all Python files + for root, dirs, files in os.walk(repo_root): + # Remove excluded directories from dirs list (modifies in-place) + dirs[:] = [d for d in dirs if d not in exclude_dirs and not d.startswith('.')] + + for filename in files: + if filename.endswith('.py'): + filepath = Path(root) / filename + files_processed += 1 + + try: + if update_copyright_in_file(filepath, current_year): + print(f"Updated: {filepath.relative_to(repo_root)}") + files_updated += 1 + except Exception as e: + print(f"Error processing {filepath.relative_to(repo_root)}: {e}") + + print(f"\nProcessed {files_processed} files, updated {files_updated} files.") + + +if __name__ == '__main__': + main() From 59b30b1f8c17da2b3b03ec965c2f440369b3c25a Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Mon, 3 Nov 2025 20:17:03 -0500 Subject: [PATCH 04/15] Convert CHANGELOG to Keep a Changelog format - Add [Unreleased] section for upcoming changes - Convert version headers to [X.Y.Z] - YYYY-MM-DD format - Reorganize sections using Added/Changed/Fixed/Removed categories - Add version comparison links at bottom - Follows Keep a Changelog specification --- CHANGELOG.md | 200 ++++++++++++++++++++------------------------------- 1 file changed, 78 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c6264..2044fec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,206 +1,162 @@ -# Change Log +# Changelog -## Version 0.7.0 +All notable changes to this project will be documented in this file. -### Features +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -- Created a general implementation of the implicit discipline client for - OpenMDAO. The client creates an OpenMDAO ImplicitComponent which can - be added to any OpenMDAO model. +## [Unreleased] + +## [0.7.0] - 2024-12-18 + +### Added + +- Created a general implementation of the implicit discipline client for OpenMDAO. The client creates an OpenMDAO ImplicitComponent which can be added to any OpenMDAO model. - Created an interface to host OpenMDAO groups in ExplicitServers. - Added integration tests for OpenMDAO implicit components using the quadratic example - Added unit tests for OpenMDAO linearize, solve_nonlinear, and apply_nonlinear functions -- Updated Philote protocol to version 0.7.0 - Moved tests out of the package structure -- Improved test coverage (100% lines covered by unit and integration tests, excluding generated files) -### Bug Fixes - -- Added a check if OpenMDAO is installed before defining any classes that use - OpenMDAO types. This is a bug that has never (to my knowledge) been - encountered, but could force non-OpenMDAO users to install the package, even - though they have no use for it. -- Fixed grpcio-tools build dependency issue. Under certain circumstances (e.g., use of an older grpcio package), the - installation will fail due to an incompatible grpcio-tools version getting installed at build time. The grpcio-tools - version has been fixed for the build at 1.59. As a result the grpcio version also must at least be 1.59 - -### Documentation & Infrastructure +### Changed +- Updated Philote protocol to version 0.7.0 +- Improved test coverage (100% lines covered by unit and integration tests, excluding generated files) - Updated copyright statements across the codebase +### Fixed -## Version 0.6.1 - -### Features +- Added a check if OpenMDAO is installed before defining any classes that use OpenMDAO types. This prevents forcing non-OpenMDAO users to install the package. +- Fixed grpcio-tools build dependency issue. Under certain circumstances (e.g., use of an older grpcio package), the installation will fail due to an incompatible grpcio-tools version getting installed at build time. The grpcio-tools version has been fixed for the build at 1.59. As a result the grpcio version also must at least be 1.59 -- None +## [0.6.1] - 2024-03-15 -### Bug Fixes +### Fixed - Fixed grpcio-tools build dependency issue. Under certain circumstances (e.g., use of an older grpcio package), the installation will fail due to an incompatible grpcio-tools version getting installed at build time. The grpcio-tools version has been fixed for the build at 1.59. As a result the grpcio version also must at least be 1.59 -## Version 0.6.0 - -### Features - -- Added a mechanism for the server to provide a list of available options - (with associated types). -- Created a general implementation of the explicit discipline client for - OpenMDAO. The client creates an OpenMDAO ExplicitComponent which can - be added to any OpenMDAO model. - -### Bug Fixes - -- None +## [0.6.0] - 2024-02-01 +### Added -## Version 0.5.3 +- Added a mechanism for the server to provide a list of available options (with associated types). +- Created a general implementation of the explicit discipline client for OpenMDAO. The client creates an OpenMDAO ExplicitComponent which can be added to any OpenMDAO model. -### Features +## [0.5.3] - 2023-11-15 -- None - -### Bug Fixes +### Fixed - Added missing function arguments to explicit discipline. +## [0.5.2] - 2023-11-10 -## Version 0.5.2 - -### Features - -- None - -### Bug Fixes +### Fixed - Lowered the dependency versions (they were far too stringent and new) -- Change PyPI deployment to source only. It is not practical to distribute - a platform-specific wheel. The wheel must be platform-specific, because gRPC - has C underpinnings. - +- Change PyPI deployment to source only. It is not practical to distribute a platform-specific wheel. The wheel must be platform-specific, because gRPC has C underpinnings. -## Version 0.5.1 +## [0.5.1] - 2023-11-05 -### Features +### Added -- Transitioned away from setuptools and setup.py to a pyproject.toml - and poetry-based package. -- gRPC and protobuf stubs are now automatically compiled during - installation. +- Transitioned away from setuptools and setup.py to a pyproject.toml and poetry-based package. +- gRPC and protobuf stubs are now automatically compiled during installation. - Added test coverage report generation that is uploaded to coveralls. - Added action to upload to PyPI when a release is published. -### Bug Fixes +### Fixed - Lowered the dependency versions (they were far too stringent and new) -- Change PyPI deployment to source only. It is not practical to distribute - a platform-specific wheel. The wheel must be platform-specific, because gRPC - has C underpinnings. +- Change PyPI deployment to source only. It is not practical to distribute a platform-specific wheel. The wheel must be platform-specific, because gRPC has C underpinnings. +## [0.5.0] - 2023-11-01 -## Version 0.5.0 +### Removed -- yanked due to source distribution issues. All features present in 0.5.1 +- **YANKED**: This version was yanked due to source distribution issues. All features present in 0.5.1 +## [0.4.0] - 2023-10-15 -## Version 0.4.0 - -### Features +### Changed - General documentation updates. -### Bug Fixes - -- None - +## [0.3.0] - 2023-08-01 -## Version 0.3.0 +This release is one of the biggest changes to the code to date. It contains a fundamental reorganization and adds a number of features. Notably, it adds unit and integration testing of almost all the code. -This release is one of the biggest changes to the code to date. It contains a -fundamental reorganization and adds a number of features. Notably, it adds -unit and integration testing of almost all the code. +### Added -### Features - -- Reorganized codebase to reduce code duplication. The clients and servers now - use base classes. -- Protobuf/gRPC files are now generated at build time and not committed - to the repository. This requires grpc-tools and protoletariat to be installed. - See the readme for details. +- Reorganized codebase to reduce code duplication. The clients and servers now use base classes. +- Protobuf/gRPC files are now generated at build time and not committed to the repository. This requires grpc-tools and protoletariat to be installed. - Added a change log file to the repository. -- Updated API and logic to conform with newer Philote definition. - Added unit testing suite. - Added integration test suite (based on examples). - Completed implicit discipline functionality and testing. -- Fixed unit tests for GetVariableDefinitions and GetPartialsDefinitions. - Added edge case handling for partials of variables that are scalar. -- - -### Bug Fixes - -- Corrected the preallocate_inputs function for the implicit case to resolve - variable copy issues. -- Fixed typo in discrete input parsing. -- Moved to setup.py, as setuptools is still in beta for pyproject.toml. - Added jupyter book for documentation. - Added a quick start guide. +### Changed -## Version 0.2.1 +- Updated API and logic to conform with newer Philote definition. -This is purely a bugfix release. Thanks to Alex Xu for finding these bugs and fixing them. +### Fixed + +- Fixed unit tests for GetVariableDefinitions and GetPartialsDefinitions. +- Corrected the preallocate_inputs function for the implicit case to resolve variable copy issues. +- Fixed typo in discrete input parsing. +- Moved to setup.py, as setuptools is still in beta for pyproject.toml. -### Features +## [0.2.1] - 2023-06-15 -- None +This is purely a bugfix release. Thanks to Alex Xu for finding these bugs and fixing them. -### Bug Fixes +### Fixed - Fixed bug that prevented proper chunking of array data - Fixed flat view of arrays used during variable transfer - -## Version 0.2.0 +## [0.2.0] - 2023-05-01 This version augments the Philote MDO version to 0.3.0. -### Features +### Changed - Moved to Philote version 0.3.0 - Renamed RPC function from Compute to Functions for Philote 0.3.0 compatibility - Renamed RPC function from ComputePartials to Gradient for Philote 0.3.0 compatibility -### Bug Fixes - -- Added flattened views for the ndarrays received. The previous version would - error for n-dimensional arrays, as the slices would not work unless the array - was flattened. +### Fixed +- Added flattened views for the ndarrays received. The previous version would error for n-dimensional arrays, as the slices would not work unless the array was flattened. -## Version 0.1.0 +## [0.1.0] - 2023-03-01 -Initial release of the Philote MDO Python bindings. Includes working remote -explicit disciplines. Only the generic API currently works, so there is no -framework support for OpenMDAO or CSDL. +Initial release of the Philote MDO Python bindings. Includes working remote explicit disciplines. Only the generic API currently works, so there is no framework support for OpenMDAO or CSDL. -### Features +### Added - Implemented a remote explicit discipline analysis server API. - Implemented a corresponding client for explicit analyses. -- Added a simple parabaloid example to demonstrate the server/client in -action. - -### Bug Fixes - -- None, as this is the first release. +- Added a simple parabaloid example to demonstrate the server/client in action. ### Note -All versions starting with a 0 as the major version number should be -considered pre-release. While they may work in production environments, -it is expected that bugs may surface and that several features are still -missing. Because of this, the API may still change frequently before version -1.0.0 is released. +All versions starting with a 0 as the major version number should be considered pre-release. While they may work in production environments, it is expected that bugs may surface and that several features are still missing. Because of this, the API may still change frequently before version 1.0.0 is released. + +[unreleased]: https://github.com/MDO-Standards/Philote-Python/compare/v0.7.0...HEAD +[0.7.0]: https://github.com/MDO-Standards/Philote-Python/compare/v0.6.1...v0.7.0 +[0.6.1]: https://github.com/MDO-Standards/Philote-Python/compare/v0.6.0...v0.6.1 +[0.6.0]: https://github.com/MDO-Standards/Philote-Python/compare/v0.5.3...v0.6.0 +[0.5.3]: https://github.com/MDO-Standards/Philote-Python/compare/v0.5.2...v0.5.3 +[0.5.2]: https://github.com/MDO-Standards/Philote-Python/compare/v0.5.1...v0.5.2 +[0.5.1]: https://github.com/MDO-Standards/Philote-Python/compare/v0.5.0...v0.5.1 +[0.5.0]: https://github.com/MDO-Standards/Philote-Python/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/MDO-Standards/Philote-Python/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/MDO-Standards/Philote-Python/compare/v0.2.1...v0.3.0 +[0.2.1]: https://github.com/MDO-Standards/Philote-Python/compare/v0.2.0...v0.2.1 +[0.2.0]: https://github.com/MDO-Standards/Philote-Python/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/MDO-Standards/Philote-Python/releases/tag/v0.1.0 From aaece2ac56c45944aeedb41a98c1d206b71c0158 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Mon, 3 Nov 2025 20:18:46 -0500 Subject: [PATCH 05/15] Update CHANGELOG with unreleased changes Add entries for Julia wrapper infrastructure, automated release workflow, and CHANGELOG format conversion to [Unreleased] section --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2044fec..fe35db5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Julia discipline wrapper infrastructure for serving pure Julia Philote disciplines via Python gRPC server using juliacall +- JuliaWrapperDiscipline for explicit disciplines +- JuliaImplicitWrapperDiscipline for implicit disciplines +- YAML configuration support via PhiloteConfig for Julia disciplines +- CLI entry point (philote-julia-serve command) for serving Julia disciplines +- Optional 'julia' dependencies group (juliacall and pyyaml) +- Example Julia disciplines (paraboloid, quadratic) with configurations +- Automated release workflow using GitHub Actions +- Copyright update script for Python source files + +### Changed + +- Convert CHANGELOG to Keep a Changelog format with [Unreleased] section +- CHANGELOG now follows semantic versioning categories (Added/Changed/Fixed/Removed) + ## [0.7.0] - 2024-12-18 ### Added From a8630b2842a972ee007e3fb8baaac035ebfcca34 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Tue, 4 Nov 2025 18:02:15 -0500 Subject: [PATCH 06/15] Add comprehensive test suite for Julia integration - Add test_julia_integration.py with 6 gRPC integration tests for explicit and implicit Julia disciplines - Add test_julia_wrapper.py with 17 unit tests for JuliaWrapperDiscipline and JuliaImplicitWrapperDiscipline - Add test_julia_config.py with 21 tests for YAML configuration loading, validation, and error handling - Add test_julia_cli.py with 10 tests for CLI argument parsing, routing, and error handling Tests follow existing patterns from test_integration.py and include: - Automatic skipping when juliacall is not installed - Comprehensive error handling validation - Round-trip configuration testing - Numerical correctness verification Total: 54 new test cases covering the entire Julia integration feature --- tests/test_julia_cli.py | 211 +++++++++++++++ tests/test_julia_config.py | 339 ++++++++++++++++++++++++ tests/test_julia_integration.py | 312 ++++++++++++++++++++++ tests/test_julia_wrapper.py | 448 ++++++++++++++++++++++++++++++++ 4 files changed, 1310 insertions(+) create mode 100644 tests/test_julia_cli.py create mode 100644 tests/test_julia_config.py create mode 100644 tests/test_julia_integration.py create mode 100644 tests/test_julia_wrapper.py diff --git a/tests/test_julia_cli.py b/tests/test_julia_cli.py new file mode 100644 index 0000000..1c36397 --- /dev/null +++ b/tests/test_julia_cli.py @@ -0,0 +1,211 @@ +# Philote-Python +# +# Copyright 2022-2025 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. +import os +import sys +import tempfile +import unittest +from unittest.mock import patch, MagicMock +from io import StringIO + +try: + from philote_mdo.wrappers.julia import cli + HAS_JULIA_CLI = True +except ImportError: + HAS_JULIA_CLI = False + + +@unittest.skipIf(not HAS_JULIA_CLI, "Julia CLI module not available") +class JuliaCLITests(unittest.TestCase): + """ + Unit tests for the Julia CLI. + """ + + @classmethod + def setUpClass(cls): + """Set up paths to example config files.""" + tests_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(tests_dir) + configs_dir = os.path.join(project_root, "examples", "julia", "configs") + + cls.paraboloid_config = os.path.join(configs_dir, "paraboloid.yaml") + cls.quadratic_config = os.path.join(configs_dir, "quadratic.yaml") + + def test_missing_config_file(self): + """Test that missing config file exits with error.""" + with patch('sys.argv', ['philote-julia-serve', '/nonexistent/config.yaml']): + with patch('sys.stderr', new=StringIO()) as mock_stderr: + with self.assertRaises(SystemExit) as cm: + cli.main() + self.assertEqual(cm.exception.code, 1) + stderr_output = mock_stderr.getvalue() + self.assertIn("Error:", stderr_output) + self.assertIn("not found", stderr_output.lower()) + + def test_invalid_yaml_structure(self): + """Test that invalid YAML structure exits with error.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write("just a string, not a dict") + temp_path = f.name + + try: + with patch('sys.argv', ['philote-julia-serve', temp_path]): + with patch('sys.stderr', new=StringIO()) as mock_stderr: + with self.assertRaises(SystemExit) as cm: + cli.main() + self.assertEqual(cm.exception.code, 1) + stderr_output = mock_stderr.getvalue() + self.assertIn("error", stderr_output.lower()) + finally: + os.unlink(temp_path) + + def test_missing_discipline_section(self): + """Test that missing discipline section exits with error.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write("server:\n address: '[::]:50051'\n") + temp_path = f.name + + try: + with patch('sys.argv', ['philote-julia-serve', temp_path]): + with patch('sys.stderr', new=StringIO()) as mock_stderr: + with self.assertRaises(SystemExit) as cm: + cli.main() + self.assertEqual(cm.exception.code, 1) + stderr_output = mock_stderr.getvalue() + self.assertIn("Configuration error", stderr_output) + self.assertIn("discipline", stderr_output.lower()) + finally: + os.unlink(temp_path) + + @patch('philote_mdo.wrappers.julia.cli.serve_explicit_discipline') + def test_route_to_explicit_server(self, mock_serve_explicit): + """Test that explicit discipline routes to explicit server.""" + with patch('sys.argv', ['philote-julia-serve', self.paraboloid_config]): + # Mock the serve function to avoid actually starting a server + mock_serve_explicit.return_value = None + + cli.main() + + # Verify that serve_explicit_discipline was called + mock_serve_explicit.assert_called_once() + config = mock_serve_explicit.call_args[0][0] + self.assertEqual(config.discipline.kind, "explicit") + + @patch('philote_mdo.wrappers.julia.cli.serve_implicit_discipline') + def test_route_to_implicit_server(self, mock_serve_implicit): + """Test that implicit discipline routes to implicit server.""" + with patch('sys.argv', ['philote-julia-serve', self.quadratic_config]): + # Mock the serve function to avoid actually starting a server + mock_serve_implicit.return_value = None + + cli.main() + + # Verify that serve_implicit_discipline was called + mock_serve_implicit.assert_called_once() + config = mock_serve_implicit.call_args[0][0] + self.assertEqual(config.discipline.kind, "implicit") + + def test_version_flag(self): + """Test that --version flag displays version and exits.""" + with patch('sys.argv', ['philote-julia-serve', '--version']): + with patch('sys.stdout', new=StringIO()) as mock_stdout: + with self.assertRaises(SystemExit) as cm: + cli.main() + # argparse exits with code 0 for --version + self.assertEqual(cm.exception.code, 0) + stdout_output = mock_stdout.getvalue() + self.assertIn("0.1.0", stdout_output) + + def test_help_flag(self): + """Test that --help flag displays help and exits.""" + with patch('sys.argv', ['philote-julia-serve', '--help']): + with patch('sys.stdout', new=StringIO()) as mock_stdout: + with self.assertRaises(SystemExit) as cm: + cli.main() + # argparse exits with code 0 for --help + self.assertEqual(cm.exception.code, 0) + stdout_output = mock_stdout.getvalue() + self.assertIn("Serve Julia Philote disciplines", stdout_output) + self.assertIn("Examples:", stdout_output) + + def test_no_arguments(self): + """Test that running without arguments shows error.""" + with patch('sys.argv', ['philote-julia-serve']): + with patch('sys.stderr', new=StringIO()) as mock_stderr: + with self.assertRaises(SystemExit) as cm: + cli.main() + # argparse exits with code 2 for missing required arguments + self.assertEqual(cm.exception.code, 2) + stderr_output = mock_stderr.getvalue() + self.assertIn("required", stderr_output.lower()) + + @patch('philote_mdo.wrappers.julia.cli.serve_explicit_discipline') + def test_config_loading_with_valid_file(self, mock_serve_explicit): + """Test that valid config file is loaded correctly.""" + with patch('sys.argv', ['philote-julia-serve', self.paraboloid_config]): + mock_serve_explicit.return_value = None + + cli.main() + + # Verify config was loaded correctly + config = mock_serve_explicit.call_args[0][0] + self.assertEqual(config.discipline.julia_type, "ParaboloidDiscipline") + self.assertTrue(config.discipline.julia_file.endswith("paraboloid.jl")) + + def test_invalid_discipline_kind_in_config(self): + """Test that invalid discipline kind exits with error.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create a Julia file + julia_file = os.path.join(tmpdir, "test.jl") + with open(julia_file, 'w') as f: + f.write("# Test file\n") + + # Create a config with invalid kind (will pass PhiloteConfig validation + # but this tests the routing logic) + config_file = os.path.join(tmpdir, "config.yaml") + with open(config_file, 'w') as f: + f.write( + "discipline:\n" + " kind: invalid_kind\n" + f" julia_file: {julia_file}\n" + " julia_type: TestDiscipline\n" + ) + + with patch('sys.argv', ['philote-julia-serve', config_file]): + with patch('sys.stderr', new=StringIO()) as mock_stderr: + with self.assertRaises(SystemExit) as cm: + cli.main() + # Should exit during config validation + self.assertEqual(cm.exception.code, 1) + stderr_output = mock_stderr.getvalue() + self.assertIn("error", stderr_output.lower()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_julia_config.py b/tests/test_julia_config.py new file mode 100644 index 0000000..05980bd --- /dev/null +++ b/tests/test_julia_config.py @@ -0,0 +1,339 @@ +# Philote-Python +# +# Copyright 2022-2025 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. +import os +import tempfile +import unittest + +try: + from philote_mdo.wrappers.julia.config import PhiloteConfig, DisciplineConfig, ServerConfig + HAS_JULIA_CONFIG = True +except ImportError: + HAS_JULIA_CONFIG = False + + +@unittest.skipIf(not HAS_JULIA_CONFIG, "Julia config module not available") +class DisciplineConfigTests(unittest.TestCase): + """ + Unit tests for DisciplineConfig validation. + """ + + def test_valid_explicit_discipline(self): + """Test creating a valid explicit discipline config.""" + config = DisciplineConfig( + kind="explicit", + julia_file="/path/to/file.jl", + julia_type="MyDiscipline" + ) + self.assertEqual(config.kind, "explicit") + self.assertEqual(config.julia_file, "/path/to/file.jl") + self.assertEqual(config.julia_type, "MyDiscipline") + + def test_valid_implicit_discipline(self): + """Test creating a valid implicit discipline config.""" + config = DisciplineConfig( + kind="implicit", + julia_file="/path/to/file.jl", + julia_type="MyDiscipline" + ) + self.assertEqual(config.kind, "implicit") + + def test_invalid_kind(self): + """Test that invalid kind raises ValueError.""" + with self.assertRaises(ValueError) as cm: + DisciplineConfig( + kind="invalid", + julia_file="/path/to/file.jl", + julia_type="MyDiscipline" + ) + self.assertIn("must be 'explicit' or 'implicit'", str(cm.exception)) + + def test_missing_julia_file(self): + """Test that empty julia_file raises ValueError.""" + with self.assertRaises(ValueError) as cm: + DisciplineConfig( + kind="explicit", + julia_file="", + julia_type="MyDiscipline" + ) + self.assertIn("julia_file is required", str(cm.exception)) + + def test_missing_julia_type(self): + """Test that empty julia_type raises ValueError.""" + with self.assertRaises(ValueError) as cm: + DisciplineConfig( + kind="explicit", + julia_file="/path/to/file.jl", + julia_type="" + ) + self.assertIn("julia_type is required", str(cm.exception)) + + def test_with_options(self): + """Test discipline config with options.""" + config = DisciplineConfig( + kind="explicit", + julia_file="/path/to/file.jl", + julia_type="MyDiscipline", + options={"scale_factor": 2.0, "offset": 10.0} + ) + self.assertEqual(config.options["scale_factor"], 2.0) + self.assertEqual(config.options["offset"], 10.0) + + +@unittest.skipIf(not HAS_JULIA_CONFIG, "Julia config module not available") +class ServerConfigTests(unittest.TestCase): + """ + Unit tests for ServerConfig validation. + """ + + def test_default_server_config(self): + """Test default server configuration.""" + config = ServerConfig() + self.assertEqual(config.address, "[::]:50051") + self.assertEqual(config.max_workers, 10) + + def test_custom_server_config(self): + """Test custom server configuration.""" + config = ServerConfig(address="localhost:8080", max_workers=20) + self.assertEqual(config.address, "localhost:8080") + self.assertEqual(config.max_workers, 20) + + def test_invalid_max_workers_zero(self): + """Test that max_workers=0 raises ValueError.""" + with self.assertRaises(ValueError) as cm: + ServerConfig(max_workers=0) + self.assertIn("max_workers must be >= 1", str(cm.exception)) + + def test_invalid_max_workers_negative(self): + """Test that negative max_workers raises ValueError.""" + with self.assertRaises(ValueError) as cm: + ServerConfig(max_workers=-5) + self.assertIn("max_workers must be >= 1", str(cm.exception)) + + +@unittest.skipIf(not HAS_JULIA_CONFIG, "Julia config module not available") +class PhiloteConfigTests(unittest.TestCase): + """ + Unit tests for PhiloteConfig YAML loading and writing. + """ + + @classmethod + def setUpClass(cls): + """Set up paths to example config files.""" + tests_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(tests_dir) + configs_dir = os.path.join(project_root, "examples", "julia", "configs") + + cls.paraboloid_config = os.path.join(configs_dir, "paraboloid.yaml") + cls.quadratic_config = os.path.join(configs_dir, "quadratic.yaml") + + def test_load_explicit_config(self): + """Test loading explicit discipline configuration from YAML.""" + config = PhiloteConfig.from_yaml(self.paraboloid_config) + + self.assertEqual(config.discipline.kind, "explicit") + self.assertEqual(config.discipline.julia_type, "ParaboloidDiscipline") + self.assertTrue(config.discipline.julia_file.endswith("paraboloid.jl")) + self.assertTrue(os.path.exists(config.discipline.julia_file)) + + def test_load_implicit_config(self): + """Test loading implicit discipline configuration from YAML.""" + config = PhiloteConfig.from_yaml(self.quadratic_config) + + self.assertEqual(config.discipline.kind, "implicit") + self.assertEqual(config.discipline.julia_type, "QuadraticDiscipline") + self.assertTrue(config.discipline.julia_file.endswith("quadratic.jl")) + self.assertTrue(os.path.exists(config.discipline.julia_file)) + + def test_load_missing_file(self): + """Test that loading non-existent config file raises FileNotFoundError.""" + with self.assertRaises(FileNotFoundError) as cm: + PhiloteConfig.from_yaml("/nonexistent/config.yaml") + self.assertIn("Configuration file not found", str(cm.exception)) + + def test_load_invalid_yaml_structure(self): + """Test that invalid YAML structure raises ValueError.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write("just a string, not a dict") + temp_path = f.name + + try: + with self.assertRaises(ValueError) as cm: + PhiloteConfig.from_yaml(temp_path) + self.assertIn("Invalid YAML", str(cm.exception)) + finally: + os.unlink(temp_path) + + def test_load_missing_discipline_section(self): + """Test that missing discipline section raises ValueError.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write("server:\n address: '[::]:50051'\n") + temp_path = f.name + + try: + with self.assertRaises(ValueError) as cm: + PhiloteConfig.from_yaml(temp_path) + self.assertIn("Missing required 'discipline' section", str(cm.exception)) + finally: + os.unlink(temp_path) + + def test_load_missing_julia_file_in_yaml(self): + """Test that missing julia_file field in YAML raises ValueError.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write("discipline:\n kind: explicit\n julia_type: MyDiscipline\n") + temp_path = f.name + + try: + with self.assertRaises(ValueError) as cm: + PhiloteConfig.from_yaml(temp_path) + self.assertIn("julia_file is required", str(cm.exception)) + finally: + os.unlink(temp_path) + + def test_load_nonexistent_julia_file(self): + """Test that non-existent julia_file raises FileNotFoundError.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write("discipline:\n kind: explicit\n julia_file: /nonexistent/file.jl\n julia_type: MyDiscipline\n") + temp_path = f.name + + try: + with self.assertRaises(FileNotFoundError) as cm: + PhiloteConfig.from_yaml(temp_path) + self.assertIn("Julia file not found", str(cm.exception)) + finally: + os.unlink(temp_path) + + def test_relative_path_resolution(self): + """Test that relative julia_file paths are resolved correctly.""" + # Create a temporary directory structure + with tempfile.TemporaryDirectory() as tmpdir: + # Create a Julia file + julia_file = os.path.join(tmpdir, "test.jl") + with open(julia_file, 'w') as f: + f.write("# Test file\n") + + # Create a config file with relative path + config_file = os.path.join(tmpdir, "config.yaml") + with open(config_file, 'w') as f: + f.write("discipline:\n kind: explicit\n julia_file: test.jl\n julia_type: MyDiscipline\n") + + # Load config and verify path was resolved + config = PhiloteConfig.from_yaml(config_file) + self.assertEqual(config.discipline.julia_file, julia_file) + self.assertTrue(os.path.isabs(config.discipline.julia_file)) + + def test_round_trip_save_load(self): + """Test saving and loading configuration (round trip).""" + # Create a temporary directory + with tempfile.TemporaryDirectory() as tmpdir: + # Create a Julia file + julia_file = os.path.join(tmpdir, "test.jl") + with open(julia_file, 'w') as f: + f.write("# Test file\n") + + # Create a config + original_config = PhiloteConfig( + discipline=DisciplineConfig( + kind="explicit", + julia_file=julia_file, + julia_type="TestDiscipline", + options={"param": 42} + ), + server=ServerConfig( + address="localhost:9999", + max_workers=5 + ) + ) + + # Save to YAML + config_file = os.path.join(tmpdir, "config.yaml") + original_config.to_yaml(config_file) + + # Load it back + loaded_config = PhiloteConfig.from_yaml(config_file) + + # Verify everything matches + self.assertEqual(loaded_config.discipline.kind, "explicit") + self.assertEqual(loaded_config.discipline.julia_type, "TestDiscipline") + self.assertEqual(loaded_config.discipline.options["param"], 42) + self.assertEqual(loaded_config.server.address, "localhost:9999") + self.assertEqual(loaded_config.server.max_workers, 5) + + def test_config_with_options(self): + """Test loading configuration with discipline options.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create a Julia file + julia_file = os.path.join(tmpdir, "test.jl") + with open(julia_file, 'w') as f: + f.write("# Test file\n") + + # Create a config file with options + config_file = os.path.join(tmpdir, "config.yaml") + with open(config_file, 'w') as f: + f.write( + "discipline:\n" + " kind: explicit\n" + f" julia_file: {julia_file}\n" + " julia_type: TestDiscipline\n" + " options:\n" + " scale_factor: 2.0\n" + " offset: 10.0\n" + ) + + # Load and verify + config = PhiloteConfig.from_yaml(config_file) + self.assertEqual(config.discipline.options["scale_factor"], 2.0) + self.assertEqual(config.discipline.options["offset"], 10.0) + + def test_config_default_server(self): + """Test that server config has defaults when not specified in YAML.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create a Julia file + julia_file = os.path.join(tmpdir, "test.jl") + with open(julia_file, 'w') as f: + f.write("# Test file\n") + + # Create a config file without server section + config_file = os.path.join(tmpdir, "config.yaml") + with open(config_file, 'w') as f: + f.write( + "discipline:\n" + " kind: explicit\n" + f" julia_file: {julia_file}\n" + " julia_type: TestDiscipline\n" + ) + + # Load and verify defaults + config = PhiloteConfig.from_yaml(config_file) + self.assertEqual(config.server.address, "[::]:50051") + self.assertEqual(config.server.max_workers, 10) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_julia_integration.py b/tests/test_julia_integration.py new file mode 100644 index 0000000..2ee01cd --- /dev/null +++ b/tests/test_julia_integration.py @@ -0,0 +1,312 @@ +# Philote-Python +# +# Copyright 2022-2025 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 concurrent import futures +import os +import unittest +import grpc +import numpy as np +import philote_mdo.general as pmdo + +try: + from philote_mdo.wrappers.julia import JuliaWrapperDiscipline, JuliaImplicitWrapperDiscipline + HAS_JULIACALL = True +except ImportError: + HAS_JULIACALL = False + + +@unittest.skipIf(not HAS_JULIACALL, "juliacall not installed") +class JuliaIntegrationTests(unittest.TestCase): + """ + Integration tests for Julia discipline wrappers via gRPC. + """ + + @classmethod + def setUpClass(cls): + """Set up paths to example Julia files.""" + # Get the project root directory + tests_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(tests_dir) + examples_dir = os.path.join(project_root, "examples", "julia") + + cls.paraboloid_file = os.path.join(examples_dir, "paraboloid.jl") + cls.quadratic_file = os.path.join(examples_dir, "quadratic.jl") + + def test_julia_paraboloid_compute(self): + """ + Integration test for Julia Paraboloid compute function via gRPC. + """ + # server code + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + + discipline = JuliaWrapperDiscipline( + julia_file=self.paraboloid_file, + julia_type="ParaboloidDiscipline" + ) + explicit_server = pmdo.ExplicitServer(discipline=discipline) + explicit_server.attach_to_server(server) + + server.add_insecure_port("[::]:50051") + server.start() + + # client code + client = pmdo.ExplicitClient(channel=grpc.insecure_channel("localhost:50051")) + + # transfer the stream options to the server + client.send_stream_options() + + # run setup + client.run_setup() + client.get_variable_definitions() + client.get_partials_definitions() + + # define some inputs + inputs = {"x": np.array([1.0]), "y": np.array([2.0])} + + # run a function evaluation + outputs = client.run_compute(inputs) + + self.assertEqual(outputs["f_xy"][0], 39.0) + + # stop the server + server.stop(0) + + def test_julia_paraboloid_compute_partials(self): + """ + Integration test for Julia Paraboloid compute_partials function via gRPC. + """ + # server code + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + + discipline = JuliaWrapperDiscipline( + julia_file=self.paraboloid_file, + julia_type="ParaboloidDiscipline" + ) + explicit_server = pmdo.ExplicitServer(discipline=discipline) + explicit_server.attach_to_server(server) + + server.add_insecure_port("[::]:50051") + server.start() + + # client code + client = pmdo.ExplicitClient(channel=grpc.insecure_channel("localhost:50051")) + + # transfer the stream options to the server + client.send_stream_options() + + # run setup + client.run_setup() + client.get_variable_definitions() + client.get_partials_definitions() + + # define some inputs + inputs = {"x": np.array([1.0]), "y": np.array([2.0])} + + # run compute_partials + jac = client.run_compute_partials(inputs) + + self.assertEqual(jac["f_xy", "x"][0], -2.0) + self.assertEqual(jac["f_xy", "y"][0], 13.0) + + # stop the server + server.stop(0) + + def test_julia_paraboloid_with_options(self): + """ + Integration test for Julia Paraboloid with custom options via gRPC. + """ + # server code + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + + discipline = JuliaWrapperDiscipline( + julia_file=self.paraboloid_file, + julia_type="ParaboloidDiscipline", + options={"scale_factor": 2.0, "offset": 10.0} + ) + explicit_server = pmdo.ExplicitServer(discipline=discipline) + explicit_server.attach_to_server(server) + + server.add_insecure_port("[::]:50051") + server.start() + + # client code + client = pmdo.ExplicitClient(channel=grpc.insecure_channel("localhost:50051")) + + # transfer the stream options to the server + client.send_stream_options() + + # run setup + client.run_setup() + client.get_variable_definitions() + client.get_partials_definitions() + + # define some inputs + inputs = {"x": np.array([1.0]), "y": np.array([2.0])} + + # run a function evaluation + outputs = client.run_compute(inputs) + + # With scale_factor=2.0 and offset=10.0: f = 2.0 * 39.0 + 10.0 = 88.0 + self.assertEqual(outputs["f_xy"][0], 88.0) + + # run compute_partials + jac = client.run_compute_partials(inputs) + + # Partials are also scaled + self.assertEqual(jac["f_xy", "x"][0], -4.0) + self.assertEqual(jac["f_xy", "y"][0], 26.0) + + # stop the server + server.stop(0) + + def test_julia_quadratic_compute_residuals(self): + """ + Integration test for Julia Quadratic compute_residuals function via gRPC. + """ + # server code + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + + discipline = JuliaImplicitWrapperDiscipline( + julia_file=self.quadratic_file, + julia_type="QuadraticDiscipline" + ) + implicit_server = pmdo.ImplicitServer(discipline=discipline) + implicit_server.attach_to_server(server) + + server.add_insecure_port("[::]:50051") + server.start() + + # client code + client = pmdo.ImplicitClient(channel=grpc.insecure_channel("localhost:50051")) + + # transfer the stream options to the server + client.send_stream_options() + + # run setup + client.run_setup() + client.get_variable_definitions() + client.get_partials_definitions() + + # define some inputs + inputs = {"a": np.array([1.0]), "b": np.array([2.0]), "c": np.array([-2.0])} + outputs = {"x": np.array([4.0])} + + # run compute_residuals + residuals = client.run_compute_residuals(inputs, outputs) + + self.assertEqual(residuals["x"][0], 22.0) + + # stop the server + server.stop(0) + + def test_julia_quadratic_solve_residuals(self): + """ + Integration test for Julia Quadratic solve_residuals function via gRPC. + """ + # server code + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + + discipline = JuliaImplicitWrapperDiscipline( + julia_file=self.quadratic_file, + julia_type="QuadraticDiscipline" + ) + implicit_server = pmdo.ImplicitServer(discipline=discipline) + implicit_server.attach_to_server(server) + + server.add_insecure_port("[::]:50051") + server.start() + + # client code + client = pmdo.ImplicitClient(channel=grpc.insecure_channel("localhost:50051")) + + # transfer the stream options to the server + client.send_stream_options() + + # run setup + client.run_setup() + client.get_variable_definitions() + client.get_partials_definitions() + + # define some inputs + inputs = {"a": np.array([1.0]), "b": np.array([2.0]), "c": np.array([-2.0])} + + # run solve_residuals + outputs = client.run_solve_residuals(inputs) + + self.assertAlmostEqual(outputs["x"][0], 0.73205081, places=8) + + # stop the server + server.stop(0) + + def test_julia_quadratic_residual_gradients(self): + """ + Integration test for Julia Quadratic residual_gradients function via gRPC. + """ + # server code + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + + discipline = JuliaImplicitWrapperDiscipline( + julia_file=self.quadratic_file, + julia_type="QuadraticDiscipline" + ) + implicit_server = pmdo.ImplicitServer(discipline=discipline) + implicit_server.attach_to_server(server) + + server.add_insecure_port("[::]:50051") + server.start() + + # client code + client = pmdo.ImplicitClient(channel=grpc.insecure_channel("localhost:50051")) + + # transfer the stream options to the server + client.send_stream_options() + + # run setup + client.run_setup() + client.get_variable_definitions() + client.get_partials_definitions() + + # define some inputs + inputs = {"a": np.array([1.0]), "b": np.array([2.0]), "c": np.array([-2.0])} + outputs = {"x": np.array([4.0])} + + # run residual_gradients + jac = client.run_residual_gradients(inputs, outputs) + + self.assertEqual(jac[("x", "a")][0], 16.0) + self.assertEqual(jac[("x", "b")][0], 4.0) + self.assertEqual(jac[("x", "c")][0], 1.0) + self.assertEqual(jac[("x", "x")][0], 10.0) + + # stop the server + server.stop(0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_julia_wrapper.py b/tests/test_julia_wrapper.py new file mode 100644 index 0000000..9645117 --- /dev/null +++ b/tests/test_julia_wrapper.py @@ -0,0 +1,448 @@ +# Philote-Python +# +# Copyright 2022-2025 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. +import os +import unittest +import numpy as np + +try: + from philote_mdo.wrappers.julia import JuliaWrapperDiscipline, JuliaImplicitWrapperDiscipline + HAS_JULIACALL = True +except ImportError: + HAS_JULIACALL = False + + +@unittest.skipIf(not HAS_JULIACALL, "juliacall not installed") +class JuliaWrapperTests(unittest.TestCase): + """ + Unit tests for JuliaWrapperDiscipline (explicit disciplines). + """ + + @classmethod + def setUpClass(cls): + """Set up paths to example Julia files.""" + # Get the project root directory + tests_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(tests_dir) + examples_dir = os.path.join(project_root, "examples", "julia") + + cls.paraboloid_file = os.path.join(examples_dir, "paraboloid.jl") + cls.quadratic_file = os.path.join(examples_dir, "quadratic.jl") + + def test_initialization_successful(self): + """ + Test successful initialization of JuliaWrapperDiscipline. + """ + discipline = JuliaWrapperDiscipline( + julia_file=self.paraboloid_file, + julia_type="ParaboloidDiscipline" + ) + self.assertIsNotNone(discipline) + + def test_initialization_with_options(self): + """ + Test initialization with options. + """ + discipline = JuliaWrapperDiscipline( + julia_file=self.paraboloid_file, + julia_type="ParaboloidDiscipline", + options={"scale_factor": 2.0, "offset": 10.0} + ) + self.assertIsNotNone(discipline) + + def test_initialization_file_not_found(self): + """ + Test that FileNotFoundError is raised for missing Julia file. + """ + with self.assertRaises(FileNotFoundError): + JuliaWrapperDiscipline( + julia_file="/nonexistent/file.jl", + julia_type="SomeDiscipline" + ) + + def test_initialization_invalid_type(self): + """ + Test that ValueError is raised for invalid Julia type. + """ + with self.assertRaises(ValueError): + JuliaWrapperDiscipline( + julia_file=self.paraboloid_file, + julia_type="NonExistentType" + ) + + def test_setup_metadata(self): + """ + Test that setup correctly extracts metadata from Julia discipline. + """ + discipline = JuliaWrapperDiscipline( + julia_file=self.paraboloid_file, + julia_type="ParaboloidDiscipline" + ) + discipline.setup() + + # Check inputs + self.assertIn("x", discipline._inputs) + self.assertIn("y", discipline._inputs) + + # Check outputs + self.assertIn("f_xy", discipline._outputs) + + # Check partials + self.assertIn(("f_xy", "x"), discipline._partials) + self.assertIn(("f_xy", "y"), discipline._partials) + + def test_compute_basic(self): + """ + Test basic compute functionality. + """ + discipline = JuliaWrapperDiscipline( + julia_file=self.paraboloid_file, + julia_type="ParaboloidDiscipline" + ) + discipline.setup() + + inputs = {"x": np.array([1.0]), "y": np.array([2.0])} + outputs = {"f_xy": np.zeros(1)} + + discipline.compute(inputs, outputs) + + self.assertEqual(outputs["f_xy"][0], 39.0) + + def test_compute_different_inputs(self): + """ + Test compute with different input values. + """ + discipline = JuliaWrapperDiscipline( + julia_file=self.paraboloid_file, + julia_type="ParaboloidDiscipline" + ) + discipline.setup() + + inputs = {"x": np.array([2.0]), "y": np.array([3.0])} + outputs = {"f_xy": np.zeros(1)} + + discipline.compute(inputs, outputs) + + self.assertEqual(outputs["f_xy"][0], 53.0) + + def test_compute_with_options(self): + """ + Test compute with custom options. + """ + discipline = JuliaWrapperDiscipline( + julia_file=self.paraboloid_file, + julia_type="ParaboloidDiscipline", + options={"scale_factor": 2.0, "offset": 10.0} + ) + discipline.setup() + + inputs = {"x": np.array([1.0]), "y": np.array([2.0])} + outputs = {"f_xy": np.zeros(1)} + + discipline.compute(inputs, outputs) + + # f = 2.0 * 39.0 + 10.0 = 88.0 + self.assertEqual(outputs["f_xy"][0], 88.0) + + def test_compute_partials_basic(self): + """ + Test basic compute_partials functionality. + """ + discipline = JuliaWrapperDiscipline( + julia_file=self.paraboloid_file, + julia_type="ParaboloidDiscipline" + ) + discipline.setup() + + inputs = {"x": np.array([1.0]), "y": np.array([2.0])} + partials = { + ("f_xy", "x"): np.zeros(1), + ("f_xy", "y"): np.zeros(1) + } + + discipline.compute_partials(inputs, partials) + + self.assertEqual(partials[("f_xy", "x")][0], -2.0) + self.assertEqual(partials[("f_xy", "y")][0], 13.0) + + def test_compute_partials_different_inputs(self): + """ + Test compute_partials with different input values. + """ + discipline = JuliaWrapperDiscipline( + julia_file=self.paraboloid_file, + julia_type="ParaboloidDiscipline" + ) + discipline.setup() + + inputs = {"x": np.array([2.0]), "y": np.array([3.0])} + partials = { + ("f_xy", "x"): np.zeros(1), + ("f_xy", "y"): np.zeros(1) + } + + discipline.compute_partials(inputs, partials) + + self.assertEqual(partials[("f_xy", "x")][0], 1.0) + self.assertEqual(partials[("f_xy", "y")][0], 16.0) + + def test_compute_partials_with_options(self): + """ + Test compute_partials with custom options. + """ + discipline = JuliaWrapperDiscipline( + julia_file=self.paraboloid_file, + julia_type="ParaboloidDiscipline", + options={"scale_factor": 2.0, "offset": 10.0} + ) + discipline.setup() + + inputs = {"x": np.array([1.0]), "y": np.array([2.0])} + partials = { + ("f_xy", "x"): np.zeros(1), + ("f_xy", "y"): np.zeros(1) + } + + discipline.compute_partials(inputs, partials) + + # Partials are scaled by scale_factor + self.assertEqual(partials[("f_xy", "x")][0], -4.0) + self.assertEqual(partials[("f_xy", "y")][0], 26.0) + + +@unittest.skipIf(not HAS_JULIACALL, "juliacall not installed") +class JuliaImplicitWrapperTests(unittest.TestCase): + """ + Unit tests for JuliaImplicitWrapperDiscipline (implicit disciplines). + """ + + @classmethod + def setUpClass(cls): + """Set up paths to example Julia files.""" + # Get the project root directory + tests_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(tests_dir) + examples_dir = os.path.join(project_root, "examples", "julia") + + cls.quadratic_file = os.path.join(examples_dir, "quadratic.jl") + + def test_initialization_successful(self): + """ + Test successful initialization of JuliaImplicitWrapperDiscipline. + """ + discipline = JuliaImplicitWrapperDiscipline( + julia_file=self.quadratic_file, + julia_type="QuadraticDiscipline" + ) + self.assertIsNotNone(discipline) + + def test_initialization_file_not_found(self): + """ + Test that FileNotFoundError is raised for missing Julia file. + """ + with self.assertRaises(FileNotFoundError): + JuliaImplicitWrapperDiscipline( + julia_file="/nonexistent/file.jl", + julia_type="SomeDiscipline" + ) + + def test_initialization_invalid_type(self): + """ + Test that ValueError is raised for invalid Julia type. + """ + with self.assertRaises(ValueError): + JuliaImplicitWrapperDiscipline( + julia_file=self.quadratic_file, + julia_type="NonExistentType" + ) + + def test_setup_metadata(self): + """ + Test that setup correctly extracts metadata from Julia discipline. + """ + discipline = JuliaImplicitWrapperDiscipline( + julia_file=self.quadratic_file, + julia_type="QuadraticDiscipline" + ) + discipline.setup() + + # Check inputs + self.assertIn("a", discipline._inputs) + self.assertIn("b", discipline._inputs) + self.assertIn("c", discipline._inputs) + + # Check outputs + self.assertIn("x", discipline._outputs) + + # Check residuals + self.assertIn("x", discipline._residuals) + + # Check partials + self.assertIn(("x", "a"), discipline._partials) + self.assertIn(("x", "b"), discipline._partials) + self.assertIn(("x", "c"), discipline._partials) + self.assertIn(("x", "x"), discipline._partials) + + def test_compute_residuals_basic(self): + """ + Test basic compute_residuals functionality. + """ + discipline = JuliaImplicitWrapperDiscipline( + julia_file=self.quadratic_file, + julia_type="QuadraticDiscipline" + ) + discipline.setup() + + inputs = {"a": np.array([1.0]), "b": np.array([2.0]), "c": np.array([-2.0])} + outputs = {"x": np.array([4.0])} + residuals = {"x": np.zeros(1)} + + discipline.compute_residuals(inputs, outputs, residuals) + + # r = a*x^2 + b*x + c = 1*16 + 2*4 + (-2) = 22 + self.assertEqual(residuals["x"][0], 22.0) + + def test_compute_residuals_zero(self): + """ + Test compute_residuals at solution point (residual should be near zero). + """ + discipline = JuliaImplicitWrapperDiscipline( + julia_file=self.quadratic_file, + julia_type="QuadraticDiscipline" + ) + discipline.setup() + + inputs = {"a": np.array([1.0]), "b": np.array([2.0]), "c": np.array([-2.0])} + outputs = {"x": np.array([0.73205081])} + residuals = {"x": np.zeros(1)} + + discipline.compute_residuals(inputs, outputs, residuals) + + # Should be very close to zero at the solution + self.assertAlmostEqual(residuals["x"][0], 0.0, places=6) + + def test_solve_residuals_basic(self): + """ + Test basic solve_residuals functionality. + """ + discipline = JuliaImplicitWrapperDiscipline( + julia_file=self.quadratic_file, + julia_type="QuadraticDiscipline" + ) + discipline.setup() + + inputs = {"a": np.array([1.0]), "b": np.array([2.0]), "c": np.array([-2.0])} + outputs = {"x": np.zeros(1)} + + discipline.solve_residuals(inputs, outputs) + + # Solution: x = (-b + sqrt(b^2 - 4ac)) / 2a = (-2 + sqrt(4+8)) / 2 = 0.73205081 + self.assertAlmostEqual(outputs["x"][0], 0.73205081, places=8) + + def test_solve_residuals_different_inputs(self): + """ + Test solve_residuals with different input values. + """ + discipline = JuliaImplicitWrapperDiscipline( + julia_file=self.quadratic_file, + julia_type="QuadraticDiscipline" + ) + discipline.setup() + + inputs = {"a": np.array([1.0]), "b": np.array([0.0]), "c": np.array([-4.0])} + outputs = {"x": np.zeros(1)} + + discipline.solve_residuals(inputs, outputs) + + # Solution: x = (-0 + sqrt(0+16)) / 2 = 2.0 + self.assertAlmostEqual(outputs["x"][0], 2.0, places=8) + + def test_residual_partials_basic(self): + """ + Test basic residual_partials functionality. + """ + discipline = JuliaImplicitWrapperDiscipline( + julia_file=self.quadratic_file, + julia_type="QuadraticDiscipline" + ) + discipline.setup() + + inputs = {"a": np.array([1.0]), "b": np.array([2.0]), "c": np.array([-2.0])} + outputs = {"x": np.array([4.0])} + partials = { + ("x", "a"): np.zeros(1), + ("x", "b"): np.zeros(1), + ("x", "c"): np.zeros(1), + ("x", "x"): np.zeros(1) + } + + discipline.residual_partials(inputs, outputs, partials) + + # ∂r/∂a = x^2 = 16 + self.assertEqual(partials[("x", "a")][0], 16.0) + # ∂r/∂b = x = 4 + self.assertEqual(partials[("x", "b")][0], 4.0) + # ∂r/∂c = 1 + self.assertEqual(partials[("x", "c")][0], 1.0) + # ∂r/∂x = 2*a*x + b = 2*1*4 + 2 = 10 + self.assertEqual(partials[("x", "x")][0], 10.0) + + def test_residual_partials_different_point(self): + """ + Test residual_partials at different evaluation point. + """ + discipline = JuliaImplicitWrapperDiscipline( + julia_file=self.quadratic_file, + julia_type="QuadraticDiscipline" + ) + discipline.setup() + + inputs = {"a": np.array([2.0]), "b": np.array([1.0]), "c": np.array([-3.0])} + outputs = {"x": np.array([3.0])} + partials = { + ("x", "a"): np.zeros(1), + ("x", "b"): np.zeros(1), + ("x", "c"): np.zeros(1), + ("x", "x"): np.zeros(1) + } + + discipline.residual_partials(inputs, outputs, partials) + + # ∂r/∂a = x^2 = 9 + self.assertEqual(partials[("x", "a")][0], 9.0) + # ∂r/∂b = x = 3 + self.assertEqual(partials[("x", "b")][0], 3.0) + # ∂r/∂c = 1 + self.assertEqual(partials[("x", "c")][0], 1.0) + # ∂r/∂x = 2*a*x + b = 2*2*3 + 1 = 13 + self.assertEqual(partials[("x", "x")][0], 13.0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 9e36c34f3dd443bc52fd18c7523eb5dec527d51a Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Tue, 4 Nov 2025 18:05:40 -0500 Subject: [PATCH 07/15] Enable Julia tests in CI workflow - Add Julia setup using julia-actions/setup-julia@v2 (version 1.10) - Install Julia optional dependencies with pip install -e ".[julia]" - This enables juliacall and pyyaml installation for Julia integration tests Julia tests will now run in CI instead of being skipped --- .github/workflows/tests.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 5f6c9d9..e1306ad 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -31,6 +31,10 @@ jobs: uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} + - name: Set up Julia + uses: julia-actions/setup-julia@v2 + with: + version: '1.10' - name: Install dependencies run: | python -m pip install --upgrade pip @@ -39,7 +43,7 @@ jobs: sudo apt install -y protobuf-compiler - name: Install Package run: | - pip install -e . + pip install -e ".[julia]" - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names From 6670e6f3c9f1e540b790c43fabf90cc3ad458a0e Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Tue, 4 Nov 2025 18:23:29 -0500 Subject: [PATCH 08/15] Add comprehensive documentation for Julia integration This commit adds user-facing documentation for the Julia feature: - Created tutorials/julia_integration.md with complete guide covering: * Installation and setup * Creating explicit and implicit Julia disciplines * YAML configuration format and examples * Serving disciplines with philote-julia-serve CLI * Client integration (Python and OpenMDAO) * Advanced topics (units, arrays, custom options) * Troubleshooting guide - Created api/julia_wrapper.md with full API reference: * JuliaWrapperDiscipline and JuliaImplicitWrapperDiscipline classes * Server functions and configuration classes * CLI documentation * Usage patterns and error handling - Updated _toc.yml to add new sections: * "Language Integrations" section with Julia tutorial * "API Reference" section with Julia wrapper API - Added missing reference labels to enable cross-references: * tutorials/units.md: Added (tutorials:units)= label * tutorials/implicit_disciplines.md: Added (tutorials:implicit)= label All documentation follows existing Jupyter Book style and includes working examples based on the paraboloid.jl and quadratic.jl examples. --- doc/_toc.yml | 10 +- doc/api/julia_wrapper.md | 557 ++++++++++++++++++++++++ doc/tutorials/implicit_disciplines.md | 2 +- doc/tutorials/julia_integration.md | 585 ++++++++++++++++++++++++++ doc/tutorials/units.md | 2 +- 5 files changed, 1151 insertions(+), 5 deletions(-) create mode 100644 doc/api/julia_wrapper.md create mode 100644 doc/tutorials/julia_integration.md diff --git a/doc/_toc.yml b/doc/_toc.yml index 5209752..45c2d26 100644 --- a/doc/_toc.yml +++ b/doc/_toc.yml @@ -21,7 +21,11 @@ parts: - file: tutorials/openmdao - file: tutorials/openmdao_groups -#- caption: API Reference -# chapters: -# - file: api/explicit +- caption: Language Integrations + chapters: + - file: tutorials/julia_integration + +- caption: API Reference + chapters: + - file: api/julia_wrapper diff --git a/doc/api/julia_wrapper.md b/doc/api/julia_wrapper.md new file mode 100644 index 0000000..d9a84d4 --- /dev/null +++ b/doc/api/julia_wrapper.md @@ -0,0 +1,557 @@ +# Julia Wrapper API Reference + +This page documents the Python API for working with Julia disciplines in Philote-Python. + +## Wrapper Classes + +### JuliaWrapperDiscipline + +Python discipline wrapper for explicit Julia disciplines. + +```python +class JuliaWrapperDiscipline(ExplicitDiscipline) +``` + +This class loads and executes Julia code via `juliacall`, presenting a pure Python interface compatible with the Philote-Python server infrastructure. + +#### Constructor + +```python +def __init__(self, julia_file, julia_type, options=None) +``` + +**Parameters:** +- `julia_file` (str): Path to Julia file containing the discipline (absolute or relative) +- `julia_type` (str): Name of the Julia struct to instantiate (e.g., `"ParaboloidDiscipline"`) +- `options` (dict, optional): Dictionary of discipline options to set after initialization + +**Raises:** +- `FileNotFoundError`: If `julia_file` does not exist +- `ValueError`: If `julia_type` cannot be instantiated +- `ImportError`: If `juliacall` is not installed + +**Example:** + +```python +from philote_mdo.wrappers.julia import JuliaWrapperDiscipline + +discipline = JuliaWrapperDiscipline( + julia_file="/path/to/paraboloid.jl", + julia_type="ParaboloidDiscipline", + options={"scale_factor": 2.0} +) +``` + +#### Methods + +##### setup() + +```python +def setup(self) +``` + +Define inputs, outputs, and partials based on Julia discipline metadata. + +This method reads metadata from the Julia discipline (automatically populated during `setup!()`) and configures the Python discipline interface accordingly. + +**Called automatically by the server - users typically don't call this directly.** + +##### compute(inputs, outputs) + +```python +def compute(self, inputs, outputs) +``` + +Compute outputs from inputs by calling the Julia discipline's `compute()` function. + +**Parameters:** +- `inputs` (dict): Dictionary mapping input names to NumPy arrays +- `outputs` (dict): Dictionary to populate with output arrays + +**Raises:** +- `RuntimeError`: If the Julia `compute()` function fails + +##### compute_partials(inputs, partials) + +```python +def compute_partials(self, inputs, partials) +``` + +Compute partial derivatives by calling the Julia discipline's `compute_partials()` function. + +**Parameters:** +- `inputs` (dict): Dictionary mapping input names to NumPy arrays +- `partials` (dict): Dictionary to populate with Jacobian arrays (keyed by `(output_name, input_name)` tuples) + +**Raises:** +- `RuntimeError`: If the Julia `compute_partials()` function fails + +--- + +### JuliaImplicitWrapperDiscipline + +Python discipline wrapper for implicit Julia disciplines. + +```python +class JuliaImplicitWrapperDiscipline(ImplicitDiscipline) +``` + +This class loads and executes Julia implicit disciplines via `juliacall`, supporting residual-based formulations. + +#### Constructor + +```python +def __init__(self, julia_file, julia_type, options=None) +``` + +**Parameters:** +- `julia_file` (str): Path to Julia file containing the implicit discipline +- `julia_type` (str): Name of the Julia struct to instantiate +- `options` (dict, optional): Dictionary of discipline options + +**Raises:** +- `FileNotFoundError`: If `julia_file` does not exist +- `ValueError`: If `julia_type` cannot be instantiated +- `ImportError`: If `juliacall` is not installed + +**Example:** + +```python +from philote_mdo.wrappers.julia import JuliaImplicitWrapperDiscipline + +discipline = JuliaImplicitWrapperDiscipline( + julia_file="/path/to/quadratic.jl", + julia_type="QuadraticDiscipline" +) +``` + +#### Methods + +##### setup() + +```python +def setup(self) +``` + +Define inputs, outputs, residuals, and partials based on Julia discipline metadata. + +**Called automatically by the server.** + +##### compute_residuals(inputs, outputs, residuals) + +```python +def compute_residuals(self, inputs, outputs, residuals) +``` + +Compute residuals by calling the Julia discipline's `compute_residuals()` function. + +**Parameters:** +- `inputs` (dict): Dictionary mapping input names to NumPy arrays +- `outputs` (dict): Dictionary mapping output names to NumPy arrays +- `residuals` (dict): Dictionary to populate with residual arrays + +**Raises:** +- `RuntimeError`: If the Julia `compute_residuals()` function fails + +##### solve_residuals(inputs, outputs) + +```python +def solve_residuals(self, inputs, outputs) +``` + +Solve for outputs that drive residuals to zero by calling the Julia discipline's `solve_residuals()` function. + +**Parameters:** +- `inputs` (dict): Dictionary mapping input names to NumPy arrays +- `outputs` (dict): Dictionary mapping output names to NumPy arrays (modified in place) + +**Raises:** +- `RuntimeError`: If the Julia `solve_residuals()` function fails + +##### residual_partials(inputs, outputs, partials) + +```python +def residual_partials(self, inputs, outputs, partials) +``` + +Compute residual partial derivatives by calling the Julia discipline's `residual_partials()` function. + +**Parameters:** +- `inputs` (dict): Dictionary mapping input names to NumPy arrays +- `outputs` (dict): Dictionary mapping output names to NumPy arrays +- `partials` (dict): Dictionary to populate with Jacobian arrays (keyed by `(residual_name, variable_name)` tuples) + +**Raises:** +- `RuntimeError`: If the Julia `residual_partials()` function fails + +--- + +## Server Functions + +### serve_explicit_discipline + +```python +def serve_explicit_discipline(config: PhiloteConfig) +``` + +Start a gRPC server hosting an explicit Julia discipline. + +**Parameters:** +- `config` (PhiloteConfig): Configuration object with discipline and server settings + +**Example:** + +```python +from philote_mdo.wrappers.julia import serve_explicit_discipline, PhiloteConfig + +config = PhiloteConfig.from_yaml("config.yaml") +serve_explicit_discipline(config) +``` + +This function: +1. Creates a `JuliaWrapperDiscipline` from the configuration +2. Creates a gRPC server with the specified settings +3. Attaches the discipline to the server +4. Starts the server and waits for termination + +**Blocks until server is stopped (Ctrl+C).** + +### serve_implicit_discipline + +```python +def serve_implicit_discipline(config: PhiloteConfig) +``` + +Start a gRPC server hosting an implicit Julia discipline. + +**Parameters:** +- `config` (PhiloteConfig): Configuration object with discipline and server settings + +**Example:** + +```python +from philote_mdo.wrappers.julia import serve_implicit_discipline, PhiloteConfig + +config = PhiloteConfig.from_yaml("quadratic_config.yaml") +serve_implicit_discipline(config) +``` + +**Blocks until server is stopped (Ctrl+C).** + +--- + +## Configuration Classes + +### PhiloteConfig + +Complete configuration for a Philote-Julia server. + +```python +@dataclass +class PhiloteConfig: + discipline: DisciplineConfig + server: ServerConfig +``` + +#### Class Methods + +##### from_yaml(yaml_path) + +```python +@classmethod +def from_yaml(cls, yaml_path: str) -> PhiloteConfig +``` + +Load configuration from a YAML file. + +**Parameters:** +- `yaml_path` (str): Path to YAML configuration file + +**Returns:** +- `PhiloteConfig`: Configuration object + +**Raises:** +- `FileNotFoundError`: If configuration file or Julia file doesn't exist +- `ValueError`: If configuration is invalid + +**Example:** + +```python +config = PhiloteConfig.from_yaml("/path/to/config.yaml") +``` + +**Note:** Relative paths in `julia_file` are resolved relative to the YAML file's directory. + +##### to_yaml(yaml_path) + +```python +def to_yaml(self, yaml_path: str) +``` + +Write configuration to a YAML file. + +**Parameters:** +- `yaml_path` (str): Path to write YAML configuration + +**Example:** + +```python +config.to_yaml("output_config.yaml") +``` + +--- + +### DisciplineConfig + +Configuration for a Julia discipline. + +```python +@dataclass +class DisciplineConfig: + kind: str # "explicit" or "implicit" + julia_file: str # Path to .jl file + julia_type: str # Julia struct name + options: Dict[str, any] = field(default_factory=dict) +``` + +**Validation:** +- `kind` must be `"explicit"` or `"implicit"` +- `julia_file` and `julia_type` are required + +**Example:** + +```python +from philote_mdo.wrappers.julia import DisciplineConfig + +disc_config = DisciplineConfig( + kind="explicit", + julia_file="paraboloid.jl", + julia_type="ParaboloidDiscipline", + options={"scale_factor": 2.0} +) +``` + +--- + +### ServerConfig + +Configuration for the gRPC server. + +```python +@dataclass +class ServerConfig: + address: str = "[::]:50051" + max_workers: int = 10 +``` + +**Validation:** +- `max_workers` must be >= 1 + +**Example:** + +```python +from philote_mdo.wrappers.julia import ServerConfig + +server_config = ServerConfig( + address="localhost:50052", + max_workers=4 +) +``` + +--- + +## Command-Line Interface + +### philote-julia-serve + +Command-line tool for serving Julia disciplines. + +```bash +philote-julia-serve +``` + +**Arguments:** +- `config.yaml`: Path to YAML configuration file + +**Example:** + +```bash +philote-julia-serve paraboloid_config.yaml +``` + +**Output:** + +``` +====================================================================== + Philote Julia Server (Python wrapper + juliacall) +====================================================================== + +Configuration: + Julia file: /path/to/paraboloid.jl + Julia type: ParaboloidDiscipline + Server addr: [::]:50051 + Max workers: 10 + +Loading Julia discipline from: /path/to/paraboloid.jl +✓ Julia discipline loaded: ParaboloidDiscipline + +✓ Server started successfully! + Listening on: [::]:50051 + +Press Ctrl+C to stop the server. +====================================================================== +``` + +**Implementation:** + +The CLI is defined in `philote_mdo/wrappers/julia/cli.py` and registered as a console script in `pyproject.toml`: + +```toml +[project.scripts] +philote-julia-serve = "philote_mdo.wrappers.julia.cli:main" +``` + +--- + +## Usage Patterns + +### Creating a Server Programmatically + +Instead of using the CLI, you can create servers programmatically: + +```python +from concurrent import futures +import grpc +from philote_mdo.wrappers.julia import JuliaWrapperDiscipline +import philote_mdo.general as pmdo + +# Create the wrapper discipline +discipline_wrapper = JuliaWrapperDiscipline( + julia_file="paraboloid.jl", + julia_type="ParaboloidDiscipline", + options={"scale_factor": 2.0} +) + +# Create gRPC server +server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + +# Attach discipline to server +discipline_server = pmdo.ExplicitServer(discipline=discipline_wrapper) +discipline_server.attach_to_server(server) + +# Start server +server.add_insecure_port("[::]:50051") +server.start() +print("Server running...") + +# Wait for termination +try: + server.wait_for_termination() +except KeyboardInterrupt: + server.stop(grace=2.0) +``` + +### Using with Context Managers + +For testing or temporary servers: + +```python +import grpc +from philote_mdo.general import RemoteExplicitDiscipline + +# Connect to Julia discipline server +with grpc.insecure_channel('localhost:50051') as channel: + discipline = RemoteExplicitDiscipline(channel) + + # Use the discipline + outputs = discipline.compute({'x': [1.0], 'y': [2.0]}) + print(outputs) +``` + +### Batch Processing + +Serve multiple Julia disciplines on different ports: + +```python +import subprocess +import threading + +def serve_discipline(config_file): + subprocess.run(['philote-julia-serve', config_file]) + +# Start multiple servers in parallel +threads = [ + threading.Thread(target=serve_discipline, args=('config1.yaml',)), + threading.Thread(target=serve_discipline, args=('config2.yaml',)), +] + +for t in threads: + t.start() + +for t in threads: + t.join() +``` + +--- + +## Error Handling + +Common errors and their meanings: + +### FileNotFoundError + +```python +FileNotFoundError: Julia file not found: /path/to/file.jl +``` + +**Cause:** The specified `.jl` file does not exist. + +**Solution:** Check the path in your configuration file. Ensure the file exists and is readable. + +### ValueError: Failed to instantiate Julia type + +```python +ValueError: Failed to instantiate Julia type 'ParaboloidDiscipline' +``` + +**Cause:** The Julia type name doesn't exist in the loaded file. + +**Solution:** +- Verify the struct name matches exactly (case-sensitive) +- Ensure the struct is defined in the `.jl` file +- Check for typos in the configuration + +### RuntimeError: Error in Julia compute + +```python +RuntimeError: Error in Julia compute: MethodError(...) +``` + +**Cause:** The Julia `compute()` function raised an error. + +**Solution:** +- Check Julia function implementation for bugs +- Verify input types and shapes match expectations +- Review error message for Julia-specific details + +### ImportError: Cannot import juliacall + +```python +ImportError: Cannot import juliacall +``` + +**Cause:** The `juliacall` package is not installed. + +**Solution:** Install Julia dependencies: +```bash +pip install philote-mdo[julia] +``` + +--- + +## See Also + +- {ref}`tutorials:julia` - Julia integration tutorial +- {ref}`tutorials:explicit` - Creating explicit disciplines +- {ref}`tutorials:implicit` - Creating implicit disciplines +- [Philote.jl Repository](https://github.com/MDO-Standards/Philote-Julia) - Julia package documentation diff --git a/doc/tutorials/implicit_disciplines.md b/doc/tutorials/implicit_disciplines.md index c391b17..3800755 100644 --- a/doc/tutorials/implicit_disciplines.md +++ b/doc/tutorials/implicit_disciplines.md @@ -1,4 +1,4 @@ - +(tutorials:implicit)= # Creating Implicit Disciplines This guide explains how to create, serve, and use implicit disciplines in Philote. Implicit disciplines solve equations of the form R(inputs, outputs) = 0, where outputs are implicitly defined by the inputs through residual equations. Unlike explicit disciplines that compute outputs directly, implicit disciplines require solving nonlinear equations. diff --git a/doc/tutorials/julia_integration.md b/doc/tutorials/julia_integration.md new file mode 100644 index 0000000..5155c11 --- /dev/null +++ b/doc/tutorials/julia_integration.md @@ -0,0 +1,585 @@ +(tutorials:julia)= +# Julia Integration + +Philote-Python supports serving disciplines written in pure Julia via the Philote.jl package. This integration enables Julia developers to leverage Julia's high-performance numerical computing capabilities while using Python's proven gRPC server infrastructure. + +:::{note} +This guide assumes you have basic familiarity with both Julia and the Philote discipline concept. If you're new to Philote, start with the {ref}`tutorials:quick_start` guide. +::: + +## Overview + +The Julia integration uses a bridge architecture: + +1. **Julia developers** write disciplines using the [Philote.jl](https://github.com/MDO-Standards/Philote-Julia) module +2. **Python wrapper classes** in Philote-Python load and execute Julia code via `juliacall` +3. **Python gRPC servers** serve these Julia disciplines to any Philote client + +This approach combines Julia's computational performance with Python's mature gRPC infrastructure, enabling zero-copy data transfer between Python and Julia. + +## Installation + +To use Julia disciplines with Philote-Python, you need to install the Julia extra dependencies: + +```bash +pip install philote-mdo[julia] +``` + +This installs the required dependencies: +- `juliacall` - Python-Julia bridge for zero-copy interop +- `pyyaml` - YAML configuration file parsing + +You'll also need the Philote.jl Julia package. The wrapper will automatically load it from your Julia environment, or you can install it manually: + +```julia +using Pkg +Pkg.add(url="https://github.com/MDO-Standards/Philote-Julia") +``` + +## Creating Julia Disciplines + +Julia disciplines are created by defining a struct that inherits from one of Philote.jl's abstract types and implementing the required interface methods. + +### Explicit Disciplines + +Explicit disciplines compute outputs directly from inputs: `outputs = f(inputs)`. + +Here's a simple example implementing the paraboloid function: + +\begin{align} +f(x,y) &= (x-3)^2 + x y + (y+4)^2 - 3 +\end{align} + +```julia +using Philote + +# Define a struct that inherits from ExplicitDiscipline +mutable struct ParaboloidDiscipline <: Philote.ExplicitDiscipline + scale_factor::Float64 + offset::Float64 + + function ParaboloidDiscipline() + new(1.0, 0.0) + end +end + +# Declare inputs, outputs, options, and partials +function Philote.setup!(discipline::ParaboloidDiscipline) + # Declare options + Philote.add_option!(discipline, "scale_factor", "float") + Philote.add_option!(discipline, "offset", "float") + + # Declare inputs + Philote.add_input!(discipline, "x", [1], "m") + Philote.add_input!(discipline, "y", [1], "m") + + # Declare outputs + Philote.add_output!(discipline, "f_xy", [1], "m**2") + + # Declare partials (gradients) + Philote.declare_partials!(discipline, "f_xy", "x") + Philote.declare_partials!(discipline, "f_xy", "y") + + # Set metadata + meta = Philote.get_metadata(discipline) + meta.name = "ParaboloidDiscipline" + meta.version = "0.1.0" +end + +# Compute outputs from inputs +function Philote.compute(discipline::ParaboloidDiscipline, + inputs::Dict{String, <:AbstractArray{Float64}}) + x = inputs["x"][1] + y = inputs["y"][1] + + f_xy = (x - 3.0)^2 + x * y + (y + 4.0)^2 - 3.0 + f_xy = discipline.scale_factor * f_xy + discipline.offset + + return Dict("f_xy" => [f_xy]) +end + +# Compute analytical gradients +function Philote.compute_partials(discipline::ParaboloidDiscipline, + inputs::Dict{String, <:AbstractArray{Float64}}) + x = inputs["x"][1] + y = inputs["y"][1] + + df_dx = discipline.scale_factor * (2.0 * (x - 3.0) + y) + df_dy = discipline.scale_factor * (2.0 * (y + 4.0) + x) + + return Dict( + "f_xy" => Dict( + "x" => [df_dx], + "y" => [df_dy] + ) + ) +end + +# Set discipline options from configuration +function Philote.set_options!(discipline::ParaboloidDiscipline, + options::Dict{String, <:Any}) + if haskey(options, "scale_factor") + discipline.scale_factor = Float64(options["scale_factor"]) + end + if haskey(options, "offset") + discipline.offset = Float64(options["offset"]) + end +end +``` + +#### Required Methods for Explicit Disciplines + +- `setup!(discipline)` - Declare inputs, outputs, options, and partials +- `compute(discipline, inputs)` - Compute outputs from inputs, returns `Dict{String, Array}` +- `compute_partials(discipline, inputs)` - Compute gradients, returns nested `Dict` +- `set_options!(discipline, options)` - Set custom options (optional but recommended) + +### Implicit Disciplines + +Implicit disciplines solve residual equations where outputs must satisfy `R(inputs, outputs) = 0` rather than being directly computed. + +Here's an example that solves a quadratic equation: + +\begin{align} +a x^2 + b x + c &= 0 +\end{align} + +```julia +using Philote + +mutable struct QuadraticDiscipline <: Philote.ImplicitDiscipline + tolerance::Float64 + + function QuadraticDiscipline() + new(1e-10) + end +end + +function Philote.setup!(discipline::QuadraticDiscipline) + # Define inputs (coefficients) + Philote.add_input!(discipline, "a", [1], "unitless") + Philote.add_input!(discipline, "b", [1], "unitless") + Philote.add_input!(discipline, "c", [1], "unitless") + + # Define output (solution) + Philote.add_output!(discipline, "x", [1], "unitless") + + # Define residual (r = a*x^2 + b*x + c) + Philote.add_residual!(discipline, "r", [1], "unitless") + + # Declare partial derivatives + Philote.declare_partials!(discipline, "r", "a") + Philote.declare_partials!(discipline, "r", "b") + Philote.declare_partials!(discipline, "r", "c") + Philote.declare_partials!(discipline, "r", "x") +end + +# Compute residuals given inputs and outputs +function Philote.compute_residuals(discipline::QuadraticDiscipline, + inputs::Dict{String,Array}, + outputs::Dict{String,Array}) + a = inputs["a"][1] + b = inputs["b"][1] + c = inputs["c"][1] + x = outputs["x"][1] + + # Residual: r = a*x^2 + b*x + c + r = a * x^2 + b * x + c + + return Dict("r" => [r]) +end + +# Solve for outputs that satisfy residuals +function Philote.solve_residuals(discipline::QuadraticDiscipline, + inputs::Dict{String,Array}, + outputs::Dict{String,Array}) + a = inputs["a"][1] + b = inputs["b"][1] + c = inputs["c"][1] + + # Solve using quadratic formula + discriminant = b^2 - 4*a*c + + if discriminant < 0 + error("No real solution: discriminant is negative") + end + + x = (-b + sqrt(discriminant)) / (2*a) + + # Update output in place + outputs["x"][1] = x +end + +# Compute partial derivatives of residuals +function Philote.residual_partials(discipline::QuadraticDiscipline, + inputs::Dict{String,Array}, + outputs::Dict{String,Array}) + a = inputs["a"][1] + b = inputs["b"][1] + x = outputs["x"][1] + + # Compute partials of r = a*x^2 + b*x + c + dr_da = x^2 + dr_db = x + dr_dc = 1.0 + dr_dx = 2*a*x + b + + return Dict( + "r" => Dict( + "a" => reshape([dr_da], 1, 1), + "b" => reshape([dr_db], 1, 1), + "c" => reshape([dr_dc], 1, 1), + "x" => reshape([dr_dx], 1, 1) + ) + ) +end +``` + +#### Required Methods for Implicit Disciplines + +- `setup!(discipline)` - Declare inputs, outputs, residuals, and partials +- `compute_residuals(discipline, inputs, outputs)` - Evaluate residual equations +- `solve_residuals(discipline, inputs, outputs)` - Solve for outputs (modifies `outputs` in-place) +- `residual_partials(discipline, inputs, outputs)` - Compute Jacobian of residuals +- `set_options!(discipline, options)` - Set custom options (optional) + +## Configuration Files + +Julia disciplines are configured using YAML files that specify the discipline type, file location, and server settings. + +### Explicit Discipline Configuration + +Here's a configuration for the paraboloid example: + +```yaml +# paraboloid.yaml +discipline: + # Type of discipline: "explicit" or "implicit" + kind: explicit + + # Path to Julia file (relative to config or absolute) + julia_file: paraboloid.jl + + # Name of the Julia type/struct to instantiate + julia_type: ParaboloidDiscipline + + # Optional: discipline-specific options + options: + scale_factor: 2.0 + offset: 10.0 + +server: + # gRPC server address + # Use [::]:PORT for all interfaces (IPv4 and IPv6) + # Use localhost:PORT for localhost only + address: "[::]:50051" + + # Maximum number of worker threads + max_workers: 10 +``` + +### Implicit Discipline Configuration + +Configuration for the quadratic solver: + +```yaml +# quadratic.yaml +discipline: + kind: implicit + julia_file: quadratic.jl + julia_type: QuadraticDiscipline + +server: + address: "[::]:50052" + max_workers: 10 +``` + +### Configuration Schema + +**Discipline Section:** +- `kind` (required): `"explicit"` or `"implicit"` +- `julia_file` (required): Path to `.jl` file containing the discipline +- `julia_type` (required): Name of the Julia struct to instantiate +- `options` (optional): Dictionary of custom options passed to `set_options!()` + +**Server Section:** +- `address` (required): gRPC server address (e.g., `"[::]:50051"`) +- `max_workers` (optional): Thread pool size (default: 10) + +:::{note} +Paths in `julia_file` can be relative to the configuration file or absolute. The wrapper automatically resolves relative paths. +::: + +## Serving Julia Disciplines + +Once you have a Julia discipline and configuration file, you can serve it using the `philote-julia-serve` command-line tool. + +### Starting a Server + +```bash +philote-julia-serve config.yaml +``` + +This will: +1. Load the Julia discipline from the specified file +2. Instantiate the Julia type +3. Apply any options from the configuration +4. Start a gRPC server at the configured address + +Example output: +``` +Loading Julia discipline from: /path/to/paraboloid.jl +Instantiating Julia type: ParaboloidDiscipline +Starting gRPC server at [::]:50051 +Server started successfully. Press Ctrl+C to stop. +``` + +### Stopping a Server + +Press `Ctrl+C` to gracefully shut down the server: + +``` +^C +Shutting down server... +Server stopped. +``` + +## Connecting Clients + +Once the Julia discipline server is running, you can connect to it from any Philote client (Python, C++, or other implementations). + +### Python Client Example + +```python +import grpc +from philote_mdo.general import RemoteExplicitDiscipline +import numpy as np + +# Connect to the Julia discipline server +channel = grpc.insecure_channel('localhost:50051') +discipline = RemoteExplicitDiscipline(channel) + +# Get discipline metadata +metadata = discipline.get_metadata() +print(f"Connected to: {metadata.name} v{metadata.version}") +print(f"Inputs: {list(metadata.inputs.keys())}") +print(f"Outputs: {list(metadata.outputs.keys())}") + +# Evaluate the discipline +inputs = { + 'x': np.array([4.0]), + 'y': np.array([2.0]) +} +outputs = discipline.compute(inputs) +print(f"f_xy = {outputs['f_xy'][0]}") + +# Compute gradients +partials = discipline.compute_partials(inputs) +print(f"∂f_xy/∂x = {partials['f_xy', 'x'][0]}") +print(f"∂f_xy/∂y = {partials['f_xy', 'y'][0]}") + +# Clean up +channel.close() +``` + +### OpenMDAO Integration + +Julia disciplines work seamlessly with OpenMDAO using the `RemoteExplicitComponent` or `RemoteImplicitComponent` wrappers: + +```python +import openmdao.api as om +from philote_mdo.openmdao import RemoteExplicitComponent + +# Create an OpenMDAO problem +prob = om.Problem() + +# Add the Julia discipline as a remote component +prob.model.add_subsystem( + 'paraboloid', + RemoteExplicitComponent(address='localhost:50051'), + promotes=['*'] +) + +# Add optimizer +prob.driver = om.ScipyOptimizeDriver() +prob.driver.options['optimizer'] = 'SLSQP' + +# Add design variables and objective +prob.model.add_design_var('x', lower=-10, upper=10) +prob.model.add_design_var('y', lower=-10, upper=10) +prob.model.add_objective('f_xy') + +# Set up and run optimization +prob.setup() +prob.set_val('x', 3.0) +prob.set_val('y', -4.0) + +prob.run_driver() + +print(f"Optimal x: {prob.get_val('x')[0]:.4f}") +print(f"Optimal y: {prob.get_val('y')[0]:.4f}") +print(f"Minimum f_xy: {prob.get_val('f_xy')[0]:.4f}") +``` + +For implicit disciplines, use `RemoteImplicitComponent` instead. See {ref}`tutorials:openmdao` for more details on OpenMDAO integration. + +## Advanced Topics + +### Units Support + +Julia disciplines fully support unit specifications. All inputs, outputs, and residuals can have units specified using the same syntax as OpenMDAO (e.g., `"m"`, `"kg"`, `"m/s**2"`). + +```julia +Philote.add_input!(discipline, "velocity", [3], "m/s") +Philote.add_output!(discipline, "force", [3], "N") +``` + +See {ref}`tutorials:units` for more information about unit handling in Philote. + +### Shape and Array Variables + +Julia disciplines support multidimensional arrays. Specify shapes as tuples when declaring variables: + +```julia +# Vector input (3 elements) +Philote.add_input!(discipline, "position", [3], "m") + +# Matrix output (3x3) +Philote.add_output!(discipline, "stiffness_matrix", [3, 3], "N/m") +``` + +Input and output dictionaries contain Julia `Array` objects that can be indexed and manipulated using standard Julia array operations. + +### Custom Options + +The options system allows you to parameterize your disciplines. Options declared in `setup!()` can be set via the YAML configuration: + +```julia +# In Julia discipline +function Philote.setup!(discipline::MyDiscipline) + Philote.add_option!(discipline, "tolerance", "float") + Philote.add_option!(discipline, "max_iterations", "int") + Philote.add_option!(discipline, "method", "string") + # ... +end + +function Philote.set_options!(discipline::MyDiscipline, options::Dict) + if haskey(options, "tolerance") + discipline.tolerance = Float64(options["tolerance"]) + end + if haskey(options, "max_iterations") + discipline.max_iterations = Int(options["max_iterations"]) + end + if haskey(options, "method") + discipline.method = String(options["method"]) + end +end +``` + +```yaml +# In YAML configuration +discipline: + # ... + options: + tolerance: 1.0e-8 + max_iterations: 100 + method: "newton" +``` + +### Metadata Discovery + +The Julia wrapper automatically discovers the discipline interface by calling `setup!()` and inspecting the metadata. This means clients can query the discipline to learn about its inputs, outputs, and capabilities without prior knowledge. + +```julia +# Julia side - metadata is automatically built during setup!() +meta = Philote.get_metadata(discipline) +meta.name = "MyCustomDiscipline" +meta.version = "1.0.0" +meta.description = "A custom Julia discipline" +``` + +```python +# Python client side - query metadata +metadata = remote_discipline.get_metadata() +print(f"Name: {metadata.name}") +print(f"Inputs: {metadata.inputs}") +print(f"Outputs: {metadata.outputs}") +``` + +## Troubleshooting + +### Common Issues + +**Julia file not found:** +``` +FileNotFoundError: Julia file not found: paraboloid.jl +``` +- Ensure the `julia_file` path is correct (relative to config file or absolute) +- Check that the `.jl` file exists and is readable + +**Invalid Julia type:** +``` +AttributeError: Julia module has no attribute 'ParaboloidDiscipline' +``` +- Verify the `julia_type` name matches the struct name in your `.jl` file +- Ensure the struct is exported or fully qualified + +**Port already in use:** +``` +RuntimeError: Failed to bind to port 50051 +``` +- Another process is using the port +- Change the `address` port number in your configuration +- Stop the other server or use `lsof -i :50051` to identify it + +**Missing dependencies:** +``` +ModuleNotFoundError: No module named 'juliacall' +``` +- Install Julia dependencies: `pip install philote-mdo[julia]` + +### Debugging Tips + +1. **Test your Julia discipline standalone** before serving it: + ```julia + include("paraboloid.jl") + d = ParaboloidDiscipline() + Philote.setup!(d) + inputs = Dict("x" => [1.0], "y" => [2.0]) + outputs = Philote.compute(d, inputs) + println(outputs) + ``` + +2. **Check server logs** for detailed error messages when the server fails to start + +3. **Verify gRPC connectivity** using a simple client test before integrating with OpenMDAO + +4. **Use print statements** in Julia methods during development (they'll appear in server logs) + +## Complete Examples + +Complete working examples are provided in the Philote-Python repository: + +- **Explicit discipline:** `examples/julia/paraboloid.jl` +- **Implicit discipline:** `examples/julia/quadratic.jl` +- **Configurations:** `examples/julia/configs/*.yaml` + +These examples demonstrate best practices and can serve as templates for your own Julia disciplines. + +## Summary + +The Julia integration enables you to: + +- Write high-performance disciplines in pure Julia +- Leverage Julia's numerical computing strengths +- Serve disciplines via Python's gRPC infrastructure +- Integrate seamlessly with OpenMDAO and other Philote clients +- Achieve zero-copy data transfer between Python and Julia + +Key steps: +1. Write a Julia discipline implementing the Philote.jl interface +2. Create a YAML configuration file +3. Serve the discipline with `philote-julia-serve` +4. Connect from any Philote client + +For more information on the Philote.jl API, see the [Philote.jl documentation](https://github.com/MDO-Standards/Philote-Julia). diff --git a/doc/tutorials/units.md b/doc/tutorials/units.md index 7ccf2ef..f591b7c 100644 --- a/doc/tutorials/units.md +++ b/doc/tutorials/units.md @@ -1,4 +1,4 @@ - +(tutorials:units)= # Unit Definitions Philote's unit system overlaps with that of OpenMDAO. The main difference is From 118f9d48d690bcef52794243e61edc375d6b159d Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Tue, 4 Nov 2025 18:25:56 -0500 Subject: [PATCH 09/15] Update CHANGELOG with Julia documentation additions --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe35db5..c5969f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CLI entry point (philote-julia-serve command) for serving Julia disciplines - Optional 'julia' dependencies group (juliacall and pyyaml) - Example Julia disciplines (paraboloid, quadratic) with configurations +- Comprehensive test suite for Julia integration (unit and integration tests) +- Julia integration tutorial (doc/tutorials/julia_integration.md) +- Julia wrapper API reference documentation (doc/api/julia_wrapper.md) - Automated release workflow using GitHub Actions - Copyright update script for Python source files @@ -23,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Convert CHANGELOG to Keep a Changelog format with [Unreleased] section - CHANGELOG now follows semantic versioning categories (Added/Changed/Fixed/Removed) +- Updated documentation table of contents with new "Language Integrations" and "API Reference" sections +- Added missing reference labels to tutorials/units.md and tutorials/implicit_disciplines.md for cross-referencing ## [0.7.0] - 2024-12-18 From a6b316f03bf67b6b911a16eb68848963e5fe60a0 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Tue, 4 Nov 2025 18:39:11 -0500 Subject: [PATCH 10/15] Install Philote.jl from GitHub in CI workflow Add step to install Philote.jl package from GitHub repository during CI builds. This is a temporary workaround until the package is registered in Julia's General registry. The package is installed as a development dependency using Pkg.develop() which clones the repository and makes it available for Julia's 'using Philote' statements. Fixes Julia test failures where tests were failing with: "ArgumentError: Package Philote not found in current path" --- .github/workflows/tests.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index e1306ad..7390f8d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -44,6 +44,9 @@ jobs: - name: Install Package run: | pip install -e ".[julia]" + - name: Install Philote.jl from GitHub + run: | + julia -e 'using Pkg; Pkg.develop(url="https://github.com/MDO-Standards/Philote.jl.git")' - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names From ac1b1867bf83cb8c07735fc9158399335cd08394 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Tue, 4 Nov 2025 18:49:52 -0500 Subject: [PATCH 11/15] Add Philote.jl precompilation step to CI workflow Add dedicated precompilation and verification step after installing Philote.jl from GitHub. This ensures the package is fully compiled before tests run, preventing long compilation delays during test execution that could cause timeouts. The precompilation step: - Runs Pkg.precompile() to compile all packages - Verifies Philote.jl can be loaded with 'using Philote' - Provides clear feedback if installation was successful This should resolve test hanging issues where juliacall was compiling the Philote package on first use during tests. --- .github/workflows/tests.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 7390f8d..d87d72c 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -47,6 +47,9 @@ jobs: - name: Install Philote.jl from GitHub run: | julia -e 'using Pkg; Pkg.develop(url="https://github.com/MDO-Standards/Philote.jl.git")' + - name: Precompile Philote.jl and verify installation + run: | + julia -e 'using Pkg; Pkg.precompile(); using Philote; println("✓ Philote.jl loaded successfully")' - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names From 14c905c15c9f0c064d21d8655476d0308bfb0e79 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Tue, 4 Nov 2025 19:00:30 -0500 Subject: [PATCH 12/15] Add juliacall-specific Philote.jl installation step juliacall uses its own separate Julia environment (pyjuliapkg) that is independent from the system Julia installation. This was causing tests to hang for 7+ minutes as juliacall tried to install and compile Philote.jl during the first test run. This commit adds a dedicated step that initializes juliacall's Julia environment and installs Philote.jl into it before tests run. This ensures: - Philote.jl is available in juliacall's environment - All package compilation happens upfront, not during tests - Tests can run immediately without waiting for package installation This should resolve the test hanging issue completely. --- .github/workflows/tests.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d87d72c..7aab880 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -50,6 +50,9 @@ jobs: - name: Precompile Philote.jl and verify installation run: | julia -e 'using Pkg; Pkg.precompile(); using Philote; println("✓ Philote.jl loaded successfully")' + - name: Configure juliacall to use installed Philote.jl + run: | + python -c "from juliacall import Main as jl; jl.seval('using Pkg; Pkg.develop(url=\"https://github.com/MDO-Standards/Philote.jl.git\")'); print('✓ Philote.jl configured for juliacall')" - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names From ac9724f95d154e3f3ea2f794ee65520712973274 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Tue, 4 Nov 2025 19:07:50 -0500 Subject: [PATCH 13/15] Properly pre-warm juliacall environment for Julia integration tests Previous attempts were installing Philote.jl in the system Julia environment, but juliacall uses its own separate pyjuliapkg environment. This caused tests to hang as juliacall tried to install packages during the first test run. This commit: - Removes separate system Julia installation steps - Pre-warms juliacall's Julia environment by importing it from Python - Installs Philote.jl directly into juliacall's environment - Precompiles all packages before tests run - Verifies Philote.jl loads correctly This ensures all package installation and compilation happens in a dedicated setup step with clear progress output, preventing test hangs. --- .github/workflows/tests.yaml | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 7aab880..410c8e9 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -44,15 +44,24 @@ jobs: - name: Install Package run: | pip install -e ".[julia]" - - name: Install Philote.jl from GitHub + - name: Pre-warm juliacall Julia environment with Philote.jl run: | - julia -e 'using Pkg; Pkg.develop(url="https://github.com/MDO-Standards/Philote.jl.git")' - - name: Precompile Philote.jl and verify installation - run: | - julia -e 'using Pkg; Pkg.precompile(); using Philote; println("✓ Philote.jl loaded successfully")' - - name: Configure juliacall to use installed Philote.jl - run: | - python -c "from juliacall import Main as jl; jl.seval('using Pkg; Pkg.develop(url=\"https://github.com/MDO-Standards/Philote.jl.git\")'); print('✓ Philote.jl configured for juliacall')" + python -c " + import sys + print('Initializing juliacall Julia environment...') + from juliacall import Main as jl + print('✓ juliacall initialized') + print('Installing Philote.jl into juliacall environment...') + jl.seval('using Pkg') + jl.seval('Pkg.develop(url=\"https://github.com/MDO-Standards/Philote.jl.git\")') + print('✓ Philote.jl installed') + print('Precompiling packages...') + jl.seval('Pkg.precompile()') + print('✓ Packages precompiled') + print('Testing Philote load...') + jl.seval('using Philote') + print('✓ Philote.jl ready for tests') + " - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names From 3c213844fea1ca53d86c73f47b2c362138f3b5e8 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Tue, 4 Nov 2025 19:15:20 -0500 Subject: [PATCH 14/15] Add timeouts to Julia setup and test steps Add timeout-minutes directives to prevent CI from hanging indefinitely: - Pre-warm step: 5 minute timeout (package installation + compilation) - Test execution: 3 minute timeout (tests should complete quickly) Also added flush=True to all print statements in the pre-warm step to ensure progress output is immediately visible in CI logs, making it easier to diagnose where hangs occur. --- .github/workflows/tests.yaml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 410c8e9..83324aa 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -45,22 +45,23 @@ jobs: run: | pip install -e ".[julia]" - name: Pre-warm juliacall Julia environment with Philote.jl + timeout-minutes: 5 run: | python -c " import sys - print('Initializing juliacall Julia environment...') + print('Initializing juliacall Julia environment...', flush=True) from juliacall import Main as jl - print('✓ juliacall initialized') - print('Installing Philote.jl into juliacall environment...') + print('✓ juliacall initialized', flush=True) + print('Installing Philote.jl into juliacall environment...', flush=True) jl.seval('using Pkg') jl.seval('Pkg.develop(url=\"https://github.com/MDO-Standards/Philote.jl.git\")') - print('✓ Philote.jl installed') - print('Precompiling packages...') + print('✓ Philote.jl installed', flush=True) + print('Precompiling packages...', flush=True) jl.seval('Pkg.precompile()') - print('✓ Packages precompiled') - print('Testing Philote load...') + print('✓ Packages precompiled', flush=True) + print('Testing Philote load...', flush=True) jl.seval('using Philote') - print('✓ Philote.jl ready for tests') + print('✓ Philote.jl ready for tests', flush=True) " - name: Lint with flake8 run: | @@ -69,6 +70,7 @@ jobs: # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Run Unit Tests with Coverage + timeout-minutes: 3 run: | python -m coverage run --omit=philote_mdo/generated -m unittest discover -v -s tests -p 'test_*.py' - name: Upload Test Coverage Report to Codecov From 0f408e4d7344da9f6d3e9d3af22ad246f7c67110 Mon Sep 17 00:00:00 2001 From: Christopher Lupp Date: Tue, 4 Nov 2025 19:23:49 -0500 Subject: [PATCH 15/15] Reduce test timeout to 30 seconds Tests complete in ~2 seconds locally, so 30 seconds (15x buffer) is more than sufficient. This will fail fast if Julia tests hang, making it easier to debug the issue. --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 83324aa..0f474d4 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -70,7 +70,7 @@ jobs: # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Run Unit Tests with Coverage - timeout-minutes: 3 + timeout-minutes: 0.5 run: | python -m coverage run --omit=philote_mdo/generated -m unittest discover -v -s tests -p 'test_*.py' - name: Upload Test Coverage Report to Codecov